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

1705 lines
93 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" :class="{ 'evidence-active': activeStage === 'evidence' }">
<view class="box-select-wrap">
<button class="box-select-btn" @tap="toggleBoxDropdown">
<text class="box-select-summary">{{ containerReference(currentBox) }}</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.taskId || box.cabinetNo"
class="box-option"
:class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }"
@tap="requestBoxSwitch(index)"
>
<view><text class="box-option-number">{{ containerReference(box) }}</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="stage-switch" role="tablist" aria-label="作业阶段">
<button class="stage-tab" :class="{ active: activeStage === 'picking' }" role="tab" :aria-selected="activeStage === 'picking'" @tap="selectStage('picking')">拣货作业</button>
<button class="stage-tab" :class="{ active: activeStage === 'evidence' }" role="tab" :aria-selected="activeStage === 'evidence'" @tap="selectStage('evidence')">装柜取证</button>
</view>
<view v-if="activeStage === 'picking'" class="picking-stage">
<view class="scan-section">
<view class="scan-row">
<ScanTargetInput v-model="scanValue" name="boxCode" :label="hasPalletItems ? '箱码或托盘码扫码框' : '箱码扫码框'" :placeholder="hasPalletItems ? '扫描 DP 托盘码或箱唛标签码' : '扫描或输入箱唛标签码'" :active="activeScanField === 'boxCode'" @activate="setScanTarget" @submit="handleManualSubmit" />
</view>
<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"><text>已处理 </text><text class="progress-number">{{ summary.processed }}</text><text>/{{ summary.total }} 箱</text></view>
<button class="box-tasks-btn" :aria-label="`查看柜次任务,已完成 ${completedCount}/${boxes.length}`" @tap="openBoxList('pending')"><text>柜次</text><text class="box-tasks-count">{{ completedCount }}/{{ boxes.length }}</text><text class="box-tasks-arrow">›</text></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">{{ isPalletItem(item) ? (item.palletNo || '托盘码待同步') + ' · 整托不可拆' : '拣货明细' }}</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 v-if="item.location" class="pick-item-location">{{ item.location }}</text>
</view>
<view v-if="itemState(item) === ITEM_STATUS.PENDING && !isPalletItem(item)" 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="isPalletItem(item) && itemState(item) === ITEM_STATUS.PENDING" class="pick-item-actions"><text>请扫描上方 DP 托盘码整托拣出</text></view><view v-else-if="isPalletItem(item) && pickingOperationReady && ['PENDING', 'PICKING'].includes(item.itemStatus)" class="pick-item-actions"><button class="pick-btn pick-btn-undo" @tap="undoPallet(item)">撤销整托草稿</button></view><view v-else-if="pickingOperationReady && !isPalletItem(item)" class="pick-item-actions processed-actions">
<button class="pick-btn pick-btn-undo" @tap="undoItem(item.id)">撤销</button>
</view>
</view>
</view>
</view>
</view>
<view v-if="activeStage === 'evidence'" class="loading-evidence-section">
<view class="section-heading-row">
<view><text class="section-title">装柜证据</text><text class="section-subtitle">每类至少 1 张,拍照后自动上传,可继续追加</text></view>
<text class="sync-state" :class="remoteSyncState.tone">{{ remoteSyncState.label }}</text>
</view>
<view class="evidence-grid">
<view v-for="(slot, slotIndex) in evidenceSlots" :key="slot.type" class="evidence-card" :class="{ filled: evidenceEntries(slot.type).length }">
<view class="evidence-card-header">
<view class="evidence-card-main">
<text class="evidence-step" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? '✓' : slotIndex + 1 }}</text>
<view class="evidence-card-title">
<text class="evidence-label">{{ slot.label }}</text>
<text class="evidence-count" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }}</text>
</view>
</view>
<button class="evidence-btn evidence-add-btn" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
</view>
<view v-if="evidenceEntries(slot.type).length" class="evidence-photo-grid">
<view v-for="photo in evidenceEntries(slot.type)" :key="photo.id || photo.fileId || photo.localPath" class="evidence-photo-item">
<image v-if="evidencePreviewSource(photo)" class="evidence-thumbnail" :src="evidencePreviewSource(photo)" mode="aspectFill" @tap="previewEvidencePhoto(photo, slot.type)" />
<view v-else class="evidence-thumbnail evidence-thumbnail-placeholder" @tap="previewEvidencePhoto(photo, slot.type)">图片处理中</view>
<text class="evidence-file" @tap="previewEvidencePhoto(photo, slot.type)">{{ photo.fileName || '已记录' }}</text>
<button v-if="photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-void-btn" @tap="voidEvidencePhoto(photo)">作废</button>
</view>
</view>
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
{{ evidenceUploadState(slot.type) }}
</text>
<text v-else-if="hasPendingLocalEvidence(slot.type)" class="evidence-upload-state pending">
{{ slot.type === 'SEAL' ? '请填写封签号,保存后上传' : '照片待自动上传' }}
</text>
<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" :disabled="sealSaving || !sealReadyToSave" @tap="saveSealNo">{{ sealSaving ? '上传中…' : '保存并上传' }}</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 || !loadingStageReady || pickingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
<view v-if="pickingVersion === 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 v-if="pickingCompleted && !loadingStageReady && activeStage === 'picking'" 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>
<view v-if="activeStage === 'picking'" 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 || (!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">{{ containerReference(entry.box) }}{{ 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">
<view class="partial-info-copy"><text v-if="partialTargetItem.sku" class="partial-info-sku">{{ partialTargetItem.sku }}</text><text class="partial-info-desc">{{ partialTargetItem.desc }}</text></view>
<view class="partial-info-qty"><text class="partial-info-qty-label">需拣</text><text class="partial-info-qty-number">{{ partialTargetItem.qty }}</text><text>{{ partialTargetItem.unit }}</text></view>
</view>
<view class="partial-field">
<text class="partial-input-label">实际拣出数量</text>
<view class="partial-input-control"><input
v-model="partialInput"
class="partial-input"
aria-label="实际拣出数量"
type="number"
placeholder="请输入数量"
placeholder-class="partial-input-placeholder"
:focus="partialFocused"
confirm-type="done"
@confirm="confirmPartial"
/><text class="partial-input-unit">{{ partialTargetItem ? partialTargetItem.unit : '' }}</text></view>
</view>
<view class="partial-quick-row">
<button
v-for="option in partialQuickOptions"
:key="option.label"
class="partial-quick-btn"
:class="{ selected: String(partialInput) === String(option.value) }"
@tap="setPartialInput(option.value)"
>
<text class="partial-quick-ratio">{{ option.label }}</text><text class="partial-quick-value">{{ option.value }}{{ partialTargetItem ? partialTargetItem.unit : '' }}</text>
</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.location ? `${item.sku} · ${item.location}` : item.sku }}</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">{{ containerReference(currentBox) }}</text>
<view class="complete-evidence-status">
<text>装柜照片已拍 {{ evidenceCapturedTypeCount }}/{{ evidenceSlots.length }} 类</text>
<text>{{ sealNo ? '封签号已填写' : '封签号未填写' }}</text>
</view>
<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="continueToEvidence">{{ evidenceNextButtonText }}</button>
</view>
</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, isPalletItem } from '@/utils/picking-state.js'
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
import { session, requireSession } from '@/stores/session.js'
import { clearLoadingCompletionAttempt, clearPickingDraft, getPickingSession, restoreLoadingCompletionAttempt, restorePickingSession, saveLoadingCompletionAttempt, 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, completeContainerPickingTask, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, isRemoteReady, listLoadingOrders, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, 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 { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js'
import { containerReference, formatApiError, sanitizeUserMessage } from '@/utils/user-facing.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 === 'PROCESSING'
|| workflowStatus === 'COMPLETION_FAILED'
|| workflowStatus === 'COMPLETED'
|| taskStatus === 'COMPLETING'
|| taskStatus === 'COMPLETION_FAILED'
|| taskStatus === 'COMPLETED'
}
export default {
components: { PopupHeader, ScanTargetInput },
data() {
return {
ITEM_STATUS,
currentUser: session.currentUser,
skipReasons: SKIP_REASONS,
incompleteReasons: INCOMPLETE_REASONS,
...getPickingSession(),
activeScanField: 'boxCode',
activeStage: 'picking',
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: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, evidencePreviewOpening: false, evidencePreviewReturning: false, sealNo: '', sealSaving: false, pickingVersion: null, remotePick: null,
remoteTaskId: null, assignmentVersion: null, submitId: '',
taskRequestId: 0,
remoteError: '', remoteLoading: false, submitting: false,
toast: { visible: false, message: '', type: '' }
}
},
computed: {
hasPalletItems() { return this.currentBox.items.some(isPalletItem) },
currentBox() {
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', inboundCode: '', 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() {
if (this.remoteTaskId || this.currentBox.taskId) {
if (!this.remotePick) return false
const hasServerPending = this.currentBox.items.some((item) => ['PENDING', 'PICKING'].includes(item.itemStatus))
return isPickingCompleteTask(this.remotePick) && !hasServerPending
}
return Boolean(this.completedBoxes[this.currentBoxIndex])
},
pickingOperationReady() {
return this.remotePick?.workflowStatus === 'IN_PROGRESS'
&& Boolean(this.remoteTaskId && this.currentBox.loadingPlanId && this.currentBox.containerBookingId)
&& this.pickingVersion !== null
},
loadingStageReady() {
return this.pickingCompleted
&& this.evidenceIdentity
&& this.evidenceIdentity.taskId
&& this.evidenceIdentity.warehouseId
&& this.pickingVersion !== null
&& Number.isFinite(Number(this.pickingVersion))
},
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.pickingCompleted ? '继续装柜取证' : this.summary.buttonText
},
submitStatusHint() {
if (this.pickingCompleted) return '拣货已完成,请继续装柜取证'
if (!this.summary.canSubmit) return '请先处理至少一件'
if (this.summary.pending > 0) return '提交时需选择原因'
return `已处理 ${this.summary.processed}/${this.summary.total}`
},
evidenceCapturedTypeCount() {
return this.evidenceSlots.filter((slot) => this.evidenceEntries(slot.type).length > 0).length
},
evidenceNextButtonText() {
return this.evidenceCapturedTypeCount === this.evidenceSlots.length && Boolean(String(this.sealNo || '').trim())
? '查看装柜取证'
: '去拍照'
},
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.remoteTaskId || this.currentBox.taskId)) 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())
},
sealReadyToSave() {
return Boolean(String(this.sealNo || '').trim()) && this.hasPendingLocalEvidence('SEAL')
}
},
created() {
this.resetEvidenceState()
},
onLoad(options = {}) {
if (!requireSession()) return
this.currentUser = session.currentUser
if (options.taskId) {
// A local completion marker belongs to the previously opened task. The
// canonical server detail decides whether this task is complete.
this.completedBoxes = {}
this.boxes = [{
boxNo: '', cabinetNo: null, currentContainerNo: null, orderNo: '', inboundCode: '',
taskId: String(options.taskId), loadingPlanId: null, containerBookingId: null,
inboundId: null, inboundVersion: null,
warehouseId: options.warehouseId ? String(options.warehouseId) : null,
tenantId: null, items: []
}]
this.currentBoxIndex = 0
this.remoteTaskId = options.taskId ? String(options.taskId) : null
}
},
async onShow() {
if (!requireSession()) return
if (this._pageVisible) return this._showRefreshPromise
this._pageVisible = true
this.currentUser = session.currentUser
this.startHardwareScanner()
if (this.cameraPicking) {
this.debugEvidence('choose.resume', { evidenceIdentity: this.evidenceIdentity })
return
}
if (this.evidencePreviewReturning) {
this.evidencePreviewReturning = false
this.debugEvidence('preview.resume', { evidenceIdentity: this.evidenceIdentity })
return
}
if (this._showRefreshPromise) return this._showRefreshPromise
this._showRefreshPromise = (async () => {
await this.refreshAvailableBoxes()
await this.syncRemoteTask()
})()
try { await this._showRefreshPromise }
finally { this._showRefreshPromise = null }
},
onHide() {
this._pageVisible = false
if (this.evidencePreviewOpening) {
this.evidencePreviewOpening = false
this.evidencePreviewReturning = true
}
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: {
isPalletItem,
undoPallet(item) {
if (!this.requirePickingOperation()) return
const members = this.currentBox.items.filter((entry) => isPalletItem(entry) && entry.palletNo === item.palletNo)
if (members.some((entry) => !['PENDING', 'PICKING'].includes(entry.itemStatus))) return
for (const member of members) { this.itemStates[member.id] = ITEM_STATUS.PENDING; delete this.partialQuantities[member.id] }
delete this.scannedBoxCodes[item.palletNo]
this.persistSession()
},
pickScannedPallet(item) {
if (this.submitting || !this.requirePickingOperation() || !item.palletNo) return
const members = this.currentBox.items.filter((entry) => isPalletItem(entry) && entry.palletNo === item.palletNo)
if (members.some((entry) => !['PENDING', 'PICKING'].includes(entry.itemStatus))) { this.showToast('托盘状态已变化,请刷新任务', 'warning'); return }
for (const member of members) { this.itemStates[member.id] = ITEM_STATUS.PICKED; this.partialQuantities[member.id] = Number(member.qty) }
this.scannedBoxCodes = { ...this.scannedBoxCodes, [item.palletNo]: item.id }
this.recordScanResult('success', '整托已记入草稿', item.palletNo, `整托 ${members.reduce((sum, entry) => sum + Number(entry.qty), 0)} 箱,提交后保存`)
this.showToast('整托已拣,待统一提交', 'success'); this.persistSession()
},
containerReference,
isSelectableTask(task) {
return Boolean(task?.recordStatus === 'ACTIVE'
&& !task?.mergedIntoContainerBookingId
&& task?.taskId
&& task?.loadingPlanId
&& task?.containerBookingId
&& task?.currentContainerNo
&& task?.warehouseId
&& task?.version !== null
&& task?.version !== undefined
&& ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW'].includes(task?.workflowStatus))
},
boxFromTask(task, existing = null) {
return {
boxNo: String(task.currentContainerNo || existing?.boxNo || ''),
cabinetNo: task.cabinetNo ? String(task.cabinetNo) : (existing?.cabinetNo || null),
currentContainerNo: task.currentContainerNo ? String(task.currentContainerNo) : (existing?.currentContainerNo || null),
orderNo: String(task.orderNo || existing?.orderNo || ''),
inboundCode: String(task.inboundCode || existing?.inboundCode || ''),
taskId: String(task.taskId),
loadingPlanId: task.loadingPlanId || existing?.loadingPlanId || null,
containerBookingId: task.containerBookingId || existing?.containerBookingId || null,
inboundId: existing?.inboundId || task.inboundId || null,
inboundVersion: existing?.inboundVersion ?? task.inboundVersion ?? null,
warehouseId: task.warehouseId || existing?.warehouseId || null,
tenantId: existing?.tenantId || task.tenantId || null,
items: existing?.items || []
}
},
remapBoxState(previousBoxes, nextBoxes, state) {
const byTaskId = new Map(previousBoxes.map((box, index) => [String(box.taskId || ''), state[index]]))
return Object.fromEntries(nextBoxes.map((box, index) => [index, byTaskId.get(String(box.taskId || ''))]).filter(([, value]) => value !== undefined))
},
async refreshAvailableBoxes() {
if (!session.token) return
try {
const result = await listLoadingOrders({ page: 1, size: 50 })
const records = (result.records || []).filter((task) => this.isSelectableTask(task))
const previousBoxes = [...this.boxes]
const selectedTaskId = String(this.remoteTaskId || this.currentBox.taskId || '')
const existingByTaskId = new Map(previousBoxes.map((box) => [String(box.taskId || ''), box]))
const recordByTaskId = new Map(records.map((task) => [String(task.taskId), task]))
const orderedTaskIds = previousBoxes.map((box) => String(box.taskId || '')).filter((taskId) => recordByTaskId.has(taskId))
records.forEach((task) => { const taskId = String(task.taskId); if (!orderedTaskIds.includes(taskId)) orderedTaskIds.push(taskId) })
if (selectedTaskId && !orderedTaskIds.includes(selectedTaskId) && existingByTaskId.has(selectedTaskId)) orderedTaskIds.unshift(selectedTaskId)
const nextBoxes = orderedTaskIds.map((taskId) => {
const existing = existingByTaskId.get(taskId) || null
const task = recordByTaskId.get(taskId)
return task ? this.boxFromTask(task, existing) : existing
}).filter(Boolean)
if (!nextBoxes.length) return
this.boxDrafts = this.remapBoxState(previousBoxes, nextBoxes, this.boxDrafts)
this.completedBoxes = this.remapBoxState(previousBoxes, nextBoxes, this.completedBoxes)
this.boxes = nextBoxes
const selectedIndex = this.boxes.findIndex((box) => String(box.taskId || '') === selectedTaskId)
this.currentBoxIndex = selectedIndex >= 0 ? selectedIndex : 0
this.debugSubmit('tasks.available', { selectedTaskId, taskIds: this.boxes.map((box) => box.taskId), count: this.boxes.length })
} catch (error) {
this.debugSubmit('tasks.available.error', { message: error.message || '', code: error.code || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
this.showToast(error.network ? '任务列表同步失败,请检查网络后重试' : '任务列表同步失败,当前任务仍可继续', 'warning')
}
},
selectStage(stage) {
this.activeStage = stage
},
debugEvidence(event, payload = {}) {
try { console.info(`[PDA] evidence.${event}`, payload) } catch (_) { /* console may be unavailable on a device */ }
},
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 || null,
pickingVersion: this.pickingVersion,
pending: this.summary.pending,
...payload
})
} catch (_) { /* console may be unavailable on a device */ }
},
isTaskConflict(error) {
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
},
remoteFailureMessage(error, fallback = '操作失败') {
return formatApiError(error, fallback)
},
async refreshAfterTaskConflict(message) {
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
this.submitId = ''
this.submitting = false
this.remoteError = message
this.showToast(message, 'warning')
await this.syncRemoteTask()
if (String(this.remoteTaskId || '') === String(requestedTaskId || '') && this.remotePick?.taskStatus === 'LOADING') {
try { await this.refreshInventorySnapshot() } catch (error) { this.debugSubmit('refresh.inventory-error', { message: error?.message || '', code: error?.code || '' }) }
}
if (String(this.remoteTaskId || '') === String(requestedTaskId || '') && !this.remoteError) this.remoteError = message
},
async refreshInventorySnapshot() {
const warehouseId = this.currentBox.warehouseId
const snapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId)
if (!snapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
if (this.pickingVersion !== null && String(snapshot.version) !== String(this.pickingVersion)) {
const error = new Error('库存已变化,请刷新后重试')
error.code = 'A0409'
throw error
}
return snapshot
},
captureEvidenceIdentity() {
return { taskId: this.remoteTaskId || this.currentBox.taskId || null, warehouseId: this.currentBox.warehouseId || null }
},
isSameEvidenceIdentity(left, right) {
return String(left?.taskId ?? '') === String(right?.taskId ?? '')
&& String(left?.warehouseId ?? '') === String(right?.warehouseId ?? '')
},
isMergedContainerRecord(task) {
return task?.recordStatus === 'MERGED' || Boolean(task?.mergedIntoContainerBookingId)
},
clearMergedTask(task, previousIdentity) {
const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId
const staleIdentity = {
operatorId,
taskId: previousIdentity?.taskId ?? task?.taskId ?? task?.id,
loadingPlanId: previousIdentity?.loadingPlanId ?? task?.loadingPlanId,
containerBookingId: previousIdentity?.containerBookingId ?? task?.containerBookingId,
warehouseId: previousIdentity?.warehouseId ?? this.currentBox.warehouseId
}
clearPickingDraft(staleIdentity)
const evidenceKey = this.evidenceDraftKey({ taskId: staleIdentity.taskId, warehouseId: staleIdentity.warehouseId })
this.evidenceDrafts = Object.fromEntries(Object.entries(this.evidenceDrafts).filter(([key]) => key !== evidenceKey))
this.evidencePreviewCache = Object.fromEntries(Object.entries(this.evidencePreviewCache).filter(([key]) => !key.startsWith(`${evidenceKey}|`)))
this.taskRequestId += 1
this.remoteLoading = false
this.submitting = false
this.remoteError = '当前柜记录已合并,请返回任务列表刷新'
this.remoteTaskId = null
this.remotePick = null
this.pickingVersion = null
this.assignmentVersion = null
this.itemStates = {}
this.partialQuantities = {}
this.scannedBoxCodes = {}
this.boxDrafts = {}
this.completedBoxes = {}
this.scanHistory = []
this.currentBox.taskId = null
this.currentBox.inboundId = null
this.currentBox.loadingPlanId = null
this.currentBox.containerBookingId = null
this.currentBox.cabinetNo = ''
this.currentBox.boxNo = ''
this.currentBox.currentContainerNo = null
this.currentBox.items = []
this.resetEvidenceState()
},
isEvidenceContextActive(contextId, identity) {
return contextId === this.evidenceContextId
&& this.isSameEvidenceIdentity(this.evidenceIdentity, identity)
},
evidenceDraftKey(identity) {
return `${String(identity?.taskId ?? '')}|${String(identity?.warehouseId ?? '')}`
},
rememberEvidenceDraft(identity = this.evidenceIdentity) {
const key = this.evidenceDraftKey(identity)
if (!identity?.taskId || !identity?.warehouseId) return
const files = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath).map((entry) => ({ ...entry }))]).filter(([, entries]) => entries.length))
if (Object.keys(files).length || String(this.sealNo || '').trim()) this.evidenceDrafts = { ...this.evidenceDrafts, [key]: { files, sealNo: this.sealNo || '' } }
},
restoreEvidenceDraft(identity = this.evidenceIdentity) {
const draft = this.evidenceDrafts[this.evidenceDraftKey(identity)]
if (!draft) return
this.evidenceFiles = Object.fromEntries(Object.entries(draft.files || {}).map(([type, entries]) => [type, entries.map((entry) => ({ ...entry }))]))
if (draft.sealNo) this.sealNo = draft.sealNo
},
removeDraftPhoto(type, localPath) {
const key = this.evidenceDraftKey(this.evidenceIdentity)
const draft = this.evidenceDrafts[key]
if (!draft) return
const files = { ...(draft.files || {}) }
files[type] = (files[type] || []).filter((entry) => entry.localPath !== localPath)
if (!files[type].length) delete files[type]
this.evidenceDrafts = Object.keys(files).length || draft.sealNo ? { ...this.evidenceDrafts, [key]: { ...draft, files } } : Object.fromEntries(Object.entries(this.evidenceDrafts).filter(([draftKey]) => draftKey !== key))
},
requireEvidenceCapture() {
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) {
this.showToast('任务状态尚未同步,请刷新重试', 'warning')
return false
}
return true
},
requireEvidenceMutation() {
if (!this.requireEvidenceCapture()) return false
if (!isRemoteReady(this.evidenceIdentity.taskId)) {
this.showToast('当前会话未连接服务端,证据保留本地', 'warning')
return false
}
return true
},
requirePickingOperation() {
if (this.pickingOperationReady) return true
this.showToast('任务尚未领取完成,请稍候或刷新重试', 'warning')
return false
},
requireEvidenceStage() {
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
this.evidenceIdentity = null
this.evidenceFiles = {}
this.evidenceQueueItems = []
this.sealNo = ''
this.sealSaving = false
this.evidenceQueue = createEvidenceUploadQueue({
upload: (item, onProgress) => uploadEvidenceFile(item.taskId, item.warehouseId, item, item.localPath, 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, inboundId: this.currentBox.inboundId || this.remotePick?.inboundId, loadingPlanId: this.currentBox.loadingPlanId || this.remotePick?.loadingPlanId, containerBookingId: this.currentBox.containerBookingId || this.remotePick?.containerBookingId, warehouseId: this.currentBox.warehouseId || null }
},
restoreRemoteDraft() {
const restored = restorePickingSession(this.draftIdentity())
if (!restored) return false
const itemIds = new Set(this.currentBox.items.map((item) => String(item.id)))
const serverStates = { ...this.itemStates }
const serverPartialQuantities = { ...this.partialQuantities }
Object.entries(restored.itemStates || {}).forEach(([id, status]) => {
if (!itemIds.has(String(id)) || serverStates[id] !== ITEM_STATUS.PENDING || status === ITEM_STATUS.PENDING) return
const item = this.currentBox.items.find((entry) => String(entry.id) === String(id))
if (isPalletItem(item)) {
const members = this.currentBox.items.filter((entry) => isPalletItem(entry) && entry.palletNo === item.palletNo)
if (!item.palletNo || !restored.scannedBoxCodes?.[item.palletNo] || status !== ITEM_STATUS.PICKED || members.some((entry) => restored.itemStates?.[entry.id] !== ITEM_STATUS.PICKED || Number(restored.partialQuantities?.[entry.id]) !== Number(entry.qty) || !['PENDING', 'PICKING'].includes(entry.itemStatus))) return
}
const partial = Number(restored.partialQuantities?.[id] || 0)
if (status === ITEM_STATUS.PARTIAL && (!partial || partial >= Number(item?.qty || 0))) return
serverStates[id] = status
if (partial > 0) serverPartialQuantities[id] = partial
})
this.itemStates = serverStates
this.partialQuantities = serverPartialQuantities
this.scannedBoxCodes = Object.fromEntries(Object.entries(restored.scannedBoxCodes || {}).filter(([, id]) => itemIds.has(String(id)) && (!isPalletItem(this.currentBox.items.find((item) => String(item.id) === String(id))) || serverStates[id] === ITEM_STATUS.PICKED)))
this.boxDrafts = restored.boxDrafts || {}
this.completedBoxes = restored.completedBoxes || {}
if (this.remotePick && !isPickingCompleteTask(this.remotePick)) {
const completedBoxes = { ...this.completedBoxes }
delete completedBoxes[this.currentBoxIndex]
this.completedBoxes = 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); clearTimeout(this._evidencePreviewOpenTimer) },
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.activeStage = 'picking'
this.currentBox.inboundId = null
this.currentBox.inboundVersion = null
this.remoteTaskId = this.currentBox.taskId || null
this.remotePick = null
this.pickingVersion = null
this.assignmentVersion = null
this.resetEvidenceState()
this.boxDropdownOpen = false
this.loadBoxState(index)
this.scrollToTop()
if (session.token && this.currentBox.taskId) this.syncRemoteTask()
this.showToast(`切换至集装箱 ${this.currentBox.currentContainerNo || '同步中'}`, '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') {
if (!this.requirePickingOperation()) return
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()
if (isPalletItem(result.item)) return this.pickScannedPallet(result.item)
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]] : []) },
evidencePreviewSource(photo) { return photo?.localPath || photo?.fileUrl || photo?.downloadUrl || photo?.url || '' },
evidencePreviewCacheKey(fileId, identity = this.evidenceIdentity) { return `${this.evidenceDraftKey(identity)}|${String(fileId || '')}` },
cacheEvidencePreview(fileId, localPath, identity = this.evidenceIdentity) {
if (!fileId || !localPath) return
this.evidencePreviewCache = { ...this.evidencePreviewCache, [this.evidencePreviewCacheKey(fileId, identity)]: localPath }
},
withCachedEvidencePreview(photo, identity = this.evidenceIdentity) {
if (!photo || this.evidencePreviewSource(photo)) return photo
const localPath = this.evidencePreviewCache[this.evidencePreviewCacheKey(photo.fileId, identity)]
return localPath ? { ...photo, localPath } : photo
},
previewEvidencePhoto(photo, type) {
const current = this.evidencePreviewSource(photo)
const urls = this.evidenceEntries(type).map((item) => this.evidencePreviewSource(item)).filter(Boolean)
if (!current || !urls.length) { this.showToast('照片地址尚未同步,请刷新后重试', 'warning'); return }
if (typeof uni.previewImage !== 'function') { this.showToast('当前环境不支持预览照片', 'warning'); return }
this.debugEvidence('preview.open', { evidenceType: type, evidenceIdentity: this.evidenceIdentity, count: urls.length })
this.evidencePreviewOpening = true
clearTimeout(this._evidencePreviewOpenTimer)
this._evidencePreviewOpenTimer = setTimeout(() => { this.evidencePreviewOpening = false }, 1500)
uni.previewImage({ current, urls, fail: () => { this.evidencePreviewOpening = false } })
},
evidenceItems(result) {
const items = Array.isArray(result?.evidences) ? result.evidences : (Array.isArray(result?.evidences?.evidences) ? result.evidences.evidences : [])
const seen = new Set()
return items.map((item) => ({
...item,
id: String(item.evidenceId ?? item.id ?? ''),
fileId: String(item.fileId ?? '')
})).filter((item) => {
const key = `${item.evidenceType || ''}:${item.fileId || `id-${item.id || ''}`}`
if (seen.has(key)) return false
seen.add(key)
return true
})
},
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 },
hasPendingLocalEvidence(type) { return this.evidenceEntries(type).some((item) => item.localPath && !item.fileId) },
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 this.validEvidenceEntries(type).length ? '已上传并保存' : (item.attempts > 1 ? '重试成功' : '已上传')
}
if (item.status === 'REPLACED') return '已替换'
return ''
},
async syncRemoteTask() {
if (!session.token) return
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
if (!requestedTaskId) { this.remoteError = '当前没有可进入的拣货任务,请返回任务列表刷新'; return }
this.rememberEvidenceDraft()
const previousIdentity = this.draftIdentity()
const previousTaskVersion = this.pickingVersion
const previousItemIds = new Set(this.currentBox.items.map((item) => String(item.id)))
const previousEvidenceState = {
identity: this.evidenceIdentity ? { ...this.evidenceIdentity } : null,
files: Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).map((entry) => ({ ...entry }))])),
sealNo: this.sealNo
}
const requestId = ++this.taskRequestId
const selector = { taskId: requestedTaskId }
this.currentBox.inboundId = null
this.currentBox.loadingPlanId = null
this.currentBox.containerBookingId = null
this.resetEvidenceState()
this.debugSubmit('refresh.start', { requestId, selector })
this.remoteLoading = true
this.remoteError = ''
try {
const warehouseId = this.currentBox.warehouseId
const task = await getContainerPickingTask(selector, warehouseId)
if (requestId !== this.taskRequestId) return
if (!task) throw new Error('服务端未返回当前拣货任务')
if (this.isMergedContainerRecord(task)) {
this.clearMergedTask(task, previousIdentity)
this.showToast('当前柜记录已合并,请返回任务列表刷新', 'warning')
return
}
if (task.recordStatus !== 'ACTIVE') throw new Error('当前柜记录尚未就绪,请返回任务列表刷新')
const detailTaskId = task.taskId ?? task.id
if (String(detailTaskId || '') !== String(requestedTaskId)) throw new Error('服务端返回的拣货任务与当前选择不一致,请返回刷新')
if (!task.loadingPlanId || !task.containerBookingId) throw new Error('任务尚未同步完整作业关联,请返回任务列表刷新')
if (task.version === null || task.version === undefined) throw new Error('任务状态尚未同步,请刷新重试')
if (!task.currentContainerNo) throw new Error('任务尚未同步集装箱号,请返回任务列表刷新')
const remoteItems = task.items || task.pickItems || task.details || task.lines
if (!Array.isArray(remoteItems) || !remoteItems.length) throw new Error('当前拣货任务暂无可作业明细,请返回刷新')
{
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 || task.orderNo || this.currentBox.orderNo
this.currentBox.inboundCode = firstItem.inboundCode || task.inboundCode || this.currentBox.inboundCode
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 = detailTaskId
if (!this.remoteTaskId) throw new Error('服务端任务缺少 taskId,无法开始拣货')
this.currentBox.loadingPlanId = task.loadingPlanId
this.currentBox.containerBookingId = task.containerBookingId
this.currentBox.cabinetNo = task.cabinetNo
this.currentBox.boxNo = task.currentContainerNo
this.currentBox.currentContainerNo = task.currentContainerNo
const remoteTask = normalizePickingTask(task)
this.assignmentVersion = remoteTask.assignment?.version ?? remoteTask.assignmentVersion ?? (remoteTask.workflowStatus === 'UNASSIGNED' ? 0 : null)
if (remoteTask.workflowStatus !== 'AWAITING_REVIEW' && this.assignmentVersion === null) throw new Error('任务领取状态尚未同步,请刷新重试')
let claimed = null
if (remoteTask.workflowStatus === 'UNASSIGNED') {
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
} else if (remoteTask.workflowStatus === 'IN_PROGRESS') {
const assigneeId = remoteTask.assignment?.assigneeId
if (!assigneeId || String(assigneeId) !== String(session.currentUser?.id || '')) throw new Error('当前任务已由其他作业员领取,请返回任务列表刷新')
} else if (!isPickingCompleteTask(remoteTask)) {
throw new Error('当前任务尚未进入可拣货状态,请返回任务列表刷新')
}
this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) })
if (remoteTask.workflowStatus === 'UNASSIGNED' && this.remotePick.workflowStatus !== 'IN_PROGRESS') throw new Error('任务领取尚未完成,请刷新重试')
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null
this.evidenceIdentity = this.captureEvidenceIdentity()
this.restoreEvidenceDraft()
if (this.isSameEvidenceIdentity(previousEvidenceState.identity, this.evidenceIdentity)) {
this.evidenceFiles = { ...this.evidenceFiles, ...previousEvidenceState.files }
if (previousEvidenceState.sealNo) this.sealNo = previousEvidenceState.sealNo
}
this.debugSubmit('refresh.ready', { requestId, workflowStatus: this.remotePick.workflowStatus, taskStatus: this.remotePick.taskStatus, assignmentVersion: this.assignmentVersion, evidenceIdentity: this.evidenceIdentity })
this.assignmentVersion = this.remotePick.assignment?.version ?? this.remotePick.assignmentVersion ?? this.assignmentVersion
if (!this.pickingCompleted) this.restoreRemoteDraft()
if (!this.pickingCompleted) {
const completedBoxes = { ...this.completedBoxes }
delete completedBoxes[this.currentBoxIndex]
this.completedBoxes = completedBoxes
this.completionSummary = { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 }
}
const addedItemIds = this.currentBox.items.map((item) => String(item.id)).filter((id) => !previousItemIds.has(id))
const taskVersionChanged = previousTaskVersion !== null && String(previousTaskVersion) !== String(this.pickingVersion)
if (taskVersionChanged || addedItemIds.length) {
this.debugSubmit('refresh.incremental', { previousTaskVersion, taskVersion: this.pickingVersion, addedItemIds, pickingCompleted: this.pickingCompleted })
if (addedItemIds.length) this.showToast(`已同步 ${addedItemIds.length} 条新增待拣明细`, 'warning')
}
await this.loadLoadingEvidence()
if (this.loadingStageReady) this.activeStage = 'evidence'
} 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 ? '网络不可用,无法开始拣货,恢复网络后请重试' : this.remoteFailureMessage(error, '任务同步失败')
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
},
async loadLoadingEvidence() {
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) return
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
try {
const evidence = await getEvidence(identity.taskId, identity.warehouseId)
if (!this.isEvidenceContextActive(contextId, identity)) return
const remoteItems = this.evidenceItems(evidence).map((item) => ({
...item,
previewUrl: item.previewUrl || getEvidencePreviewUrl(identity.taskId, item.id, identity.warehouseId)
}))
this.debugEvidence('list.loaded', { evidenceIdentity: identity, count: remoteItems.length, previewCount: remoteItems.filter((item) => item.previewUrl).length })
const localFiles = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath)]))
this.evidenceFiles = remoteItems.map((item) => this.withCachedEvidencePreview(item, identity)).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, localFiles)
const seal = this.evidenceEntry('SEAL')
if (seal?.sealNo) this.sealNo = seal.sealNo
await this.loadEvidencePreviews(contextId, identity)
if (!this.isEvidenceContextActive(contextId, identity)) return
await this.flushPendingEvidence()
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : this.remoteFailureMessage(error, '装柜证据加载失败')
}
},
async loadEvidencePreviews(contextId, identity) {
const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type)
.filter((item) => !this.evidencePreviewSource(item) && item.previewUrl)
.map((item) => ({ type: slot.type, id: item.id, fileId: item.fileId, previewUrl: item.previewUrl })))
const loaded = await Promise.all(pending.map(async (item) => {
try {
const localPath = await downloadEvidencePreview(item.previewUrl)
this.debugEvidence('preview.downloaded', { evidenceType: item.type, fileId: item.fileId, hasLocalPath: Boolean(localPath) })
return { ...item, localPath }
} catch (error) {
this.debugEvidence('preview.download-error', { evidenceType: item.type, fileId: item.fileId, message: error?.message || '', statusCode: error?.statusCode || '', contentType: error?.contentType || '', requestId: error?.requestId || '', traceId: error?.traceId || '' })
return null
}
}))
if (!this.isEvidenceContextActive(contextId, identity)) return
loaded.filter(Boolean).forEach((item) => this.cacheEvidencePreview(item.fileId, item.localPath, identity))
this.evidenceFiles = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, entries.map((entry) => {
const preview = loaded.find((item) => item && item.type === type && item.id === entry.id && item.fileId === entry.fileId)
return preview ? { ...entry, localPath: preview.localPath } : entry
})]))
},
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?.status === 'UPLOADING') return `上传中 ${queued.progress || 0}%`
if (queued?.status === 'PENDING') return '等待上传'
return this.evidenceEntry(type) ? '继续拍照' : '拍照'
},
chooseEvidence(slot) {
const evidenceType = slot?.type || null
this.debugEvidence('choose.start', { evidenceType, evidenceIdentity: this.evidenceIdentity, loadingStageReady: this.loadingStageReady, hasChooseImage: typeof uni.chooseImage === 'function' })
if (!this.requireEvidenceCapture()) { this.debugEvidence('choose.blocked', { evidenceType, reason: 'identity-not-ready', evidenceIdentity: this.evidenceIdentity }); return }
if (!isOnline()) { this.debugEvidence('choose.blocked', { evidenceType, reason: 'offline' }); this.showToast('当前网络不可用,请联网后再拍照上传', 'warning'); return }
if (typeof uni.chooseImage !== 'function') { this.debugEvidence('choose.blocked', { evidenceType, reason: 'chooseImage-unavailable' }); this.showToast('当前环境不支持选择照片', 'warning'); return }
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
this.cameraPicking = true
this.debugEvidence('choose.open-camera', { evidenceType, contextId, evidenceIdentity: identity })
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => {
this.cameraPicking = false
if (!this.isEvidenceContextActive(contextId, identity)) return
const path = result.tempFilePaths && result.tempFilePaths[0]
if (!path) { this.debugEvidence('camera.empty', { evidenceType: slot.type, evidenceIdentity: identity }); return }
this.debugEvidence('camera.selected', { evidenceType: slot.type, evidenceIdentity: identity })
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.rememberEvidenceDraft(identity)
if (slot.type === 'SEAL') {
this.showToast('照片已拍摄,请输入封签号并保存上传', 'success')
return
}
this.queueEvidenceUpload(slot.type, path)
}, fail: (error) => {
this.cameraPicking = false
if (!this.isEvidenceContextActive(contextId, identity)) return
this.debugEvidence('camera.error', { evidenceType: slot.type, evidenceIdentity: identity, message: error?.errMsg || '' })
this.showToast('未选择照片,可稍后重试', 'warning')
} })
},
applyEvidenceResponse(result, type, localPath, identity) {
const remoteItems = this.evidenceItems(result)
const saved = [...remoteItems].reverse().find((item) => item.evidenceType === type && item.status !== 'VOID' && item.status !== 'VOIDED')
if (saved?.fileId && localPath) this.cacheEvidencePreview(saved.fileId, localPath, identity)
const localFiles = Object.fromEntries(Object.entries(this.evidenceFiles).map(([entryType, entries]) => [entryType, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath && entry.localPath !== localPath)]).filter(([, entries]) => entries.length))
this.evidenceFiles = remoteItems.map((item) => this.withCachedEvidencePreview(item, identity)).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, localFiles)
const seal = this.evidenceEntry('SEAL')
if (seal?.sealNo) this.sealNo = seal.sealNo
this.removeDraftPhoto(type, localPath)
},
async queueEvidenceUpload(type, path, sealNo = null) {
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('当前任务仓库尚未同步,证据保留本地', 'warning'); return }
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
const queue = this.evidenceQueue
const active = [...this.validEvidenceEntries(type)].reverse()[0]
const expectedVersion = active?.version ?? 0
const operationId = stableOperationId('evidence-upload', `${identity.taskId}|${identity.warehouseId}`, { evidenceType: type, expectedVersion, localPath: path, sealNo: sealNo || '' })
const entry = queue.add({ taskId: identity.taskId, warehouseId: identity.warehouseId, evidenceType: type, expectedVersion, sealNo: sealNo || null, remark: null, operationId, localPath: path })
this.debugSubmit('evidence.queue.start', { evidenceType: type, queueId: entry.id, evidenceIdentity: identity })
await queue.flush()
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
const latest = queue.snapshot().find((item) => item.id === entry.id)
this.debugSubmit('evidence.queue.result', { evidenceType: type, queueId: entry.id, status: latest?.status || 'missing', fileId: latest?.result?.fileId || null, error: latest?.error || '' })
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
this.applyEvidenceResponse(latest.result, type, path, identity)
this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片已上传并保存', 'success')
},
async flushPendingEvidence() {
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => !entry.id && entry.localPath).map((entry) => ({ type: slot.type, path: entry.localPath })))
for (const item of pending) {
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
if (item.type === 'SEAL' && !String(this.sealNo || '').trim()) continue
await this.queueEvidenceUpload(item.type, item.path, item.type === 'SEAL' ? this.sealNo : null)
}
},
async retryEvidence(type) {
if (!this.requireEvidenceCapture()) return
const item = this.evidenceQueueEntry(type)
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
const queue = this.evidenceQueue
if (!item || !queue.retry(item.id)) return
this.remoteError = ''
await queue.flush()
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
const latest = queue.latest(type)
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
this.applyEvidenceResponse(latest.result, type, item.localPath, identity)
this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片重试上传成功', 'success')
},
async saveSealNo() {
if (this.sealSaving) return
if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return }
this.rememberEvidenceDraft()
const current = [...this.evidenceEntries('SEAL')].reverse().find((item) => item.localPath && !item.id)
if (!current) { this.showToast('当前封签已保存,无需重复提交', 'success'); return }
this.sealSaving = true
try {
await this.queueEvidenceUpload('SEAL', current.localPath, String(this.sealNo).trim())
} finally {
this.sealSaving = false
}
},
async voidEvidencePhoto(photo) {
if (!this.requireEvidenceMutation()) return
if (!photo?.id || !this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
const reason = 'PDA现场标记错图'
try {
await voidEvidence(identity.taskId, photo.id, identity.warehouseId, { expectedVersion: photo.version || 0, reason })
if (!this.isEvidenceContextActive(contextId, identity)) 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, identity)) return
if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '任务内容已更新,请刷新后重试'))
return
}
this.remoteError = error.network ? '网络失败,照片未作废,请重试' : this.remoteFailureMessage(error, '照片作废失败')
this.showToast('照片作废失败', 'error')
}
},
async completeLoadingTask() {
if (!this.requireEvidenceStage()) return
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('装柜任务或仓库尚未同步,请刷新后重试', 'warning'); return }
if (this.pickingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
const serverAttempt = this.remotePick?.completionCommand
const savedAttempt = restoreLoadingCompletionAttempt(this.draftIdentity()) || (serverAttempt?.operationId ? {
operationId: serverAttempt.operationId,
expectedTaskVersion: serverAttempt.expectedTaskVersion,
remark: serverAttempt.remark || 'PDA确认装柜完成'
} : null)
const expectedTaskVersion = savedAttempt?.expectedTaskVersion ?? Number(this.pickingVersion)
const completionIdentity = this.draftIdentity()
const operationIdentity = stableTaskOperationIdentity(completionIdentity)
const operationId = savedAttempt?.operationId || stableOperationId('complete', operationIdentity, {
taskId: this.remoteTaskId,
expectedTaskVersion,
loadingPlanId: completionIdentity.loadingPlanId,
containerBookingId: completionIdentity.containerBookingId
})
const completionAttempt = { operationId, expectedTaskVersion, remark: savedAttempt?.remark || 'PDA确认装柜完成' }
saveLoadingCompletionAttempt(this.draftIdentity(), completionAttempt)
this.submitting = true
try {
const completedTask = await completeContainerPickingTask(this.remoteTaskId, {
expectedTaskVersion,
operationId,
remark: completionAttempt.remark
})
if (!this.isEvidenceContextActive(contextId, identity)) return
if (completedTask) {
this.remotePick = normalizePickingTask({ ...this.remotePick, ...completedTask })
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
}
const phase = resolveCompletionPhase({ taskStatus: completedTask?.taskStatus, commandStatus: completedTask?.commandStatus })
if (phase === COMPLETION_PHASE.PROCESSING) {
this.showToast('完成命令已受理,后台处理中', 'success')
this.persistSession()
navigateBackOr('/pages/tasks/tasks')
return
}
if (phase === COMPLETION_PHASE.COMPLETED) clearLoadingCompletionAttempt(this.draftIdentity())
this.showToast('装柜已完成', 'success')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
if (shouldReconcileCompletion(error)) {
this.remoteError = '提交结果待确认,请返回任务列表刷新'
this.showToast('提交结果待确认', 'warning')
navigateBackOr('/pages/tasks/tasks')
return
}
if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '订舱、装柜关联或证据状态已变化,请刷新后重试'))
return
}
this.debugSubmit('complete.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
this.remoteError = error.network ? '网络失败,装柜完成未提交' : this.remoteFailureMessage(error, '装柜完成失败')
this.showToast('装柜完成失败', 'error')
}
finally {
if (this.isEvidenceContextActive(contextId, identity)) 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,
`当前明细 · 已拣 ${pickedCount}/${plannedCount} 箱`)
this.showToast(`已拣箱 ${pickedCount}/${plannedCount}`, 'success')
this.persistSession()
},
pickItem(id, source = 'manual') {
if (!this.requirePickingOperation()) return
const item = this.currentBox.items.find((entry) => entry.id === id)
if (isPalletItem(item)) { this.showToast('请扫描托盘码整体拣出', 'warning'); return }
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) {
if (!this.requirePickingOperation()) return
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) {
if (!this.requirePickingOperation()) return
if (isPalletItem(this.currentBox.items.find((item) => item.id === id))) return
this.skipTargetId = id
this.selectedSkipReason = ''
this.openOverlay('skip')
},
confirmSkip() {
if (!this.requirePickingOperation()) return
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) {
if (!this.requirePickingOperation()) return
if (isPalletItem(this.currentBox.items.find((item) => item.id === id))) return
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() {
if (!this.requirePickingOperation()) return
const item = this.partialTargetItem
if (!item) return
if (isPalletItem(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 `已处理${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 `拣出${picked}件${partial ? ` · 部分拣${partial}件` : ''}`
},
selectBoxFromList(entry) {
if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return
this.closeOverlay(false)
this.requestBoxSwitch(entry.index)
},
async submitPicking() {
if (this.pickingCompleted) {
await this.syncRemoteTask()
if (this.loadingStageReady) this.activeStage = 'evidence'
else this.showToast('装柜状态尚未同步,请刷新重试', 'warning')
return
}
if (!this.requirePickingOperation()) return
if (!this.summary.canSubmit) 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 actions = this.currentBox.items.map((item) => {
if (item.itemStatus && !['PENDING', 'PICKING'].includes(item.itemStatus)) return null
const status = this.itemState(item)
if (isPalletItem(item)) { if (status !== ITEM_STATUS.PICKED) throw new Error('已封托货物须先扫描 DP 托盘码整托拣出'); return { type: 'PICK_PALLET', itemId: item.taskItemId ?? item.id, palletNo: item.palletNo, version: Number(item.version || 0) } }
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)
if (!actions.length) {
this.debugSubmit('submit.empty-refresh', { serverItemStatuses: this.currentBox.items.map((item) => item.itemStatus || null) })
await this.syncRemoteTask()
if (this.loadingStageReady) this.activeStage = 'evidence'
else this.showToast('没有可提交的待拣明细,已刷新任务', 'warning')
return
}
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
const inventorySnapshot = await this.refreshInventorySnapshot()
this.debugSubmit('submit.inventory-snapshot', { snapshotTokenLength: inventorySnapshot?.snapshotToken ? String(inventorySnapshot.snapshotToken).length : 0, taskVersion: inventorySnapshot?.version ?? null })
if (!inventorySnapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
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 })
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
this.evidenceIdentity = this.captureEvidenceIdentity()
}
this.showToast('拣货已提交,正在刷新最新状态', 'success')
await this.syncRemoteTask()
if (!this.pickingCompleted) {
this.remoteError = this.remoteError || '拣货已提交,但最新任务状态尚未同步,请点击刷新任务'
this.showToast('拣货已提交,状态同步未完成,请刷新任务', 'warning')
return
}
} 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 = this.isTaskConflict(error)
const inventoryConflict = versionConflict && /库存/.test(error.message || '')
const conflictMessage = inventoryConflict ? '库存已变化,请刷新后重试' : '任务版本已变化,请刷新后重试'
if (versionConflict) {
await this.refreshAfterTaskConflict(conflictMessage)
return
}
this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : this.remoteFailureMessage(error, '拣货完成失败')
this.showToast('拣货完成未提交,请查看错误提示', '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 }
if (this.loadingStageReady) 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')
},
continueToEvidence() {
this.closeOverlay(false)
this.activeStage = 'evidence'
this.scrollToTop()
},
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>