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