1823 lines
100 KiB
Vue
1823 lines
100 KiB
Vue
<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="scanInputLabel" :placeholder="scanPlaceholder" :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">{{ isSealedPalletItem(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 class="pick-item-location" :class="{ 'location-missing': !item.location }">库位 {{ item.location || '—' }}</text>
|
||
</view>
|
||
|
||
<view v-if="itemState(item) === ITEM_STATUS.PENDING && isSealedPalletItem(item)" class="pick-item-actions">
|
||
<button class="pick-btn pick-btn-confirm" @tap="pickItem(item.id)">确认整托拣出</button>
|
||
</view>
|
||
<view v-else-if="!isSealedPalletItem(item) && [ITEM_STATUS.PENDING, ITEM_STATUS.PARTIAL].includes(itemState(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 v-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
|
||
</view>
|
||
<view v-else-if="pickingOperationReady" class="pick-item-actions processed-actions">
|
||
<text class="version-hint">已同步服务端</text>
|
||
</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" :disabled="evidenceReadOnly" @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="!evidenceReadOnly && photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-delete-btn" aria-label="删除照片" @tap="deleteEvidencePhoto(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" :disabled="evidenceReadOnly" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
|
||
<button class="evidence-btn seal-btn" :disabled="evidenceReadOnly || 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>
|
||
<text v-if="evidenceReadOnly" class="version-hint" role="status">{{ loadingCompletionState.label }}</text>
|
||
<button v-else 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">{{ remainingCartons }}</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="submitting || !selectedIncompleteReason" @tap="confirmIncompleteSubmit">{{ submitting ? '正在提交…' : '确认未拣并提交' }}</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, isSealedPalletItem, submitStates, summarizeInventoryResources, remainingPhysicalCartons } 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, partialContainerItem, pickContainerItem, pickContainerPallet, skipContainerItem, 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, completionPresentation, 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: {
|
||
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)
|
||
},
|
||
remainingCartons() {
|
||
return remainingPhysicalCartons(this.currentBox.items, this.itemStates, this.partialQuantities)
|
||
},
|
||
scanInputLabel() {
|
||
return this.pendingItems.some((item) => isSealedPalletItem(item)) ? '托盘码或托内箱码扫码框' : '箱码扫码框'
|
||
},
|
||
scanPlaceholder() {
|
||
return this.pendingItems.some((item) => isSealedPalletItem(item)) ? '扫描托盘码或托内任一箱码' : '扫描或输入箱唛标签码'
|
||
},
|
||
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
|
||
},
|
||
loadingCompletionState() {
|
||
const phase = resolveCompletionPhase({
|
||
taskStatus: this.remotePick?.workflowStatus || this.remotePick?.taskStatus,
|
||
commandStatus: this.remotePick?.completionCommand?.status || this.remotePick?.commandStatus,
|
||
submitting: this.submitting
|
||
})
|
||
return { ...completionPresentation(phase), phase }
|
||
},
|
||
evidenceReadOnly() {
|
||
return this.loadingCompletionState.blocked && !this.loadingCompletionState.retryable
|
||
},
|
||
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 >= Number(this.partialTargetItem.pickedPackageCount || 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 < Number(this.partialTargetItem.pickedPackageCount || 0)) return `实际拣出总数不能少于已拣的 ${this.partialTargetItem.pickedPackageCount} 箱`
|
||
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) {
|
||
// Session display state can belong to the task opened before this route.
|
||
// Only the scoped draft restored after loading this task may restore its history.
|
||
this.scanValue = ''
|
||
this.scanHistory = []
|
||
// 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
|
||
const showGeneration = this.taskRequestId
|
||
const refreshPromise = (async () => {
|
||
await this.refreshAvailableBoxes()
|
||
if (!this._pageVisible || showGeneration !== this.taskRequestId) return
|
||
await this.syncRemoteTask()
|
||
})()
|
||
this._showRefreshPromise = refreshPromise
|
||
try { await refreshPromise }
|
||
finally { if (this._showRefreshPromise === refreshPromise) this._showRefreshPromise = null }
|
||
},
|
||
onHide() {
|
||
this._pageVisible = false
|
||
if (this.evidencePreviewOpening) {
|
||
this.evidencePreviewOpening = false
|
||
this.evidencePreviewReturning = true
|
||
}
|
||
if (!this.cameraPicking && !this.evidencePreviewReturning) this.invalidateTaskSync()
|
||
this.stopHardwareScanner()
|
||
},
|
||
onUnload() { this._pageVisible = false; this.invalidateTaskSync(); this.evidenceContextId++; this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
|
||
beforeUnmount() { this._pageVisible = false; this.invalidateTaskSync(); 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: {
|
||
invalidateTaskSync() { this.taskRequestId++; this.remoteLoading = false; this._showRefreshPromise = null },
|
||
isTaskSyncActive(requestId, taskId) {
|
||
return this._pageVisible === true && requestId === this.taskRequestId
|
||
&& String(this.remoteTaskId || this.currentBox.taskId) === String(taskId)
|
||
},
|
||
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, warehouseId: session.currentUser?.warehouseId })
|
||
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.evidenceReadOnly) {
|
||
this.showToast(this.loadingCompletionState.label, 'warning')
|
||
return false
|
||
}
|
||
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 restoredScans = restored.scannedBoxCodes || {}
|
||
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))
|
||
const hasPalletScan = isSealedPalletItem(item) && Object.entries(restoredScans).some(([code, itemId]) =>
|
||
String(itemId) === String(id) && (code === item?.palletNo || (item?.boxCodes || []).includes(code)))
|
||
if (isSealedPalletItem(item) && (status !== ITEM_STATUS.PICKED || !hasPalletScan)) 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(restoredScans).filter(([code, id]) => {
|
||
if (!itemIds.has(String(id))) return false
|
||
const item = this.currentBox.items.find((entry) => String(entry.id) === String(id))
|
||
return !isSealedPalletItem(item) || code === item.palletNo || (item.boxCodes || []).includes(code)
|
||
}))
|
||
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
|
||
},
|
||
isSealedPalletItem,
|
||
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.inboundCode || '—'}${reason}`
|
||
},
|
||
async 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
|
||
}
|
||
const submitted = result.type === 'pallet'
|
||
? await this.pickScannedPallet(result.item, value)
|
||
: await this.pickScannedBox(result.item, value)
|
||
if (submitted) playSuccessSound()
|
||
},
|
||
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 || !this._pageVisible) 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 (!this.isTaskSyncActive(requestId, requestedTaskId)) 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) => {
|
||
const inventoryResources = Array.isArray(item.inventoryResources) ? item.inventoryResources : []
|
||
const inventorySummary = summarizeInventoryResources(inventoryResources)
|
||
return {
|
||
...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) : [],
|
||
inventoryResources,
|
||
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 || inventorySummary.location,
|
||
availablePackageCount: item.availablePackageCount ?? item.locationAvailablePackageCount ?? item.availableQty ?? item.stockAvailableQty ?? inventorySummary.availablePackageCount,
|
||
locationIds: item.locationIds ?? item.reservationIds ?? inventoryResources.map((resource) => resource.locationId).filter(Boolean),
|
||
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 === 'PICKING' || 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') {
|
||
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||
const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}`
|
||
claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId)
|
||
if (!this.isTaskSyncActive(requestId, requestedTaskId)) 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.isTaskSyncActive(requestId, requestedTaskId)) return
|
||
if (this.loadingStageReady) this.activeStage = 'evidence'
|
||
} catch (error) {
|
||
if (!this.isTaskSyncActive(requestId, requestedTaskId)) 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 deleteEvidencePhoto(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
|
||
}
|
||
},
|
||
async pickScannedBox(item, boxCode) {
|
||
const submitted = await this.submitImmediateItemAction(item, 'PICK', { pickedPackageCount: 1, boxCode })
|
||
if (!submitted) return false
|
||
const latest = this.currentBox.items.find((entry) => String(entry.id) === String(item.id)) || item
|
||
const pickedCount = Number(latest.pickedPackageCount || 0)
|
||
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()
|
||
return true
|
||
},
|
||
async pickScannedPallet(item, palletNo) {
|
||
const submitted = await this.submitImmediateItemAction(item, 'PICK_PALLET', { palletNo: item.palletNo })
|
||
if (!submitted) return false
|
||
const plannedCount = Number(item.qty || 0)
|
||
this.scannedBoxCodes = { ...this.scannedBoxCodes, [item.palletNo]: item.id, [palletNo]: item.id }
|
||
this.partialQuantities = { ...this.partialQuantities, [item.id]: plannedCount }
|
||
this.itemStates = { ...this.itemStates, [item.id]: ITEM_STATUS.PICKED }
|
||
this.recordScanResult('success', '整托扫描成功', palletNo, `已定位托盘 ${item.palletNo} · 共 ${plannedCount} 箱`)
|
||
this.showToast(`托盘 ${item.palletNo} 已确认整托拣出`, 'success')
|
||
this.persistSession()
|
||
return true
|
||
},
|
||
async pickItem(id, source = 'manual') {
|
||
if (!this.requirePickingOperation()) return
|
||
const item = this.currentBox.items.find((entry) => entry.id === id)
|
||
if (isSealedPalletItem(item)) {
|
||
const submitted = await this.submitImmediateItemAction(item, 'PICK_PALLET', { palletNo: item.palletNo })
|
||
if (!submitted) return
|
||
const plannedCount = Number(item.qty || 0)
|
||
this.scannedBoxCodes = { ...this.scannedBoxCodes, [item.palletNo]: item.id }
|
||
this.partialQuantities = { ...this.partialQuantities, [item.id]: plannedCount }
|
||
this.itemStates = { ...this.itemStates, [item.id]: ITEM_STATUS.PICKED }
|
||
this.showToast(`托盘 ${item.palletNo} 已确认整托拣出`, 'success')
|
||
this.persistSession()
|
||
return
|
||
}
|
||
const available = this.availablePickQuantity(item)
|
||
if (available !== null && available < Number(item.qty || 0)) { this.showToast(`库存/库位可用量不足:可拣 ${available}`, 'error'); return }
|
||
const remaining = Number(item?.qty || 0) - Number(item?.pickedPackageCount || 0)
|
||
if (remaining < 1 || !await this.submitImmediateItemAction(item, 'PICK', { pickedPackageCount: remaining, source })) 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)
|
||
},
|
||
openSkip(id) {
|
||
if (!this.requirePickingOperation()) return
|
||
const item = this.currentBox.items.find((entry) => entry.id === id)
|
||
if (isSealedPalletItem(item)) { this.showToast(`整托货物不能跳拣,请扫描 ${item.palletNo}`, 'warning'); return }
|
||
this.skipTargetId = id
|
||
this.selectedSkipReason = ''
|
||
this.openOverlay('skip')
|
||
},
|
||
async confirmSkip() {
|
||
if (!this.requirePickingOperation()) return
|
||
if (!this.selectedSkipReason) {
|
||
this.showToast('请先选择跳拣原因', 'warning')
|
||
return
|
||
}
|
||
const item = this.currentBox.items.find((entry) => entry.id === this.skipTargetId)
|
||
const reason = this.selectedSkipReason
|
||
if (!item || !await this.submitImmediateItemAction(item, 'SKIP', { reason })) return
|
||
if (item) item.skipReason = this.selectedSkipReason
|
||
this.itemStates = { ...this.itemStates, [this.skipTargetId]: ITEM_STATUS.SKIPPED }
|
||
this.closeOverlay(false)
|
||
this.showToast(`已标记跳拣:${reason}`, 'warning')
|
||
this.persistSession()
|
||
},
|
||
openPartial(id) {
|
||
if (!this.requirePickingOperation()) return
|
||
const item = this.currentBox.items.find((entry) => entry.id === id)
|
||
if (isSealedPalletItem(item)) { this.showToast(`整托货物不能部分拣货,请扫描 ${item.palletNo}`, 'warning'); return }
|
||
this.partialTargetId = id
|
||
this.partialInput = Number(item?.pickedPackageCount || 0) > 0 ? String(item.pickedPackageCount) : ''
|
||
this.openOverlay('partial')
|
||
this.$nextTick(() => {
|
||
this.partialFocused = true
|
||
})
|
||
},
|
||
setPartialInput(value) {
|
||
this.partialInput = String(value)
|
||
this.partialFocused = false
|
||
this.$nextTick(() => {
|
||
this.partialFocused = true
|
||
})
|
||
},
|
||
async confirmPartial() {
|
||
if (!this.requirePickingOperation()) return
|
||
const item = this.partialTargetItem
|
||
if (!item) return
|
||
if (!this.partialInputValid) return
|
||
const value = parseInt(this.partialInput, 10)
|
||
const available = this.availablePickQuantity(item)
|
||
if (available !== null && value > available) { this.showToast(`超过库存/库位可用量:可拣 ${available}`, 'error'); return }
|
||
if (!await this.submitImmediateItemAction(item, 'PARTIAL', { finalPickedPackageCount: value })) 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()
|
||
},
|
||
async submitImmediateItemAction(item, action, payload = {}) {
|
||
if (this.submitting) { this.showToast('上一笔拣货正在提交,请稍候', 'warning'); return false }
|
||
if (!this.remoteTaskId || !this.currentBox.warehouseId || this.pickingVersion === null) {
|
||
this.showToast('任务状态尚未同步,请刷新重试', 'warning')
|
||
return false
|
||
}
|
||
const itemId = item.taskItemId ?? item.remoteItemId ?? item.id
|
||
const identity = stableTaskOperationIdentity(this.draftIdentity())
|
||
const operationId = stableOperationId(`item-${action.toLowerCase()}`, identity, {
|
||
itemId, itemVersion: item.version, taskVersion: this.pickingVersion, ...payload
|
||
})
|
||
const common = { warehouseId: this.currentBox.warehouseId, version: Number(item.version || 0), operationId }
|
||
this.submitting = true
|
||
this.remoteError = ''
|
||
try {
|
||
let task
|
||
if (action === 'PICK') task = await pickContainerItem(this.remoteTaskId, itemId, { ...common, pickedPackageCount: payload.pickedPackageCount })
|
||
else if (action === 'PARTIAL') task = await partialContainerItem(this.remoteTaskId, itemId, { ...common, finalPickedPackageCount: payload.finalPickedPackageCount })
|
||
else if (action === 'SKIP') task = await skipContainerItem(this.remoteTaskId, itemId, { ...common, reason: payload.reason })
|
||
else task = await pickContainerPallet(this.remoteTaskId, { warehouseId: this.currentBox.warehouseId, palletNo: payload.palletNo, expectedTaskVersion: Number(this.pickingVersion), operationId })
|
||
this.applyImmediateTaskResult(task)
|
||
return true
|
||
} catch (error) {
|
||
if (this.isTaskConflict(error)) await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '装柜明细已变化,请刷新后重试'))
|
||
else {
|
||
this.remoteError = error.network ? '网络失败,本次拣货未提交' : this.remoteFailureMessage(error, '拣货提交失败')
|
||
this.showToast('拣货未提交,请重试', 'error')
|
||
}
|
||
return false
|
||
} finally {
|
||
this.submitting = false
|
||
}
|
||
},
|
||
applyImmediateTaskResult(result) {
|
||
if (!result) return
|
||
const task = normalizePickingTask({ ...this.remotePick, ...result })
|
||
this.remotePick = task
|
||
this.pickingVersion = task.pickingVersion ?? task.version ?? this.pickingVersion
|
||
const remoteItems = Array.isArray(result.items) ? result.items : []
|
||
const byId = new Map(remoteItems.map((item) => [String(item.id ?? item.itemId), item]))
|
||
this.currentBox.items = this.currentBox.items.map((item) => {
|
||
const remote = byId.get(String(item.taskItemId ?? item.remoteItemId ?? item.id))
|
||
return remote ? { ...item, ...remote, id: item.id, taskItemId: remote.id ?? remote.itemId ?? item.taskItemId } : item
|
||
})
|
||
const nextStates = { ...this.itemStates }
|
||
const nextPartial = { ...this.partialQuantities }
|
||
this.currentBox.items.forEach((item) => {
|
||
const count = Number(item.pickedPackageCount || 0)
|
||
if (item.itemStatus === 'PICKED') nextStates[item.id] = ITEM_STATUS.PICKED
|
||
else if (item.itemStatus === 'PICKING' || item.itemStatus === 'PARTIAL') {
|
||
nextStates[item.id] = ITEM_STATUS.PARTIAL
|
||
nextPartial[item.id] = count
|
||
} else if (item.itemStatus === 'SKIPPED') nextStates[item.id] = ITEM_STATUS.SKIPPED
|
||
})
|
||
this.itemStates = nextStates
|
||
this.partialQuantities = nextPartial
|
||
this.evidenceIdentity = this.captureEvidenceIdentity()
|
||
},
|
||
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.submitting || !this.selectedIncompleteReason) return
|
||
return this.performSubmit(this.selectedIncompleteReason)
|
||
},
|
||
async performSubmit(incompleteReason = '') {
|
||
if (this.submitting) return
|
||
const beforeSubmit = this.summary
|
||
const remoteRequired = Boolean(session.token)
|
||
this.remoteError = ''
|
||
const pendingPallet = this.currentBox.items.find((item) => isSealedPalletItem(item) && this.itemState(item) !== ITEM_STATUS.PICKED)
|
||
if (pendingPallet) {
|
||
this.remoteError = `请扫描托盘码 ${pendingPallet.palletNo}、托内任一箱码,或点击“确认整托拣出”后再提交`
|
||
this.showToast(this.remoteError, 'warning')
|
||
return
|
||
}
|
||
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)
|
||
const itemId = item.taskItemId ?? item.remoteItemId ?? item.id
|
||
if (status === ITEM_STATUS.PICKED && isSealedPalletItem(item)) return { type: 'PICK_PALLET', itemId, palletNo: item.palletNo, version: Number(item.version || 0) }
|
||
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)
|
||
if (versionConflict) {
|
||
const conflictMessage = sanitizeUserMessage(error.message, '任务状态已变化,请刷新后重试')
|
||
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>
|