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

1668 lines
90 KiB
Vue
Raw Normal View History

2026-07-30 18:04:37 +08:00
<template>
<view v-if="currentUser" class="page-shell">
<view class="work-page" :aria-hidden="activeOverlay ? 'true' : 'false'">
<view class="app-header">
<view class="header-top">
<button class="header-back" aria-label="返回上一页" @tap="leavePicking">‹</button>
2026-09-11 11:19:47 +08:00
<text class="header-title">出库拣货</text>
2026-07-30 18:04:37 +08:00
<view class="header-actions">
<text class="header-badge">{{ currentUser.warehouse }}</text>
</view>
</view>
</view>
2026-08-27 13:50:44 +08:00
<view class="work-content" :class="{ 'evidence-active': activeStage === 'evidence' }">
2026-07-30 18:04:37 +08:00
<view class="box-select-wrap">
<button class="box-select-btn" @tap="toggleBoxDropdown">
2026-08-31 17:10:46 +08:00
<text class="box-select-summary">{{ containerReference(currentBox) }}</text>
2026-07-30 18:04:37 +08:00
<text class="box-chevron" :class="{ open: boxDropdownOpen }">▼</text>
</button>
<view v-if="boxDropdownOpen" class="box-dropdown">
<view
v-for="(box, index) in boxes"
:key="box.taskId || box.cabinetNo"
2026-07-30 18:04:37 +08:00
class="box-option"
:class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }"
@tap="requestBoxSwitch(index)"
>
2026-08-31 17:10:46 +08:00
<view><text class="box-option-number">{{ containerReference(box) }}</text></view>
2026-07-30 18:04:37 +08:00
<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>
2026-08-15 11:51:18 +08:00
<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>
2026-08-27 13:50:44 +08:00
<button class="stage-tab" :class="{ active: activeStage === 'evidence' }" role="tab" :aria-selected="activeStage === 'evidence'" @tap="selectStage('evidence')">装柜取证</button>
2026-08-15 11:51:18 +08:00
</view>
2026-08-15 12:12:40 +08:00
<view v-if="activeStage === 'picking'" class="picking-stage">
2026-07-30 18:04:37 +08:00
<view class="scan-section">
<view class="scan-row">
2026-08-11 20:39:38 +08:00
<ScanTargetInput v-model="scanValue" name="boxCode" label="箱码扫码框" placeholder="扫描或输入箱唛标签码" :active="activeScanField === 'boxCode'" @activate="setScanTarget" @submit="handleManualSubmit" />
2026-07-30 18:04:37 +08:00
</view>
<view v-if="lastScanResult" class="last-scan" :class="lastScanResult.type" role="status">
<view class="last-scan-icon">{{ lastScanResult.type === 'success' ? '✓' : '!' }}</view>
<view class="last-scan-content">
<text class="last-scan-label">最近扫码 · {{ lastScanResult.title }}</text>
2026-08-12 09:26:00 +08:00
<text class="last-scan-box-code">{{ lastScanResult.boxCode }}</text>
2026-07-30 18:04:37 +08:00
<text class="last-scan-detail">{{ lastScanResult.detail }}</text>
</view>
</view>
<view v-if="scanHistory.length > 1" class="scan-history">
2026-08-12 09:26:00 +08:00
<text v-for="entry in scanHistory.slice(1, 3)" :key="entry.id" class="scan-history-item">{{ entry.boxCode }} · {{ entry.title }}</text>
2026-07-30 18:04:37 +08:00
</view>
</view>
<view class="progress-section">
<view class="progress-header">
<text class="progress-title">拣货进度</text>
<view class="progress-actions">
2026-08-27 16:42:36 +08:00
<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>
2026-07-30 18:04:37 +08:00
</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>
2026-07-30 18:04:37 +08:00
</view>
<view
v-for="item in orderedItems"
:key="item.id"
class="pick-item"
:class="itemState(item)"
>
<view class="pick-item-row1">
2026-08-31 17:10:46 +08:00
<text class="pick-item-sku">拣货明细</text>
2026-07-30 18:04:37 +08:00
<view class="pick-item-meta">
<text v-if="itemState(item) === ITEM_STATUS.PICKED" class="pick-status-tag tag-picked">已拣 ✓</text>
<text v-else-if="itemState(item) === ITEM_STATUS.PARTIAL" class="pick-status-tag tag-partial">
部分拣 {{ partialQuantities[item.id] || 0 }}/{{ item.qty }}
</text>
<text v-else-if="itemState(item) === ITEM_STATUS.SKIPPED" class="pick-status-tag tag-skipped">已跳拣</text>
<view class="pick-item-qty">
2026-08-11 20:39:38 +08:00
<text>需拣 </text><text class="qty-number">{{ item.qty }}</text><text> 箱</text>
2026-07-30 18:04:37 +08:00
<template v-if="itemState(item) === ITEM_STATUS.PARTIAL">
<text> · 实拣 </text><text class="actual-number">{{ partialQuantities[item.id] || 0 }}</text>
</template>
</view>
</view>
</view>
<view class="pick-item-row2">
<text class="pick-item-desc">{{ itemDescription(item) }}</text>
2026-08-27 17:16:41 +08:00
<text v-if="item.location" class="pick-item-location">{{ item.location }}</text>
2026-07-30 18:04:37 +08:00
</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>
2026-08-27 14:30:26 +08:00
<view v-else-if="pickingOperationReady" class="pick-item-actions processed-actions">
2026-07-30 18:04:37 +08:00
<button class="pick-btn pick-btn-undo" @tap="undoItem(item.id)">撤销</button>
</view>
</view>
</view>
2026-08-15 11:51:18 +08:00
</view>
2026-07-30 18:04:37 +08:00
</view>
2026-08-27 13:50:44 +08:00
<view v-if="activeStage === 'evidence'" class="loading-evidence-section">
2026-08-06 13:56:01 +08:00
<view class="section-heading-row">
2026-08-27 14:30:26 +08:00
<view><text class="section-title">装柜证据</text><text class="section-subtitle">每类至少 1 张,拍照后自动上传,可继续追加</text></view>
2026-08-06 13:56:01 +08:00
<text class="sync-state" :class="remoteSyncState.tone">{{ remoteSyncState.label }}</text>
</view>
<view class="evidence-grid">
2026-08-27 14:30:26 +08:00
<view v-for="(slot, slotIndex) in evidenceSlots" :key="slot.type" class="evidence-card" :class="{ filled: evidenceEntries(slot.type).length }">
2026-08-17 17:49:08 +08:00
<view class="evidence-card-header">
2026-08-27 14:30:26 +08:00
<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>
2026-08-10 11:53:43 +08:00
</view>
2026-08-17 17:49:08 +08:00
<button class="evidence-btn evidence-add-btn" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
</view>
<view v-if="evidenceEntries(slot.type).length" class="evidence-photo-grid">
<view v-for="photo in evidenceEntries(slot.type)" :key="photo.id || photo.fileId || photo.localPath" class="evidence-photo-item">
<image v-if="evidencePreviewSource(photo)" class="evidence-thumbnail" :src="evidencePreviewSource(photo)" mode="aspectFill" @tap="previewEvidencePhoto(photo, slot.type)" />
<view v-else class="evidence-thumbnail evidence-thumbnail-placeholder" @tap="previewEvidencePhoto(photo, slot.type)">图片处理中</view>
<text class="evidence-file" @tap="previewEvidencePhoto(photo, slot.type)">{{ photo.fileName || '已记录' }}</text>
<button v-if="photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-void-btn" @tap="voidEvidencePhoto(photo)">作废</button>
</view>
</view>
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
{{ evidenceUploadState(slot.type) }}
</text>
2026-09-01 11:38:45 +08:00
<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>
2026-08-06 13:56:01 +08:00
</view>
</view>
<view class="seal-row">
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
2026-09-01 11:38:45 +08:00
<button class="evidence-btn seal-btn" :disabled="sealSaving || !sealReadyToSave" @tap="saveSealNo">{{ sealSaving ? '上传中…' : '保存并上传' }}</button>
2026-08-06 13:56:01 +08:00
</view>
2026-08-11 11:31:05 +08:00
<view v-if="remoteError" class="remote-error-row"><text class="remote-error" role="alert">{{ remoteError }}</text><button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button></view>
<button class="loading-complete-btn" :disabled="submitting || !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>
2026-08-06 13:56:01 +08:00
</view>
2026-08-15 12:12:40 +08:00
<view v-if="pickingCompleted && !loadingStageReady && activeStage === 'picking'" class="remote-error-row submit-error-row">
2026-08-15 09:49:05 +08:00
<text class="version-hint" role="status">拣货已完成,装柜状态尚未同步,请刷新重试</text>
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
</view>
2026-08-06 13:56:01 +08:00
2026-08-15 12:12:40 +08:00
<view v-if="activeStage === 'picking'" class="submit-section">
2026-07-30 18:04:37 +08:00
<view class="submit-info">
2026-08-11 20:39:38 +08:00
<view class="submit-pending"><text class="submit-pending-number">{{ summary.pending }}</text><text class="submit-pending-label"> 箱待拣</text></view>
2026-07-30 18:04:37 +08:00
<text class="submit-status-hint">{{ submitStatusHint }}</text>
</view>
2026-08-15 09:47:28 +08:00
<view v-if="remoteError && !pickingCompleted" class="remote-error-row submit-error-row">
<text class="remote-error" role="alert">{{ remoteError }}</text>
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
</view>
2026-08-17 16:17:25 +08:00
<button class="submit-btn" aria-label="提交拣货完成" :disabled="submitting || (!pickingCompleted && !summary.canSubmit)" @tap="submitPicking">
2026-08-06 13:56:01 +08:00
{{ submitting ? '正在提交…' : submitButtonText }}
2026-07-30 18:04:37 +08:00
</button>
</view>
</view>
<uni-popup ref="boxListPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('boxList', $event)">
<view class="modal-sheet box-list-modal" role="dialog" aria-modal="true" :aria-label="boxListType === 'done' ? '已拣集装箱' : '待拣集装箱'" @tap.stop>
<PopupHeader :title="boxListType === 'done' ? '已拣集装箱' : '待拣集装箱'" @close="closeOverlay" />
<view v-if="visibleBoxList.length === 0" class="modal-empty">
{{ boxListType === 'done' ? '暂无已拣完成的集装箱' : '所有集装箱已拣完成' }}
</view>
<view
v-for="entry in visibleBoxList"
:key="entry.box.boxNo"
class="box-list-item"
:class="boxListType === 'done' ? 'box-list-item-done' : 'box-list-item-pending'"
@tap="selectBoxFromList(entry)"
>
<view class="box-list-info">
2026-08-31 17:10:46 +08:00
<text class="box-list-number">{{ containerReference(entry.box) }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}</text>
2026-07-30 18:04:37 +08:00
<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">
2026-08-27 17:16:41 +08:00
<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>
2026-07-30 18:04:37 +08:00
</view>
2026-08-27 17:16:41 +08:00
<view class="partial-field">
<text class="partial-input-label">实际拣出数量</text>
<view class="partial-input-control"><input
v-model="partialInput"
class="partial-input"
aria-label="实际拣出数量"
2026-07-30 18:04:37 +08:00
type="number"
2026-08-27 17:16:41 +08:00
placeholder="请输入数量"
placeholder-class="partial-input-placeholder"
2026-07-30 18:04:37 +08:00
:focus="partialFocused"
confirm-type="done"
@confirm="confirmPartial"
2026-08-27 17:16:41 +08:00
/><text class="partial-input-unit">{{ partialTargetItem ? partialTargetItem.unit : '' }}</text></view>
2026-07-30 18:04:37 +08:00
</view>
<view class="partial-quick-row">
<button
v-for="option in partialQuickOptions"
:key="option.label"
class="partial-quick-btn"
2026-08-27 17:16:41 +08:00
:class="{ selected: String(partialInput) === String(option.value) }"
2026-07-30 18:04:37 +08:00
@tap="setPartialInput(option.value)"
>
2026-08-27 17:16:41 +08:00
<text class="partial-quick-ratio">{{ option.label }}</text><text class="partial-quick-value">{{ option.value }}{{ partialTargetItem ? partialTargetItem.unit : '' }}</text>
2026-07-30 18:04:37 +08:00
</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">
2026-08-27 17:16:41 +08:00
<text v-for="item in pendingItems.slice(0, 3)" :key="item.id">{{ item.location ? `${item.sku} · ${item.location}` : item.sku }}</text>
2026-07-30 18:04:37 +08:00
<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>
2026-08-31 17:10:46 +08:00
<text class="complete-desc">{{ containerReference(currentBox) }}</text>
2026-08-27 14:30:26 +08:00
<view class="complete-evidence-status">
<text>装柜照片已拍 {{ evidenceCapturedTypeCount }}/{{ evidenceSlots.length }} 类</text>
<text>{{ sealNo ? '封签号已填写' : '封签号未填写' }}</text>
</view>
2026-07-30 18:04:37 +08:00
<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>
2026-08-27 14:30:26 +08:00
<button class="complete-btn-next" @tap="continueToEvidence">{{ evidenceNextButtonText }}</button>
2026-07-30 18:04:37 +08:00
</view>
</view>
</uni-popup>
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
</view>
</template>
<script>
2026-08-15 09:47:28 +08:00
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates } from '@/utils/picking-state.js'
2026-07-30 18:04:37 +08:00
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
import { session, requireSession } from '@/stores/session.js'
2026-08-27 14:53:44 +08:00
import { clearLoadingCompletionAttempt, clearPickingDraft, getPickingSession, restoreLoadingCompletionAttempt, restorePickingSession, saveLoadingCompletionAttempt, savePickingSession } from '@/stores/picking.js'
2026-07-30 18:04:37 +08:00
import { navigateBackOr } from '@/utils/navigation.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { claimContainerPickingTask, completeContainerPickingTask, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, isRemoteReady, listLoadingOrders, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, voidEvidence } from '@/api/pda-loading.js'
import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js'
2026-08-11 11:31:05 +08:00
import { isOnline } from '@/utils/network.js'
import { stableOperationId } from '@/utils/idempotency.js'
2026-08-12 18:32:31 +08:00
import { stableTaskOperationIdentity } from '@/utils/task-identity.js'
2026-08-27 14:53:44 +08:00
import { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js'
2026-08-31 17:10:46 +08:00
import { containerReference, formatApiError, sanitizeUserMessage } from '@/utils/user-facing.js'
2026-07-30 18:04:37 +08:00
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import PopupHeader from '@/components/PopupHeader.vue'
const VERSION_SYNC_MESSAGE = '状态尚未同步,请刷新重试'
function isPickingCompleteTask(task) {
const workflowStatus = task?.workflowStatus
const taskStatus = task?.taskStatus ?? task?.status
return workflowStatus === 'AWAITING_REVIEW'
2026-08-27 14:53:44 +08:00
|| workflowStatus === 'PROCESSING'
|| workflowStatus === 'COMPLETION_FAILED'
|| workflowStatus === 'COMPLETED'
2026-08-27 14:53:44 +08:00
|| taskStatus === 'COMPLETING'
|| taskStatus === 'COMPLETION_FAILED'
|| taskStatus === 'COMPLETED'
}
2026-07-30 18:04:37 +08:00
export default {
components: { PopupHeader, ScanTargetInput },
data() {
return {
ITEM_STATUS,
currentUser: session.currentUser,
skipReasons: SKIP_REASONS,
incompleteReasons: INCOMPLETE_REASONS,
...getPickingSession(),
2026-08-11 20:39:38 +08:00
activeScanField: 'boxCode',
2026-08-15 11:51:18 +08:00
activeStage: 'picking',
2026-07-30 18:04:37 +08:00
partialFocused: false,
boxDropdownOpen: false,
activeOverlay: '',
boxListType: 'pending',
skipTargetId: null,
selectedSkipReason: '',
partialTargetId: null,
partialInput: '',
switchTargetIndex: null,
selectedIncompleteReason: '',
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
evidenceSlots: EVIDENCE_SLOTS,
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,
2026-08-06 13:56:01 +08:00
remoteError: '', remoteLoading: false, submitting: false,
2026-07-30 18:04:37 +08:00
toast: { visible: false, message: '', type: '' }
}
},
computed: {
currentBox() {
2026-08-30 17:36:57 +08:00
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', inboundCode: '', items: [] }
2026-07-30 18:04:37 +08:00
},
summary() {
2026-08-11 20:39:38 +08:00
return getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities)
2026-07-30 18:04:37 +08:00
},
orderedItems() {
const pending = []
const processed = []
this.currentBox.items.forEach((item) => {
if (this.itemState(item) === ITEM_STATUS.PENDING) pending.push(item)
else processed.push(item)
})
return pending.concat(processed)
},
pendingItems() {
return this.currentBox.items.filter((item) => this.itemState(item) === ITEM_STATUS.PENDING)
},
lastScanResult() {
return this.scanHistory[0] || null
},
completedCount() {
return Object.keys(this.completedBoxes).filter((key) => this.completedBoxes[key]).length
},
pendingBoxCount() {
return this.boxes.length - this.completedCount
},
pickingCompleted() {
2026-08-17 15:55:17 +08:00
if (this.remoteTaskId || this.currentBox.taskId) {
if (!this.remotePick) return false
2026-08-17 15:41:50 +08:00
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
},
2026-08-15 09:49:05 +08:00
loadingStageReady() {
return this.pickingCompleted
&& this.evidenceIdentity
&& this.evidenceIdentity.taskId
&& this.evidenceIdentity.warehouseId
&& this.pickingVersion !== null
&& Number.isFinite(Number(this.pickingVersion))
2026-08-15 09:49:05 +08:00
},
2026-07-30 18:04:37 +08:00
partialTargetItem() {
return this.currentBox.items.find((item) => item.id === this.partialTargetId) || null
},
partialQuickOptions() {
if (!this.partialTargetItem) return []
const quantity = this.partialTargetItem.qty
return [
{ label: '1/4', value: Math.floor(quantity * 0.25) },
{ label: '1/2', value: Math.floor(quantity * 0.5) },
{ label: '3/4', value: Math.floor(quantity * 0.75) },
{ label: '差1', value: Math.max(1, quantity - 1) }
]
},
partialInputValid() {
if (!this.partialTargetItem) return false
const value = parseInt(this.partialInput, 10)
2026-08-10 11:53:43 +08:00
const available = this.availablePickQuantity(this.partialTargetItem)
return Number.isInteger(value) && value > 0 && value < this.partialTargetItem.qty && (available === null || value <= available)
2026-07-30 18:04:37 +08:00
},
partialValidationMessage() {
if (!this.partialInput || !this.partialTargetItem) return ''
const value = parseInt(this.partialInput, 10)
if (!Number.isInteger(value) || value <= 0) return '请输入大于 0 的整数'
if (value >= this.partialTargetItem.qty) return `部分拣货必须少于 ${this.partialTargetItem.qty},整单请返回后点击“确认拣出”`
2026-08-10 11:53:43 +08:00
const available = this.availablePickQuantity(this.partialTargetItem)
if (available !== null && value > available) return `超过库存/库位可用量,可拣 ${available}`
2026-07-30 18:04:37 +08:00
return ''
},
2026-08-15 09:47:28 +08:00
submitButtonText() {
2026-08-17 16:17:25 +08:00
return this.pickingCompleted ? '继续装柜取证' : this.summary.buttonText
2026-07-30 18:04:37 +08:00
},
submitStatusHint() {
2026-08-17 16:17:25 +08:00
if (this.pickingCompleted) return '拣货已完成,请继续装柜取证'
2026-07-30 18:04:37 +08:00
if (!this.summary.canSubmit) return '请先处理至少一件'
if (this.summary.pending > 0) return '提交时需选择原因'
return `已处理 ${this.summary.processed}/${this.summary.total}`
},
2026-08-27 14:30:26 +08:00
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())
? '查看装柜取证'
: '去拍照'
2026-07-30 18:04:37 +08:00
},
visibleBoxList() {
return this.boxes
.map((box, index) => ({ box, index }))
.filter((entry) => this.boxListType === 'done'
? Boolean(this.completedBoxes[entry.index])
: !this.completedBoxes[entry.index])
2026-08-06 13:56:01 +08:00
},
remoteSyncState() {
if (!isRemoteReady(this.remoteTaskId || this.currentBox.taskId)) return { label: '未连接', tone: 'error' }
2026-08-06 13:56:01 +08:00
if (this.remoteLoading) return { label: '同步中', tone: 'loading' }
if (this.remoteError) return { label: '同步失败', tone: 'error' }
return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' }
2026-08-10 11:53:43 +08:00
},
evidenceComplete() {
return this.evidenceSlots.every((slot) => this.validEvidenceEntries(slot.type).length > 0) && Boolean(String(this.sealNo || '').trim())
2026-08-27 17:31:24 +08:00
},
sealReadyToSave() {
return Boolean(String(this.sealNo || '').trim()) && this.hasPendingLocalEvidence('SEAL')
2026-07-30 18:04:37 +08:00
}
},
created() {
this.resetEvidenceState()
},
2026-08-11 11:31:05 +08:00
onLoad(options = {}) {
if (!requireSession()) return
2026-08-27 14:30:26 +08:00
this.currentUser = session.currentUser
if (options.taskId) {
2026-08-17 15:55:17 +08:00
// A local completion marker belongs to the previously opened task. The
// canonical server detail decides whether this task is complete.
this.completedBoxes = {}
2026-08-11 11:31:05 +08:00
this.boxes = [{
2026-08-30 17:36:57 +08:00
boxNo: '', cabinetNo: null, currentContainerNo: null, orderNo: '', inboundCode: '',
taskId: String(options.taskId), loadingPlanId: null, containerBookingId: null,
2026-08-27 14:30:26 +08:00
inboundId: null, inboundVersion: null,
2026-08-11 11:31:05 +08:00
warehouseId: options.warehouseId ? String(options.warehouseId) : null,
2026-08-27 14:30:26 +08:00
tenantId: null, items: []
2026-08-11 11:31:05 +08:00
}]
this.currentBoxIndex = 0
this.remoteTaskId = options.taskId ? String(options.taskId) : null
}
},
2026-09-04 12:41:58 +08:00
async onShow() {
if (!requireSession()) return
2026-08-27 14:30:26 +08:00
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
}
2026-08-17 19:52:01 +08:00
await this.refreshAvailableBoxes()
await this.syncRemoteTask()
2026-09-04 12:41:58 +08:00
},
onHide() {
if (this.evidencePreviewOpening) {
this.evidencePreviewOpening = false
this.evidencePreviewReturning = true
}
this.stopHardwareScanner()
2026-09-04 12:41:58 +08:00
},
2026-07-30 18:04:37 +08:00
onUnload() { this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
beforeUnmount() { this.stopHardwareScanner(); this.clearTimers() },
onBackPress() {
if (this.activeOverlay) { this.closeOverlay(); return true }
if (this.boxDropdownOpen) { this.boxDropdownOpen = false; return true }
this.persistSession()
return false
},
methods: {
2026-08-31 17:10:46 +08:00
containerReference,
2026-08-17 19:52:01 +08:00
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 {
2026-08-30 17:36:57 +08:00
boxNo: String(task.currentContainerNo || existing?.boxNo || ''),
2026-08-17 19:52:01 +08:00
cabinetNo: task.cabinetNo ? String(task.cabinetNo) : (existing?.cabinetNo || null),
currentContainerNo: task.currentContainerNo ? String(task.currentContainerNo) : (existing?.currentContainerNo || null),
2026-08-30 17:36:57 +08:00
orderNo: String(task.orderNo || existing?.orderNo || ''),
inboundCode: String(task.inboundCode || existing?.inboundCode || ''),
2026-08-17 19:52:01 +08:00
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 {
2026-09-11 11:51:35 +08:00
const result = await listLoadingOrders({ page: 1, size: 50, warehouseId: session.currentUser?.warehouseId })
2026-08-17 19:52:01 +08:00
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')
}
},
2026-08-15 11:51:18 +08:00
selectStage(stage) {
this.activeStage = stage
},
2026-08-15 11:36:56 +08:00
debugEvidence(event, payload = {}) {
try { console.info(`[PDA] evidence.${event}`, payload) } catch (_) { /* console may be unavailable on a device */ }
},
2026-08-15 09:47:28 +08:00
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,
2026-08-15 09:47:28 +08:00
pickingVersion: this.pickingVersion,
pending: this.summary.pending,
...payload
})
} catch (_) { /* console may be unavailable on a device */ }
},
2026-08-17 15:41:50 +08:00
isTaskConflict(error) {
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
},
2026-08-24 20:47:13 +08:00
remoteFailureMessage(error, fallback = '操作失败') {
2026-08-30 17:36:57 +08:00
return formatApiError(error, fallback)
2026-08-24 20:47:13 +08:00
},
2026-08-17 15:41:50 +08:00
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
2026-08-17 15:41:50 +08:00
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
},
2026-08-15 09:49:05 +08:00
captureEvidenceIdentity() {
return { taskId: this.remoteTaskId || this.currentBox.taskId || null, warehouseId: this.currentBox.warehouseId || null }
2026-08-15 09:49:05 +08:00
},
isSameEvidenceIdentity(left, right) {
return String(left?.taskId ?? '') === String(right?.taskId ?? '')
&& String(left?.warehouseId ?? '') === String(right?.warehouseId ?? '')
2026-08-15 09:49:05 +08:00
},
2026-08-17 13:24:58 +08:00
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 })
2026-08-17 13:24:58 +08:00
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()
},
2026-08-15 09:49:05 +08:00
isEvidenceContextActive(contextId, identity) {
return contextId === this.evidenceContextId
2026-08-15 09:49:05 +08:00
&& this.isSameEvidenceIdentity(this.evidenceIdentity, identity)
},
2026-08-15 12:12:40 +08:00
evidenceDraftKey(identity) {
return `${String(identity?.taskId ?? '')}|${String(identity?.warehouseId ?? '')}`
2026-08-15 12:12:40 +08:00
},
rememberEvidenceDraft(identity = this.evidenceIdentity) {
const key = this.evidenceDraftKey(identity)
if (!identity?.taskId || !identity?.warehouseId) return
2026-08-15 12:12:40 +08:00
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) {
2026-08-15 12:12:40 +08:00
this.showToast('任务状态尚未同步,请刷新重试', 'warning')
return false
}
return true
},
2026-08-28 16:53:58 +08:00
requireEvidenceMutation() {
if (!this.requireEvidenceCapture()) return false
if (!isRemoteReady(this.evidenceIdentity.taskId)) {
2026-08-28 16:53:58 +08:00
this.showToast('当前会话未连接服务端,证据保留本地', 'warning')
return false
}
return true
},
requirePickingOperation() {
if (this.pickingOperationReady) return true
this.showToast('任务尚未领取完成,请稍候或刷新重试', 'warning')
return false
},
requireEvidenceStage() {
2026-08-15 09:49:05 +08:00
if (!this.pickingCompleted) {
this.showToast('请先提交拣货完成', 'warning')
return false
}
if (!this.loadingStageReady) {
this.showToast(VERSION_SYNC_MESSAGE, 'warning')
return false
}
return true
},
resetEvidenceState() {
const contextId = this.evidenceContextId + 1
this.evidenceContextId = contextId
2026-08-15 09:49:05 +08:00
this.evidenceIdentity = null
this.evidenceFiles = {}
this.evidenceQueueItems = []
this.sealNo = ''
2026-08-27 17:31:24 +08:00
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 }
})
},
2026-08-12 09:26:00 +08:00
normalizeScanHistory(entries) {
if (!Array.isArray(entries)) return []
return entries.map((entry) => {
const normalized = { ...entry, boxCode: entry.boxCode ?? entry.sku ?? '' }
delete normalized.sku
return normalized
})
},
draftIdentity() {
const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId
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
2026-08-11 11:31:05 +08:00
const itemIds = new Set(this.currentBox.items.map((item) => String(item.id)))
2026-08-17 15:41:50 +08:00
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 partial = Number(restored.partialQuantities?.[id] || 0)
if (status === ITEM_STATUS.PARTIAL && (!partial || partial >= Number(item?.qty || 0))) return
serverStates[id] = status
if (partial > 0) serverPartialQuantities[id] = partial
})
this.itemStates = serverStates
this.partialQuantities = serverPartialQuantities
this.scannedBoxCodes = Object.fromEntries(Object.entries(restored.scannedBoxCodes || {}).filter(([, id]) => itemIds.has(String(id))))
this.boxDrafts = restored.boxDrafts || {}
this.completedBoxes = restored.completedBoxes || {}
2026-08-17 15:41:50 +08:00
if (this.remotePick && !isPickingCompleteTask(this.remotePick)) {
const completedBoxes = { ...this.completedBoxes }
delete completedBoxes[this.currentBoxIndex]
this.completedBoxes = completedBoxes
}
2026-08-12 09:26:00 +08:00
this.scanHistory = this.normalizeScanHistory(restored.scanHistory)
return true
},
2026-07-30 18:04:37 +08:00
handleOverlayChange(name, event) {
if (!event.show && this.activeOverlay === name) this.resetOverlayState()
},
startHardwareScanner() {
this.stopHardwareScanner()
this._stopScanBroadcast = startScanBroadcast({
onScan: (value) => this.handleHardwareScan(value),
onError: () => this.showToast('硬件扫码接收器启动失败,请检查扫描服务', 'warning')
})
},
stopHardwareScanner() {
if (typeof this._stopScanBroadcast !== 'function') return
this._stopScanBroadcast()
this._stopScanBroadcast = null
},
handleHardwareScan(value) {
if (this.activeOverlay || this.boxDropdownOpen) return
2026-08-11 20:39:38 +08:00
if (this.activeScanField !== 'boxCode') return
2026-07-30 18:04:37 +08:00
this.scanValue = value
this.processScannedValue(value, 'broadcast')
},
handleManualSubmit(value) {
if (this.activeOverlay || this.boxDropdownOpen) return
const normalizedValue = String(value || '').trim().toUpperCase()
this.scanValue = normalizedValue
this.processScannedValue(normalizedValue, 'manual')
},
setScanTarget(fieldName) { this.activeScanField = fieldName },
openOverlay(name) {
this.activeOverlay = name
this.$nextTick(() => {
const popup = this.$refs[`${name}Popup`]
if (popup) popup.open()
})
},
persistSession() {
this.saveCurrentBoxDraft()
2026-08-11 20:39:38 +08:00
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, scannedBoxCodes: this.scannedBoxCodes, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }, this.draftIdentity())
2026-07-30 18:04:37 +08:00
},
leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') },
clearTimers() { clearTimeout(this._toastTimer); clearTimeout(this._evidencePreviewOpenTimer) },
2026-07-30 18:04:37 +08:00
scrollToTop() { this.$nextTick(() => { if (typeof uni !== 'undefined' && typeof uni.pageScrollTo === 'function') uni.pageScrollTo({ scrollTop: 0, duration: 0 }) }) },
saveCurrentBoxDraft() {
if (!this.currentBox.boxNo) return
this.boxDrafts = {
...this.boxDrafts,
[this.currentBoxIndex]: {
itemStates: { ...this.itemStates },
partialQuantities: { ...this.partialQuantities },
2026-08-11 20:39:38 +08:00
scannedBoxCodes: { ...this.scannedBoxCodes },
2026-07-30 18:04:37 +08:00
scanHistory: this.scanHistory.map((entry) => ({ ...entry }))
}
}
},
loadBoxState(index) {
const draft = this.boxDrafts[index]
if (draft) {
this.itemStates = { ...draft.itemStates }
this.partialQuantities = { ...draft.partialQuantities }
2026-08-11 20:39:38 +08:00
this.scannedBoxCodes = { ...(draft.scannedBoxCodes || {}) }
2026-08-12 09:26:00 +08:00
this.scanHistory = this.normalizeScanHistory(draft.scanHistory)
2026-07-30 18:04:37 +08:00
} else {
this.itemStates = createItemStates(this.currentBox.items)
this.partialQuantities = {}
2026-08-11 20:39:38 +08:00
this.scannedBoxCodes = {}
2026-07-30 18:04:37 +08:00
this.scanHistory = []
}
this.scanValue = ''
},
toggleBoxDropdown() {
this.boxDropdownOpen = !this.boxDropdownOpen
},
requestBoxSwitch(index) {
if (index === this.currentBoxIndex) {
this.boxDropdownOpen = false
return
}
this.boxDropdownOpen = false
if (!this.completedBoxes[this.currentBoxIndex] && this.summary.processed > 0 && this.summary.pending > 0) {
this.switchTargetIndex = index
this.openOverlay('switchBoxConfirm')
return
}
this.selectBox(index)
},
confirmBoxSwitch() {
if (this.switchTargetIndex === null) return
const targetIndex = this.switchTargetIndex
this.closeOverlay(false)
this.selectBox(targetIndex)
},
selectBox(index) {
this.saveCurrentBoxDraft()
this.taskRequestId += 1
this.submitting = false
2026-07-30 18:04:37 +08:00
this.currentBoxIndex = index
2026-08-15 11:51:18 +08:00
this.activeStage = 'picking'
2026-08-15 09:57:53 +08:00
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()
2026-07-30 18:04:37 +08:00
this.boxDropdownOpen = false
this.loadBoxState(index)
this.scrollToTop()
if (session.token && this.currentBox.taskId) this.syncRemoteTask()
this.showToast(`切换至集装箱 ${this.currentBox.currentContainerNo || '同步中'}`, 'success')
2026-07-30 18:04:37 +08:00
},
itemState(item) {
return this.itemStates[item.id] || ITEM_STATUS.PENDING
},
hasBoxDraft(index) {
const draft = this.boxDrafts[index]
return Boolean(draft && Object.values(draft.itemStates).some((status) => status !== ITEM_STATUS.PENDING))
},
itemDescription(item) {
const reason = this.itemState(item) === ITEM_STATUS.SKIPPED && item.skipReason
? ` · ${item.skipReason}`
: ''
return `${item.desc}${reason}`
},
processScannedValue(scanValue, source = 'broadcast') {
if (!this.requirePickingOperation()) return
2026-07-30 18:04:37 +08:00
const value = scanValue.trim()
if (!value) {
const manual = source === 'manual'
playErrorSound()
2026-08-11 20:39:38 +08:00
this.recordScanResult('error', manual ? '未输入箱码' : '未读取到条码', manual ? '输入内容为空' : '广播值为空', manual ? '请输入箱码后按“完成”' : '请重新扫描并检查设备广播配置')
this.showToast(manual ? '请输入箱码' : '广播扫码结果为空', 'warning')
2026-07-30 18:04:37 +08:00
return
}
2026-08-11 20:39:38 +08:00
const result = classifyScan(this.currentBox.items, this.itemStates, value, this.scannedBoxCodes)
2026-07-30 18:04:37 +08:00
if (result.type === 'processed') {
playErrorSound()
const labels = {
[ITEM_STATUS.PICKED]: '已拣出',
[ITEM_STATUS.PARTIAL]: '已部分拣货',
[ITEM_STATUS.SKIPPED]: '已跳拣'
}
2026-08-11 20:39:38 +08:00
this.recordScanResult('warning', '重复扫描', value, `该箱码已${labels[result.status]},无需重复处理`)
this.showToast(`该箱码已处理:${value}`, 'warning')
2026-07-30 18:04:37 +08:00
return
}
if (result.type === 'unknown') {
playErrorSound()
2026-08-11 20:39:38 +08:00
this.recordScanResult('error', '当前任务无此箱码', value, '请核对箱唛标签或转入异常处理')
this.showToast(`未找到匹配的待拣箱码:${value}`, 'error')
2026-07-30 18:04:37 +08:00
return
}
playSuccessSound()
2026-08-11 20:39:38 +08:00
this.pickScannedBox(result.item, value)
2026-07-30 18:04:37 +08:00
},
2026-08-10 11:53:43 +08:00
evidenceEntries(type) { return Array.isArray(this.evidenceFiles[type]) ? this.evidenceFiles[type].filter((item) => item.status !== 'VOID' && item.status !== 'VOIDED') : (this.evidenceFiles[type] ? [this.evidenceFiles[type]] : []) },
2026-08-15 12:33:33 +08:00
evidencePreviewSource(photo) { return photo?.localPath || photo?.fileUrl || photo?.downloadUrl || photo?.url || '' },
2026-08-15 12:12:40 +08:00
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 } })
2026-08-15 12:12:40 +08:00
},
2026-08-15 11:36:56 +08:00
evidenceItems(result) {
2026-08-27 17:31:24 +08:00
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) => {
2026-08-27 17:31:24 +08:00
const key = `${item.evidenceType || ''}:${item.fileId || `id-${item.id || ''}`}`
if (seen.has(key)) return false
seen.add(key)
return true
})
2026-08-15 11:36:56 +08:00
},
2026-08-10 11:53:43 +08:00
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 },
2026-08-15 12:12:40 +08:00
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 || '上传失败'
2026-08-17 19:52:01 +08:00
if (item.status === 'SUCCEEDED') {
return this.validEvidenceEntries(type).length ? '已上传并保存' : (item.attempts > 1 ? '重试成功' : '已上传')
}
if (item.status === 'REPLACED') return '已替换'
return ''
},
2026-08-06 13:56:01 +08:00
async syncRemoteTask() {
if (!session.token) return
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
if (!requestedTaskId) { this.remoteError = '当前没有可进入的拣货任务,请返回任务列表刷新'; return }
2026-08-15 12:12:40 +08:00
this.rememberEvidenceDraft()
2026-08-17 13:24:58 +08:00
const previousIdentity = this.draftIdentity()
2026-08-17 15:41:50 +08:00
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 }
2026-08-15 09:49:05 +08:00
this.currentBox.inboundId = null
this.currentBox.loadingPlanId = null
this.currentBox.containerBookingId = null
this.resetEvidenceState()
this.debugSubmit('refresh.start', { requestId, selector })
2026-08-06 13:56:01 +08:00
this.remoteLoading = true
this.remoteError = ''
try {
const warehouseId = this.currentBox.warehouseId
2026-08-15 09:49:05 +08:00
const task = await getContainerPickingTask(selector, warehouseId)
if (requestId !== this.taskRequestId) return
if (!task) throw new Error('服务端未返回当前拣货任务')
2026-08-17 13:24:58 +08:00
if (this.isMergedContainerRecord(task)) {
this.clearMergedTask(task, previousIdentity)
this.showToast('当前柜记录已合并,请返回任务列表刷新', 'warning')
return
}
if (task.recordStatus !== 'ACTIVE') throw new Error('当前柜记录尚未就绪,请返回任务列表刷新')
const detailTaskId = task.taskId ?? task.id
if (String(detailTaskId || '') !== String(requestedTaskId)) throw new Error('服务端返回的拣货任务与当前选择不一致,请返回刷新')
if (!task.loadingPlanId || !task.containerBookingId) throw new Error('任务尚未同步完整作业关联,请返回任务列表刷新')
if (task.version === null || task.version === undefined) throw new Error('任务状态尚未同步,请刷新重试')
if (!task.currentContainerNo) throw new Error('任务尚未同步集装箱号,请返回任务列表刷新')
const remoteItems = task.items || task.pickItems || task.details || task.lines
if (!Array.isArray(remoteItems) || !remoteItems.length) throw new Error('当前拣货任务暂无可作业明细,请返回刷新')
{
this.currentBox.items = remoteItems.map((item) => ({
...item,
id: item.id ?? item.itemId,
2026-08-15 09:47:28 +08:00
// The batch endpoint expects the PDA task-item primary key (`id`),
// not a nested loading-plan item alias when both are returned.
taskItemId: item.id ?? item.itemId,
2026-08-11 20:39:38 +08:00
boxCodes: Array.isArray(item.boxCodes) ? item.boxCodes.map(String) : [],
orderNo: item.orderNo ?? '', inboundCode: item.inboundCode ?? '',
desc: item.cargoName ?? item.desc ?? item.description ?? '箱级拣货明细',
qty: Number(item.qty ?? item.plannedPackageCount ?? item.packageCount ?? 0),
unit: '箱',
location: item.location ?? item.locationCode ?? '',
2026-08-10 11:53:43 +08:00
availablePackageCount: item.availablePackageCount ?? item.locationAvailablePackageCount ?? item.availableQty ?? item.stockAvailableQty ?? null,
locationIds: item.locationIds ?? item.reservationIds ?? [],
locationAvailable: item.locationAvailable ?? item.locationUsable ?? null,
locationStatus: item.locationStatus ?? null,
skipReason: item.skipReason || '', finalState: item.finalState || '', finalPartialQuantity: item.finalPartialQuantity || 0
}))
2026-08-11 20:39:38 +08:00
const firstItem = this.currentBox.items[0]
2026-08-30 17:36:57 +08:00
this.currentBox.orderNo = firstItem.orderNo || task.orderNo || this.currentBox.orderNo
this.currentBox.inboundCode = firstItem.inboundCode || task.inboundCode || this.currentBox.inboundCode
2026-08-15 09:47:28 +08:00
const serverStates = createItemStates(this.currentBox.items)
const serverPartialQuantities = {}
this.currentBox.items.forEach((item) => {
if (item.itemStatus === 'PICKED') serverStates[item.id] = ITEM_STATUS.PICKED
else if (item.itemStatus === 'PARTIAL') {
serverStates[item.id] = ITEM_STATUS.PARTIAL
serverPartialQuantities[item.id] = Number(item.pickedPackageCount ?? item.finalPickedPackageCount ?? 0)
} else if (item.itemStatus === 'SKIPPED') serverStates[item.id] = ITEM_STATUS.SKIPPED
})
this.itemStates = serverStates
this.partialQuantities = serverPartialQuantities
2026-08-11 20:39:38 +08:00
this.scannedBoxCodes = {}
}
this.remoteTaskId = detailTaskId
if (!this.remoteTaskId) throw new Error('服务端任务缺少 taskId,无法开始拣货')
2026-08-12 18:32:31 +08:00
this.currentBox.loadingPlanId = task.loadingPlanId
this.currentBox.containerBookingId = task.containerBookingId
this.currentBox.cabinetNo = task.cabinetNo
this.currentBox.boxNo = task.currentContainerNo
2026-08-12 18:32:31 +08:00
this.currentBox.currentContainerNo = task.currentContainerNo
const remoteTask = normalizePickingTask(task)
this.assignmentVersion = remoteTask.assignment?.version ?? remoteTask.assignmentVersion ?? (remoteTask.workflowStatus === 'UNASSIGNED' ? 0 : null)
2026-08-17 16:17:25 +08:00
if (remoteTask.workflowStatus !== 'AWAITING_REVIEW' && this.assignmentVersion === null) throw new Error('任务领取状态尚未同步,请刷新重试')
let claimed = null
if (remoteTask.workflowStatus === 'UNASSIGNED') {
const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}`
claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId)
if (requestId !== this.taskRequestId) return
} else if (remoteTask.workflowStatus === 'IN_PROGRESS') {
const assigneeId = remoteTask.assignment?.assigneeId
if (!assigneeId || String(assigneeId) !== String(session.currentUser?.id || '')) throw new Error('当前任务已由其他作业员领取,请返回任务列表刷新')
} else if (!isPickingCompleteTask(remoteTask)) {
throw new Error('当前任务尚未进入可拣货状态,请返回任务列表刷新')
}
this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) })
if (remoteTask.workflowStatus === 'UNASSIGNED' && this.remotePick.workflowStatus !== 'IN_PROGRESS') throw new Error('任务领取尚未完成,请刷新重试')
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null
2026-08-15 09:57:53 +08:00
this.evidenceIdentity = this.captureEvidenceIdentity()
2026-08-15 12:12:40 +08:00
this.restoreEvidenceDraft()
2026-08-17 15:41:50 +08:00
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
2026-08-15 09:47:28 +08:00
if (!this.pickingCompleted) this.restoreRemoteDraft()
2026-08-17 15:41:50 +08:00
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')
}
2026-08-28 16:53:58 +08:00
await this.loadLoadingEvidence()
if (this.loadingStageReady) this.activeStage = 'evidence'
2026-08-06 13:56:01 +08:00
} catch (error) {
if (requestId !== this.taskRequestId) return
2026-08-15 09:47:28 +08:00
this.debugSubmit('refresh.error', { requestId, message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
2026-08-27 16:34:37 +08:00
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
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
try {
const evidence = await getEvidence(identity.taskId, identity.warehouseId)
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-15 12:33:33 +08:00
const remoteItems = this.evidenceItems(evidence).map((item) => ({
...item,
previewUrl: item.previewUrl || getEvidencePreviewUrl(identity.taskId, item.id, identity.warehouseId)
2026-08-15 12:33:33 +08:00
}))
this.debugEvidence('list.loaded', { evidenceIdentity: identity, count: remoteItems.length, previewCount: remoteItems.filter((item) => item.previewUrl).length })
2026-08-15 12:12:40 +08:00
const localFiles = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath)]))
2026-08-15 12:33:33 +08:00
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')
2026-08-15 12:12:40 +08:00
if (seal?.sealNo) this.sealNo = seal.sealNo
2026-08-15 12:33:33 +08:00
await this.loadEvidencePreviews(contextId, identity)
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-15 12:12:40 +08:00
await this.flushPendingEvidence()
} catch (error) {
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-30 17:36:57 +08:00
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : this.remoteFailureMessage(error, '装柜证据加载失败')
}
2026-08-06 13:56:01 +08:00
},
2026-08-15 12:33:33 +08:00
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
})]))
},
2026-08-11 11:31:05 +08:00
async refreshTask() {
if (!this.currentBox.boxNo) return
this.persistSession()
await this.syncRemoteTask()
if (!this.remoteError) this.showToast('任务已刷新,本地拣货标记已保留', 'success')
},
evidenceUploadLabel(type) {
const queued = this.evidenceQueueEntry(type)
2026-08-15 12:12:40 +08:00
if (queued?.status === 'UPLOADING') return `上传中 ${queued.progress || 0}%`
if (queued?.status === 'PENDING') return '等待上传'
2026-08-11 11:31:05 +08:00
return this.evidenceEntry(type) ? '继续拍照' : '拍照'
},
2026-08-06 13:56:01 +08:00
chooseEvidence(slot) {
2026-08-15 11:36:56 +08:00
const evidenceType = slot?.type || null
this.debugEvidence('choose.start', { evidenceType, evidenceIdentity: this.evidenceIdentity, loadingStageReady: this.loadingStageReady, hasChooseImage: typeof uni.chooseImage === 'function' })
2026-08-15 12:12:40 +08:00
if (!this.requireEvidenceCapture()) { this.debugEvidence('choose.blocked', { evidenceType, reason: 'identity-not-ready', evidenceIdentity: this.evidenceIdentity }); return }
2026-08-15 11:36:56 +08:00
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
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
2026-08-15 11:36:56 +08:00
this.cameraPicking = true
this.debugEvidence('choose.open-camera', { evidenceType, contextId, evidenceIdentity: identity })
2026-08-10 11:53:43 +08:00
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => {
2026-08-15 11:36:56 +08:00
this.cameraPicking = false
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-06 13:56:01 +08:00
const path = result.tempFilePaths && result.tempFilePaths[0]
2026-08-15 11:36:56 +08:00
if (!path) { this.debugEvidence('camera.empty', { evidenceType: slot.type, evidenceIdentity: identity }); return }
this.debugEvidence('camera.selected', { evidenceType: slot.type, evidenceIdentity: identity })
2026-08-10 11:53:43 +08:00
const next = { evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' }
this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: [...(this.evidenceFiles[slot.type] || []), next] }
2026-08-15 12:12:40 +08:00
this.rememberEvidenceDraft(identity)
if (slot.type === 'SEAL') {
2026-09-01 11:38:45 +08:00
this.showToast('照片已拍摄,请输入封签号并保存上传', 'success')
return
}
2026-08-27 13:50:44 +08:00
this.queueEvidenceUpload(slot.type, path)
2026-08-15 11:36:56 +08:00
}, fail: (error) => {
this.cameraPicking = false
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-15 11:36:56 +08:00
this.debugEvidence('camera.error', { evidenceType: slot.type, evidenceIdentity: identity, message: error?.errMsg || '' })
this.showToast('未选择照片,可稍后重试', 'warning')
} })
2026-08-06 13:56:01 +08:00
},
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
2026-08-15 09:57:53 +08:00
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 })
2026-08-15 11:36:56 +08:00
this.debugSubmit('evidence.queue.start', { evidenceType: type, queueId: entry.id, evidenceIdentity: identity })
await queue.flush()
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
const latest = queue.snapshot().find((item) => item.id === entry.id)
2026-08-15 11:36:56 +08:00
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)
2026-09-01 11:38:45 +08:00
this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片已上传并保存', 'success')
2026-08-15 12:12:40 +08:00
},
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 })))
2026-08-17 15:41:50 +08:00
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)
2026-08-17 15:41:50 +08:00
}
},
async retryEvidence(type) {
2026-08-27 13:50:44 +08:00
if (!this.requireEvidenceCapture()) return
const item = this.evidenceQueueEntry(type)
const contextId = this.evidenceContextId
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
const queue = this.evidenceQueue
if (!item || !queue.retry(item.id)) return
this.remoteError = ''
await queue.flush()
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
const latest = queue.latest(type)
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
this.applyEvidenceResponse(latest.result, type, item.localPath, identity)
2026-09-01 11:38:45 +08:00
this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片重试上传成功', 'success')
2026-08-06 13:56:01 +08:00
},
2026-08-10 11:53:43 +08:00
async saveSealNo() {
2026-08-27 17:31:24 +08:00
if (this.sealSaving) return
2026-08-10 11:53:43 +08:00
if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return }
2026-08-15 12:12:40 +08:00
this.rememberEvidenceDraft()
const current = [...this.evidenceEntries('SEAL')].reverse().find((item) => item.localPath && !item.id)
2026-08-27 17:31:24 +08:00
if (!current) { this.showToast('当前封签已保存,无需重复提交', 'success'); return }
this.sealSaving = true
try {
await this.queueEvidenceUpload('SEAL', current.localPath, String(this.sealNo).trim())
2026-08-27 17:31:24 +08:00
} finally {
this.sealSaving = false
}
2026-08-10 11:53:43 +08:00
},
async voidEvidencePhoto(photo) {
2026-08-28 16:53:58 +08:00
if (!this.requireEvidenceMutation()) return
if (!photo?.id || !this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
const contextId = this.evidenceContextId
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
2026-08-10 11:53:43 +08:00
const reason = 'PDA现场标记错图'
try {
await voidEvidence(identity.taskId, photo.id, identity.warehouseId, { expectedVersion: photo.version || 0, reason })
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-10 11:53:43 +08:00
this.evidenceFiles = { ...this.evidenceFiles, [photo.evidenceType]: (this.evidenceFiles[photo.evidenceType] || []).map((item) => item.id === photo.id ? { ...item, status: 'VOID', voidReason: reason } : item) }
this.showToast('照片已作废,可重新拍摄', 'success')
} catch (error) {
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-17 15:41:50 +08:00
if (this.isTaskConflict(error)) {
2026-08-30 17:36:57 +08:00
await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '任务内容已更新,请刷新后重试'))
2026-08-17 15:41:50 +08:00
return
}
2026-08-30 17:36:57 +08:00
this.remoteError = error.network ? '网络失败,照片未作废,请重试' : this.remoteFailureMessage(error, '照片作废失败')
2026-08-10 11:53:43 +08:00
this.showToast('照片作废失败', 'error')
}
},
2026-08-06 13:56:01 +08:00
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 }
2026-08-10 11:53:43 +08:00
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
const contextId = this.evidenceContextId
2026-08-15 09:57:53 +08:00
const identity = { ...this.evidenceIdentity }
2026-08-27 16:17:15 +08:00
const serverAttempt = this.remotePick?.completionCommand
const savedAttempt = restoreLoadingCompletionAttempt(this.draftIdentity()) || (serverAttempt?.operationId ? {
operationId: serverAttempt.operationId,
expectedTaskVersion: serverAttempt.expectedTaskVersion,
remark: serverAttempt.remark || 'PDA确认装柜完成'
} : null)
2026-08-27 14:53:44 +08:00
const expectedTaskVersion = savedAttempt?.expectedTaskVersion ?? Number(this.pickingVersion)
const completionIdentity = this.draftIdentity()
const operationIdentity = stableTaskOperationIdentity(completionIdentity)
2026-08-27 14:53:44 +08:00
const operationId = savedAttempt?.operationId || stableOperationId('complete', operationIdentity, {
2026-08-17 09:41:16 +08:00
taskId: this.remoteTaskId,
expectedTaskVersion,
loadingPlanId: completionIdentity.loadingPlanId,
containerBookingId: completionIdentity.containerBookingId
2026-08-17 09:41:16 +08:00
})
2026-08-27 14:53:44 +08:00
const completionAttempt = { operationId, expectedTaskVersion, remark: savedAttempt?.remark || 'PDA确认装柜完成' }
saveLoadingCompletionAttempt(this.draftIdentity(), completionAttempt)
2026-08-06 13:56:01 +08:00
this.submitting = true
try {
2026-08-17 09:41:16 +08:00
const completedTask = await completeContainerPickingTask(this.remoteTaskId, {
expectedTaskVersion,
operationId,
2026-08-27 14:53:44 +08:00
remark: completionAttempt.remark
2026-08-17 09:41:16 +08:00
})
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-17 09:41:16 +08:00
if (completedTask) {
this.remotePick = normalizePickingTask({ ...this.remotePick, ...completedTask })
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
}
2026-08-27 14:53:44 +08:00
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) {
2026-08-15 09:57:53 +08:00
if (!this.isEvidenceContextActive(contextId, identity)) return
2026-08-27 14:53:44 +08:00
if (shouldReconcileCompletion(error)) {
this.remoteError = '提交结果待确认,请返回任务列表刷新'
this.showToast('提交结果待确认', 'warning')
navigateBackOr('/pages/tasks/tasks')
return
}
2026-08-17 15:41:50 +08:00
if (this.isTaskConflict(error)) {
2026-08-30 17:36:57 +08:00
await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '订舱、装柜关联或证据状态已变化,请刷新后重试'))
2026-08-17 15:41:50 +08:00
return
}
2026-08-24 20:47:13 +08:00
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 {
2026-08-15 09:57:53 +08:00
if (this.isEvidenceContextActive(contextId, identity)) this.submitting = false
}
2026-08-06 13:56:01 +08:00
},
2026-08-11 20:39:38 +08:00
pickScannedBox(item, boxCode) {
const pickedCount = Number(this.partialQuantities[item.id] || 0) + 1
const plannedCount = Number(item.qty || 0)
this.scannedBoxCodes = { ...this.scannedBoxCodes, [boxCode]: item.id }
this.partialQuantities = { ...this.partialQuantities, [item.id]: pickedCount }
this.itemStates = { ...this.itemStates,
[item.id]: pickedCount >= plannedCount ? ITEM_STATUS.PICKED : ITEM_STATUS.PARTIAL }
this.recordScanResult('success', '箱码扫描成功', boxCode,
2026-08-31 17:10:46 +08:00
`当前明细 · 已拣 ${pickedCount}/${plannedCount} 箱`)
2026-08-11 20:39:38 +08:00
this.showToast(`已拣箱 ${pickedCount}/${plannedCount}`, 'success')
this.persistSession()
},
2026-07-30 18:04:37 +08:00
pickItem(id, source = 'manual') {
if (!this.requirePickingOperation()) return
2026-07-30 18:04:37 +08:00
const item = this.currentBox.items.find((entry) => entry.id === id)
2026-08-10 11:53:43 +08:00
const available = this.availablePickQuantity(item)
if (available !== null && available < Number(item.qty || 0)) { this.showToast(`库存/库位可用量不足:可拣 ${available}`, 'error'); return }
2026-07-30 18:04:37 +08:00
this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PICKED }
2026-08-11 20:39:38 +08:00
this.partialQuantities = { ...this.partialQuantities, [id]: Number(item?.qty || 0) }
this.showToast(item ? `已确认拣出 ${item.qty} 箱` : '已确认拣出', 'success')
this.persistSession()
2026-07-30 18:04:37 +08:00
},
2026-08-12 09:26:00 +08:00
recordScanResult(type, title, boxCode, detail) {
const entry = { id: `${Date.now()}-${this.scanHistory.length}-${boxCode}`, type, title, boxCode, detail }
2026-07-30 18:04:37 +08:00
this.scanHistory = [entry, ...this.scanHistory].slice(0, 3)
},
undoItem(id) {
if (!this.requirePickingOperation()) return
2026-07-30 18:04:37 +08:00
const item = this.currentBox.items.find((entry) => entry.id === id)
if (item) item.skipReason = ''
this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PENDING }
const nextPartialQuantities = { ...this.partialQuantities }
delete nextPartialQuantities[id]
this.partialQuantities = nextPartialQuantities
2026-08-11 20:39:38 +08:00
this.scannedBoxCodes = Object.fromEntries(Object.entries(this.scannedBoxCodes)
.filter(([, itemId]) => String(itemId) !== String(id)))
2026-07-30 18:04:37 +08:00
this.showToast('已撤销操作', 'success')
this.persistSession()
2026-07-30 18:04:37 +08:00
},
openSkip(id) {
if (!this.requirePickingOperation()) return
2026-07-30 18:04:37 +08:00
this.skipTargetId = id
this.selectedSkipReason = ''
this.openOverlay('skip')
},
confirmSkip() {
if (!this.requirePickingOperation()) return
2026-07-30 18:04:37 +08:00
if (!this.selectedSkipReason) {
this.showToast('请先选择跳拣原因', 'warning')
return
}
const item = this.currentBox.items.find((entry) => entry.id === this.skipTargetId)
if (item) item.skipReason = this.selectedSkipReason
this.itemStates = { ...this.itemStates, [this.skipTargetId]: ITEM_STATUS.SKIPPED }
const reason = this.selectedSkipReason
this.closeOverlay(false)
this.showToast(`已标记跳拣:${reason}`, 'warning')
this.persistSession()
2026-07-30 18:04:37 +08:00
},
openPartial(id) {
if (!this.requirePickingOperation()) return
2026-07-30 18:04:37 +08:00
this.partialTargetId = id
this.partialInput = ''
this.openOverlay('partial')
this.$nextTick(() => {
this.partialFocused = true
})
},
setPartialInput(value) {
this.partialInput = String(value)
this.partialFocused = false
this.$nextTick(() => {
this.partialFocused = true
})
},
confirmPartial() {
if (!this.requirePickingOperation()) return
2026-07-30 18:04:37 +08:00
const item = this.partialTargetItem
if (!item) return
if (!this.partialInputValid) return
const value = parseInt(this.partialInput, 10)
2026-08-10 11:53:43 +08:00
const available = this.availablePickQuantity(item)
if (available !== null && value > available) { this.showToast(`超过库存/库位可用量:可拣 ${available}`, 'error'); return }
2026-07-30 18:04:37 +08:00
this.itemStates = { ...this.itemStates, [item.id]: ITEM_STATUS.PARTIAL }
this.partialQuantities = { ...this.partialQuantities, [item.id]: value }
this.closeOverlay(false)
this.showToast(`已部分拣货:${value}/${item.qty} ${item.unit}`, 'success')
this.persistSession()
2026-07-30 18:04:37 +08:00
},
2026-08-10 11:53:43 +08:00
availablePickQuantity(item) {
if (!item) return null
if (item.locationAvailable === false || item.locationStatus === 'DISABLED') return 0
const raw = item.locationAvailablePackageCount ?? item.availablePackageCount ?? item.availableQty ?? item.stockAvailableQty
return raw === null || raw === undefined || raw === '' ? null : Math.max(0, Number(raw))
},
2026-07-30 18:04:37 +08:00
openBoxList(type) {
this.boxListType = type
this.boxDropdownOpen = false
this.openOverlay('boxList')
},
boxListDetail(entry) {
if (this.boxListType !== 'done') {
const draft = this.boxDrafts[entry.index]
const processed = draft
? Object.values(draft.itemStates).filter((status) => status !== ITEM_STATUS.PENDING).length
: 0
2026-08-31 17:10:46 +08:00
return `已处理${processed}件 · 待拣${entry.box.items.length - processed}件`
2026-07-30 18:04:37 +08:00
}
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
2026-08-31 17:10:46 +08:00
return `拣出${picked}件${partial ? ` · 部分拣${partial}件` : ''}`
2026-07-30 18:04:37 +08:00
},
selectBoxFromList(entry) {
if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return
this.closeOverlay(false)
this.requestBoxSwitch(entry.index)
},
2026-08-17 16:17:25 +08:00
async submitPicking() {
if (this.pickingCompleted) {
await this.syncRemoteTask()
if (this.loadingStageReady) this.activeStage = 'evidence'
else this.showToast('装柜状态尚未同步,请刷新重试', 'warning')
return
}
if (!this.requirePickingOperation()) return
2026-08-17 16:17:25 +08:00
if (!this.summary.canSubmit) return
2026-07-30 18:04:37 +08:00
if (this.summary.pending > 0) {
this.selectedIncompleteReason = ''
this.openOverlay('submitConfirm')
return
}
this.performSubmit()
},
confirmIncompleteSubmit() {
if (!this.selectedIncompleteReason) return
this.performSubmit(this.selectedIncompleteReason)
},
2026-08-06 13:56:01 +08:00
async performSubmit(incompleteReason = '') {
2026-07-30 18:04:37 +08:00
const beforeSubmit = this.summary
2026-08-11 11:31:05 +08:00
const remoteRequired = Boolean(session.token)
2026-08-15 09:47:28 +08:00
this.remoteError = ''
this.debugSubmit('submit.start', { incompleteReason, remoteRequired, remoteReady: isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo) })
if (remoteRequired && !isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) {
2026-08-11 11:31:05 +08:00
this.remoteError = '缺少服务端任务信息,未提交拣货完成'
2026-08-06 13:56:01 +08:00
this.showToast('拣货完成未提交', 'error')
return
}
if (remoteRequired) {
2026-08-06 13:56:01 +08:00
this.submitting = true
try {
if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试')
if (this.pickingVersion === null) throw new Error(VERSION_SYNC_MESSAGE)
const actions = this.currentBox.items.map((item) => {
2026-08-17 15:41:50 +08:00
if (item.itemStatus && !['PENDING', 'PICKING'].includes(item.itemStatus)) return null
2026-08-06 13:56:01 +08:00
const status = this.itemState(item)
const itemId = item.taskItemId ?? item.remoteItemId ?? item.id
2026-08-11 11:31:05 +08:00
if (status === ITEM_STATUS.PICKED) return { type: 'PICK', itemId, pickedPackageCount: Number(item.qty || 0), locationIds: item.locationIds || [], version: Number(item.version || 0) }
if (status === ITEM_STATUS.PARTIAL) return { type: 'PARTIAL', itemId, finalPickedPackageCount: Number(this.partialQuantities[item.id] || 0), locationIds: item.locationIds || [], version: Number(item.version || 0) }
if (status === ITEM_STATUS.SKIPPED) return { type: 'SKIP', itemId, reason: item.skipReason || incompleteReason || '现场跳拣', version: Number(item.version || 0) }
if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)`, version: Number(item.version || 0) }
return null
}).filter(Boolean)
2026-08-17 16:17:25 +08:00
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
2026-08-17 16:17:25 +08:00
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('库存快照未同步,请刷新任务后重试')
2026-08-15 09:47:28 +08:00
this.debugSubmit('submit.batch', { localItemIds: this.currentBox.items.map((item) => item.id), actionItemIds: actions.map((action) => action.itemId), actionCount: actions.length })
const submittedTask = await submitContainerPickingBatch({ taskId: this.remoteTaskId, loadingPlanId: this.currentBox.loadingPlanId, containerBookingId: this.currentBox.containerBookingId, version: Number(this.pickingVersion) }, actions, { warehouseId, operationId: submitId, inventorySnapshotToken: inventorySnapshot?.snapshotToken })
2026-08-15 09:47:28 +08:00
this.debugSubmit('submit.success', { itemCount: actions.length, workflowStatus: submittedTask?.workflowStatus || null, taskStatus: submittedTask?.taskStatus || null })
2026-08-15 12:12:40 +08:00
if (submittedTask) {
this.remotePick = normalizePickingTask({ ...this.remotePick, ...submittedTask })
2026-08-17 15:41:50 +08:00
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
2026-08-15 12:12:40 +08:00
this.evidenceIdentity = this.captureEvidenceIdentity()
}
2026-08-31 13:43:18 +08:00
this.showToast('拣货已提交,正在刷新最新状态', 'success')
await this.syncRemoteTask()
if (!this.pickingCompleted) {
this.remoteError = this.remoteError || '拣货已提交,但最新任务状态尚未同步,请点击刷新任务'
this.showToast('拣货已提交,状态同步未完成,请刷新任务', 'warning')
return
}
2026-08-06 13:56:01 +08:00
} catch (error) {
2026-08-15 09:47:28 +08:00
this.debugSubmit('submit.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '', network: Boolean(error.network) })
2026-08-17 15:41:50 +08:00
const versionConflict = this.isTaskConflict(error)
const inventoryConflict = versionConflict && /库存/.test(error.message || '')
const conflictMessage = inventoryConflict ? '库存已变化,请刷新后重试' : '任务版本已变化,请刷新后重试'
2026-08-17 15:41:50 +08:00
if (versionConflict) {
await this.refreshAfterTaskConflict(conflictMessage)
return
}
2026-08-30 17:36:57 +08:00
this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : this.remoteFailureMessage(error, '拣货完成失败')
2026-08-17 15:41:50 +08:00
this.showToast('拣货完成未提交,请查看错误提示', 'error')
2026-08-06 13:56:01 +08:00
return
} finally { this.submitting = false }
}
2026-07-30 18:04:37 +08:00
const result = submitStates(this.currentBox.items, this.itemStates)
this.itemStates = result.states
this.currentBox.items.forEach((item) => {
if (result.autoSkippedIds.includes(item.id)) {
item.skipReason = `${incompleteReason}(提交时确认未拣)`
}
item.finalState = this.itemState(item)
item.finalPartialQuantity = this.partialQuantities[item.id] || 0
})
this.completedBoxes = { ...this.completedBoxes, [this.currentBoxIndex]: true }
2026-08-15 09:57:53 +08:00
if (this.loadingStageReady) this.loadLoadingEvidence()
2026-07-30 18:04:37 +08:00
this.saveCurrentBoxDraft()
if (remoteRequired) clearPickingDraft(this.draftIdentity())
this.submitId = ''
2026-08-11 20:39:38 +08:00
const afterSubmit = getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities)
2026-07-30 18:04:37 +08:00
this.completionSummary = {
picked: afterSubmit.picked,
partial: afterSubmit.partial,
skipped: afterSubmit.skipped,
autoSkipped: beforeSubmit.pending
}
this.openOverlay('complete')
},
2026-08-27 14:30:26 +08:00
continueToEvidence() {
2026-07-30 18:04:37 +08:00
this.closeOverlay(false)
2026-08-27 14:30:26 +08:00
this.activeStage = 'evidence'
this.scrollToTop()
2026-07-30 18:04:37 +08:00
},
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>