394 lines
57 KiB
Vue
394 lines
57 KiB
Vue
<template>
|
||
<view v-if="user" class="page-shell portal-page work-page">
|
||
<PortalHeader :title="title" :user="user" />
|
||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||
<view v-if="showCurrent" class="detail-toolbar"><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
|
||
|
||
<template v-if="!showCurrent">
|
||
<view class="row-head candidate-toolbar"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap">{{ loading ? '刷新中…' : '刷新' }}</button></view>
|
||
<view v-if="loading && !visibleCandidates.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载换标任务…</text></view>
|
||
<view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view>
|
||
<view v-else-if="stage !== 'review'" class="task-list">
|
||
<view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card relabel-candidate" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)">
|
||
<view class="row-head"><text class="business-code">{{ item.relabelNo }}</text><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view>
|
||
<view class="candidate-kind"><text>{{ relabelTypeName(item.operationType) }}</text><text v-if="item.reviewDecision === 'REWORK'" class="rework-tag">退回重做</text></view>
|
||
<view class="candidate-footer"><text class="candidate-progress">已换标 <text class="candidate-progress-value">{{ item.relabelledPackages }} / {{ item.expectedPackages }}</text></text><text v-if="item.skippedPackages + item.exceptionPackages > 0" class="candidate-warning">跳过/异常 {{ item.skippedPackages + item.exceptionPackages }}</text><text class="candidate-enter">进入 ›</text></view>
|
||
</view>
|
||
</view>
|
||
<view v-else class="task-list">
|
||
<view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card relabel-candidate" @tap="openReview(item)">
|
||
<view class="row-head"><text class="business-code">{{ item.relabelNo }}</text><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view>
|
||
<view class="candidate-kind"><text>{{ relabelTypeName(item.operationType) }}</text></view>
|
||
<view class="candidate-footer"><text class="candidate-progress">已换标 <text class="candidate-progress-value">{{ item.relabelledPackages }} / {{ item.expectedPackages }}</text></text><text v-if="item.skippedPackages + item.exceptionPackages > 0" class="candidate-warning">跳过/异常 {{ item.skippedPackages + item.exceptionPackages }}</text><text class="candidate-enter">查看并复核 ›</text></view>
|
||
</view>
|
||
</view>
|
||
|
||
</template>
|
||
<view v-if="showCurrent" class="work-card">
|
||
<text class="status-pill">换标类型:{{ relabelTypeName(current.operationType) }}</text>
|
||
<view class="row-head"><view><text class="business-code">{{ current.relabelNo }}</text><text class="meta-line">所属公司:{{ current.customerName || candidates.find(item => item.relabelNo === current.relabelNo)?.customerName || '未提供' }}</text><text class="meta-line">客户订单:{{ current.customerOrderNo || '—' }}</text><text class="meta-line">标签截止:{{ formatTime(current.labelDeadline) }}</text></view><text class="status-pill">{{ statusLabel(current.taskStatus || current.applicationStatus) }}</text></view>
|
||
<view class="destination-summary"><text class="meta-line">FBA 目的仓:</text><text v-for="destination in fbaDestinations" :key="destination" class="destination-tag">{{ destination }}</text><text v-if="!fbaDestinations.length" class="meta-line">待维护</text><view class="summary-metrics"><text class="summary-metric">应处理 {{ current.expectedPackages }}</text><text class="summary-metric">已换标 {{ current.relabelledPackages }}</text><text class="summary-metric">跳过 {{ current.skippedPackages }}</text><text class="summary-metric">异常 {{ current.exceptionPackages }}</text></view></view>
|
||
<view class="section-gap"><view class="row-head"><text class="card-title">标签文件</text><button class="link-button" :disabled="busy" @tap="loadDocuments">刷新文件</button></view><text v-if="!documents.length" class="meta-line">暂无标签文件</text><view v-for="file in documents" :key="`${file.scope}:${file.fileId}`" class="row-head"><text class="meta-line">{{ file.scope === 'CARTON_TOTAL' ? '总箱唛' : file.scope === 'SKU_TOTAL' ? '总 SKU 标签' : '明细标签' }}:{{ file.fileName }}</text><button class="secondary" :disabled="busy" @tap="printDocument(file)">查看 / 打印</button></view></view>
|
||
<template v-if="stage === 'execute'">
|
||
<view v-if="current.reviewDecision === 'REWORK' && current.reviewRemark" class="error-box section-gap"><text class="error-title">退回重新贴标</text><text>{{ current.reviewRemark }}</text></view>
|
||
<view v-if="current.taskStatus !== 'PROCESSING'" class="empty-box section-gap"><text class="empty-copy">当前任务不是处理中状态,请返回换标列表选择可执行任务,或等待复核退回。</text></view>
|
||
<template v-else>
|
||
<view v-if="availableTabs.length > 1" class="tab-row" role="tablist"><button v-for="tab in availableTabs" :key="tab.key" class="tab-button" role="tab" :aria-selected="taskTab === tab.key" :class="{ active: taskTab === tab.key }" :disabled="busy" @tap="changeTab(tab.key)">{{ tab.name }}</button></view>
|
||
<text class="card-title section-gap">{{ isReturnBulk ? '退货贴标 SKU 明细' : taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}</text>
|
||
<view v-if="isReturnBulk" class="section-gap"><text class="hint">先扫描来源包裹/箱,再核对商品 SKU、目标标签和实际数量。</text><ScanTargetInput name="returnCarton" v-model="returnCartonCode" label="来源包裹/箱码" placeholder="扫描真实来源包裹/箱" :active="activeTarget === 'return-carton'" @activate="activeTarget = 'return-carton'" @submit="verifyReturnCarton" /><button class="secondary" :disabled="busy" @tap="verifyReturnCarton(returnCartonCode)">确认来源包裹/箱</button><text v-if="verifiedReturnCarton" class="success-title">已确认:{{ verifiedReturnCarton }}</text></view>
|
||
<view v-if="isReturnBulk" class="section-gap"><ScanTargetInput name="returnSku" v-model="returnSkuCode" label="商品 SKU" placeholder="扫描本来源待换标 SKU" :active="activeTarget === 'return-sku'" @activate="activeTarget = 'return-sku'" @submit="verifyReturnSku" /><button class="secondary" @tap="verifyReturnSku(returnSkuCode)">核对商品 SKU</button><text v-for="group in returnBulkSkuGroups" :key="group.key" class="meta-line">{{ group.skuCode }} · 汇总待贴 {{ group.remaining }} 件(不自动分配)</text></view>
|
||
<template v-if="isReturnBulk"><view v-for="item in returnBulkSources" :key="item.itemId" class="item-card section-gap"><text class="business-code">{{ item.cartonMark }} · {{ item.skuCode }}</text><text class="meta-line">目标商品标签:{{ item.newSkuCode || '未提供,禁止登记' }} · 本来源剩余 {{ remainingReturnQuantity(item) }} 件</text><ScanTargetInput :name="'target-' + item.itemId" v-model="returnTargetLabels[item.itemId]" label="目标商品 SKU 标签" placeholder="扫描目标商品标签" :active="activeTarget === 'target-' + item.itemId" @activate="activeTarget = 'target-' + item.itemId" /><input v-model="bulkQuantities[item.itemId]" type="number" placeholder="必填:本来源本次实际贴标件数" /><button class="secondary" :disabled="busy" @tap="fillAllReturn(item)">本来源全部(需确认)</button><button class="primary" :disabled="busy" @tap="completeBulkSku(item)">登记本来源实际数量</button></view></template>
|
||
<view v-if="taskTab === 'carton' && !isReturnBulk" class="section-gap"><ScanTargetInput name="quickCarton" v-model="quickCarton" label="仓库唛头" placeholder="扫描箱唛自动确认,手输后按完成" :active="activeTarget === 'quick-carton'" @activate="activeTarget = 'quick-carton'" @submit="submitCarton" /></view>
|
||
<view v-if="taskTab === 'sku'" class="section-gap">
|
||
<ScanTargetInput name="quickSku" v-model="quickSku" label="旧 SKU 标签" placeholder="扫描旧 SKU 自动确认" :active="activeTarget === 'quick-sku'" @activate="activeTarget = 'quick-sku'" @submit="submitSku" />
|
||
<text v-if="skuChoices.length" class="hint">同 SKU 对应多个明细,请选择本次换标的来源箱:</text>
|
||
<button v-for="row in skuChoices" :key="`${row.item.itemId}:${row.index}`" :disabled="busy" @tap="commitSku(row)">{{ row.item.cartonMark }} · {{ row.item.sourcePalletCode || '未提供托盘信息' }}:{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }} · {{ row.sku.quantity }} 件</button>
|
||
<view v-for="row in pendingSkuRows" :key="`${row.item.itemId}:${row.index}`" class="item-card sku-detail-card"><text class="business-code">{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }}</text><text class="meta-line">来源箱号:{{ row.item.cartonMark || '未提供' }} · {{ row.sku.quantity }} 件</text><text class="meta-line">{{ row.item.sourcePalletBasis === 'RETURN_CARTON' ? '所在托盘' : '参考来源托盘' }}:{{ row.item.sourcePalletCode === 'UNBOUND' ? '未绑定托盘' : row.item.sourcePalletCode || '未提供托盘信息' }}</text><text v-if="row.item.sourcePalletBasis === 'INVENTORY_REFERENCE' && row.item.sourcePalletCode && row.item.sourcePalletCode !== 'UNBOUND'" class="meta-line">参考托盘来自库存预留,实际箱子位置请现场核对。</text><button class="link-button" @tap="selectItem(row.item); changeAction('EXCEPTION')">异常 / 跳过</button></view>
|
||
</view>
|
||
<view v-if="taskTab === 'carton'" class="item-list carton-label-list"><view v-for="item in visibleItems" :key="item.itemId" class="carton-label-item" :class="{ done: remaining(item) === 0, current: selectedItem?.itemId === item.itemId }" @tap="selectItem(item)">
|
||
<view class="carton-label"><text class="label-caption">仓库箱唛</text><text class="label-code">{{ item.cartonMark || '—' }}</text></view><text class="label-arrow">→</text><view class="carton-label fba-label"><text class="label-caption">FBA 箱唛</text><text class="label-code">{{ item.fbaCartonMark || '—' }}</text></view>
|
||
<text v-if="fbaDestinations.length > 1" class="destination-tag">{{ item.destinationFbaWarehouseCode || '待维护' }}</text>
|
||
</view></view>
|
||
<template v-if="taskTab === 'sku' && selectedItem && action !== 'RELABEL' && remaining(selectedItem) > 0">
|
||
<view class="mode-row section-gap"><button v-for="mode in actionModes" :key="mode.key" class="mode-button" :class="{ active: action === mode.key }" @tap="changeAction(mode.key)">{{ mode.name }}</button></view>
|
||
<ScanTargetInput name="oldLabel" v-model="oldLabel" label="仓库唛头" placeholder="扫描或输入仓库唛头" :active="activeTarget === 'old'" @activate="activeTarget = 'old'" @submit="handleOldLabel" />
|
||
<template v-if="action !== 'RELABEL'"><text class="field-label section-gap">跳过或异常原因(必填)</text><textarea v-model="reason" class="reason-input" maxlength="1000" aria-label="跳过或异常原因(必填)" /><view v-if="action === 'EXCEPTION'" class="mode-row section-gap"><button v-for="item in exceptionCodes" :key="item.key" class="mode-button" :class="{ active: exceptionCode === item.key }" @tap="exceptionCode = item.key">{{ item.name }}</button></view></template>
|
||
<button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button>
|
||
</template>
|
||
<view class="section-gap">
|
||
<ReturnRelabelPackWork v-if="showReturnPack" :key="current.relabelNo + ':' + (current.reviewedAt || 'INITIAL')" :task="current" :locked="busy" @saved="applyCurrent" @refresh="reloadCurrent" @error="setError" @busy="busy = $event" />
|
||
<RelabelPalletWork v-if="requiresPalletBinding && (taskTab === 'carton' || isReturnTask)" ref="palletWork" :key="current.relabelNo" :task="palletTask" :allow-disassemble="true" @activate="activeTarget = 'pallet-work'" @busy="busy = $event" @saved="reloadCurrent" />
|
||
<button class="primary" :disabled="busy || Boolean(completionBlockReason)" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : allProcessed ? '完成现场换标并等待复核' : '手动完成并登记异常' }}</button>
|
||
<view class="row-head"><text class="card-title">完成照片</text><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view>
|
||
<button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto">拍照或从相册选择</button>
|
||
<view class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile">
|
||
<image v-if="photoUrls[fileId]" class="photo-thumbnail" :src="photoUrls[fileId]" mode="aspectFill" @tap="previewPhoto(fileId)" />
|
||
<button v-else class="photo-thumbnail photo-placeholder" @tap="loadPhoto(fileId)">{{ photoErrors[fileId] ? '加载失败,点击重试' : '照片加载中' }}</button>
|
||
<text class="photo-name">{{ photoNames[index] || '换标现场照片' }}</text>
|
||
<view class="photo-actions"><button class="link-button" :disabled="busy" @tap="retakePhoto(index)">重拍</button><button class="link-button" :disabled="busy" @tap="removePhoto(index)">移除</button></view>
|
||
</view></view>
|
||
<text class="field-label section-gap">{{ allProcessed ? '完成备注(选填)' : '异常作业备注(必填)' }}</text><textarea v-model="remark" class="reason-input" maxlength="900" :aria-label="allProcessed ? '完成备注(选填)' : '异常作业备注(必填)'" />
|
||
<view v-if="completionBlockReason" class="error-box section-gap"><text class="error-title">还不能完成换标</text><text>{{ completionBlockReason }}</text></view>
|
||
</view>
|
||
</template>
|
||
</template>
|
||
<template v-else-if="stage === 'review'">
|
||
<view class="section-gap"><text class="card-title">待客户确认</text><text class="meta-line">现场换标已完成,等待客户确认。</text></view>
|
||
</template>
|
||
|
||
</view>
|
||
|
||
<view v-if="successMessage" class="success-box" role="status"><text class="success-title">{{ successMessage }}</text></view>
|
||
</view></scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import PortalHeader from '@/components/PortalHeader.vue'
|
||
import ReturnRelabelPackWork from '@/components/ReturnRelabelPackWork.vue'
|
||
import { requiresReturnPack, returnPackFacts } from '@/utils/return-relabel.js'
|
||
import RelabelPalletWork from '@/components/RelabelPalletWork.vue'
|
||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||
import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js'
|
||
import { session, requireSession } from '@/stores/session.js'
|
||
import { restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||
import { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSku, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
|
||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||
import { isRelabelFundsBlocked, relabelErrorMessage } from '@/utils/relabel-error.js'
|
||
|
||
const STAGES = { relabelReceive: 'receive', relabelExecute: 'execute', relabelReview: 'review' }
|
||
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
|
||
export default {
|
||
components: { PortalHeader, ScanTargetInput, RelabelPalletWork, ReturnRelabelPackWork },
|
||
data() { return { user: session.currentUser, stage: 'receive', entryTaskNo: '', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, returnCartonCode: '', verifiedReturnCarton: '', returnTargetLabels: {}, quickTargetLabel: '', quickActualQuantity: '', returnSkuCode: '', verifiedReturnSkuKey: '', bulkQuantities: {}, activeTarget: 'task', loading: false, busy: false, busyAction: '', error: null, successMessage: '', exceptionCode: 'OTHER', actionModes: [{ key: 'RELABEL', name: '正常换标' }, { key: 'SKIP', name: '跳过' }, { key: 'EXCEPTION', name: '异常' }], exceptionCodes: [{ key: 'DAMAGED', name: '破损' }, { key: 'MISSING_LABEL', name: '缺标签' }, { key: 'LABEL_MISMATCH', name: '标签不符' }, { key: 'UNREADABLE', name: '无法识别' }, { key: 'OTHER', name: '其他' }] } },
|
||
computed: {
|
||
palletTask() {
|
||
if (!this.isReturnTask) return this.current
|
||
const boxes = new Map()
|
||
for (const item of this.current.items || []) {
|
||
const previous = boxes.get(item.cartonMark)
|
||
const done = Number(item.relabelledPackages) === Number(item.expectedPackages)
|
||
boxes.set(item.cartonMark, { ...item, operationType: 'CARTON_LABEL', expectedPackages: 1, relabelledPackages: done && (!previous || previous.relabelledPackages === 1) ? 1 : 0 })
|
||
}
|
||
return { ...this.current, operationType: 'CARTON_LABEL', items: [...boxes.values()] }
|
||
},
|
||
isReturnTask() { return (this.current?.items || []).some(item => item.returnCartonId) },
|
||
showReturnPack() { return this.isReturnTask && (requiresReturnPack(this.current) || returnPackFacts(this.current).length > 0) },
|
||
requiresPalletBinding() { return this.isReturnTask ? this.current?.requiresPalletBinding === true || this.current?.deliveryMethod === 'TRUCK' : true },
|
||
returnBulkSources() { return (this.current?.items || []).filter(item => item.returnCartonId && item.operationType === 'RETURN_BULK_SKU_LABEL' && item.cartonMark === this.verifiedReturnCarton && item.skuCode.toUpperCase() === this.returnSkuCode.trim().toUpperCase() && this.remainingReturnQuantity(item) > 0) },
|
||
fbaDestinations() { return [...new Set((this.current?.items || []).map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
|
||
isReturnBulk() { return this.current?.operationType === 'RETURN_BULK_SKU_LABEL' },
|
||
returnBulkSkuGroups() {
|
||
const groups = new Map()
|
||
for (const item of (this.current?.items || []).filter(row => row.operationType === 'RETURN_BULK_SKU_LABEL')) {
|
||
const skuCode = String(item.skuCode || '').trim()
|
||
const destination = String(item.destinationFbaWarehouseCode || '').trim()
|
||
const key = `${skuCode.toUpperCase()}::${destination.toUpperCase()}`
|
||
if (!groups.has(key)) groups.set(key, { key, skuCode, destinationFbaWarehouseCode: destination, expectedPackages: 0, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0, sources: [] })
|
||
const group = groups.get(key)
|
||
group.expectedPackages += Number(item.expectedPackages || 0)
|
||
group.relabelledPackages += Number(item.relabelledPackages || 0)
|
||
group.skippedPackages += Number(item.skippedPackages || 0)
|
||
group.exceptionPackages += Number(item.exceptionPackages || 0)
|
||
group.sources.push({ itemId: item.itemId, cartonMark: item.cartonMark, expectedPackages: Number(item.expectedPackages || 0) })
|
||
}
|
||
return [...groups.values()].map(group => ({ ...group, remaining: Math.max(0, group.expectedPackages - group.relabelledPackages - group.skippedPackages - group.exceptionPackages) }))
|
||
},
|
||
showCurrent() { if (!this.current) return false; if (this.stage === 'review') return this.current.taskStatus === 'WAIT_REVIEW'; const owned = this.candidates.some((item) => item.relabelNo === this.current.relabelNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); if (this.stage === 'execute') return this.current.taskStatus === 'PROCESSING' && owned; return !this.current.taskStatus || this.current.taskStatus === 'AVAILABLE' || (this.current.taskStatus === 'PROCESSING' && owned) },
|
||
availableTabs() { const items = this.current?.items || []; return [{ key: 'sku', name: 'SKU 换标', types: ['SKU_LABEL', 'CARTON_AND_SKU'] }, { key: 'carton', name: '箱唛换标', types: ['CARTON_LABEL', 'CARTON_AND_SKU'] }].filter((tab) => items.some((item) => tab.types.includes(item.operationType))) },
|
||
unitDraft() { return this.unitDrafts?.[this.selectedItem?.itemId] || { cartonMark: '', fbaCartonMark: '', skuVerifications: [] } },
|
||
skuVerified() { if (!this.selectedItem || this.selectedItem.operationType === 'CARTON_LABEL') return true; const expected = this.skuDetails(this.selectedItem); return expected.length > 0 && expected.every((sku, index) => { const actual = this.unitDraft.skuVerifications[index]; return actual && actual.skuCode.trim() === sku.skuCode && actual.newSkuCode.trim() === sku.newSkuCode && Number(actual.quantity) > 0 && Number(actual.quantity) === Number(sku.quantity) }) },
|
||
cartonVerified() { return Boolean(this.selectedItem && this.oldLabel.trim() === this.selectedItem.cartonMark && (this.taskTab === 'sku' || this.newLabel.trim() === this.selectedItem.fbaCartonMark && this.newLabel.trim())) },
|
||
title() { return ({ receive: '换标列表', execute: '换标执行', review: '换标复核' })[this.stage] },
|
||
candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '换标任务' },
|
||
emptyText() { return `暂无${this.candidateHeading}` },
|
||
visibleCandidates() { const candidates = this.candidates; if (this.stage === 'review') return candidates.filter((item) => item.taskStatus === 'WAIT_REVIEW'); if (this.stage === 'execute') return candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator); return candidates.filter((item) => !item.taskStatus || item.taskStatus === 'AVAILABLE' || (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator)).sort((left, right) => Number(right.reviewDecision === 'REWORK') - Number(left.reviewDecision === 'REWORK') || String(right.reviewedAt || '').localeCompare(String(left.reviewedAt || ''))) },
|
||
visibleItems() { return (this.current?.items || []).filter((item) => (this.taskTab === 'sku' ? ['SKU_LABEL', 'CARTON_AND_SKU'].includes(item.operationType) : ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType)) && this.remaining(item) > 0) },
|
||
pendingSkuRows() { return this.visibleItems.flatMap(item => this.skuDetails(item).map((sku, index) => ({ item, sku, index })).filter(row => !this.skuDone(row.item.itemId, row.index))) },
|
||
requiresNewCartonLabel() { return this.action === 'RELABEL' && this.selectedItem && this.selectedItem.operationType !== 'SKU_LABEL' },
|
||
taskCode: { get() { return warehouseWork.relabel.currentNo }, set(value) { updateWarehouseWork('relabel', { currentNo: value }) } },
|
||
oldLabel: { get() { return this.unitDraft.cartonMark }, set(value) { if (this.selectedItem) this.unitDraft.cartonMark = value } },
|
||
newLabel: { get() { return this.unitDraft.fbaCartonMark }, set(value) { if (this.selectedItem) this.unitDraft.fbaCartonMark = value } },
|
||
action: { get() { return warehouseWork.relabel.action }, set(value) { updateWarehouseWork('relabel', { action: value }) } },
|
||
reason: { get() { return warehouseWork.relabel.reason }, set(value) { updateWarehouseWork('relabel', { reason: value }) } },
|
||
remark: { get() { return warehouseWork.relabel.remark }, set(value) { updateWarehouseWork('relabel', { remark: value }) } },
|
||
photoFileIds() { return warehouseWork.relabel.photoFileIds || [] }, photoNames() { return warehouseWork.relabel.photoNames || [] },
|
||
allProcessed() { return Boolean(this.current?.expectedPackages) && this.current.relabelledPackages + this.current.skippedPackages + this.current.exceptionPackages === this.current.expectedPackages },
|
||
completionBlockReason() {
|
||
if (!this.current) return '请先选择换标任务'
|
||
if (!this.allProcessed && !this.remark.trim()) return '仍有未扫描明细,请继续处理;需要异常完工时必须填写异常作业备注'
|
||
if (this.isReturnTask && requiresReturnPack(this.current) && !returnPackFacts(this.current).length) return '部分换标须先完成全部来源登记并生成独立新箱'
|
||
const requiresPalletBinding = this.requiresPalletBinding && (this.current.items || []).some((item) => item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
|
||
if (requiresPalletBinding && Number(this.current.relabelledPackages || 0) > 0) {
|
||
const round = this.current.reviewedAt || 'INITIAL'
|
||
const marks = new Set((this.current.history || []).filter((entry) => entry.action === 'PALLET_BIND').flatMap((entry) => {
|
||
try { const detail = JSON.parse(entry.detail); return detail.round === round ? detail.cartonMarks || [] : [] } catch { return [] }
|
||
}))
|
||
const requiredMarks = new Set((this.current.items || []).filter((item) =>
|
||
(item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
|
||
&& Number(item.relabelledPackages || 0) > 0).map((item) => item.cartonMark).filter(Boolean))
|
||
if ([...requiredMarks].some((mark) => !marks.has(mark))) return '请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定'
|
||
}
|
||
if (!this.photoFileIds.length) return '请至少上传 1 张换标完成照片'
|
||
return ''
|
||
},
|
||
canSubmitUnit() { if (!this.selectedItem || !this.visibleItems.some((item) => item.itemId === this.selectedItem.itemId) || !this.oldLabel.trim()) return false; if (this.action === 'RELABEL') return this.cartonVerified && this.skuVerified; return Boolean(this.reason.trim()) },
|
||
unitActionText() { return ({ RELABEL: '确认本箱换标完成', SKIP: '确认跳过本箱', EXCEPTION: '登记本箱异常' })[this.action] }
|
||
},
|
||
onLoad(options) { this.stage = STAGES[options.op] || 'receive'; this.entryTaskNo = String(options.task || '').trim() },
|
||
mixins: [{ data() { return { documents: [], quickCarton: '', lastCarton: null, quickSku: '', lastSku: '', skuChoices: [], photoUrls: {}, photoErrors: {} } } }],
|
||
watch: {
|
||
'current.reviewedAt'(value) { if (this.current?.reviewDecision === 'REWORK' && warehouseWork.relabel.reworkRound !== `${this.current.relabelNo}:${value}`) { this.unitDrafts = {}; this.selectedItem = null; this.lastCarton = null; this.quickCarton = ''; this.verifiedReturnCarton = ''; this.returnCartonCode = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; updateWarehouseWork('relabel', { reworkRound: `${this.current.relabelNo}:${value}`, photoFileIds: [], photoNames: [], remark: '' }) } },
|
||
photoFileIds: { deep: true, handler(ids) { for (const id of ids) if (!this.photoUrls[id]) this.loadPhoto(id) } },
|
||
'current.relabelNo'() { this.photoUrls = {}; this.photoErrors = {}; for (const id of this.photoFileIds) this.loadPhoto(id) }
|
||
},
|
||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); if (this.stage === 'execute') this.startScanner(); this.bootstrap() },
|
||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||
methods: {
|
||
skuDone(itemId, index) { return (this.current?.history || []).some(entry => { if (entry.action !== 'SKU_SCAN') return false; try { const data = JSON.parse(entry.detail); return String(data.itemId) === String(itemId) && data.skuIndex === index && data.round === (this.current.reviewedAt || 'INITIAL') } catch { return false } }) },
|
||
async submitSku(raw) {
|
||
const oldSku = String(raw?.detail?.value ?? raw ?? this.quickSku).trim()
|
||
if (!oldSku || this.busy || !this.current) return
|
||
this.quickSku = oldSku
|
||
const matches = this.pendingSkuRows.filter(row => row.sku.skuCode === oldSku)
|
||
this.skuChoices = []
|
||
if (!matches.length) {
|
||
const exists = (this.current.items || []).some(item => this.skuDetails(item).some(sku => sku.skuCode === oldSku))
|
||
return this.setError(new Error(exists ? '该 SKU 已处理完成,不需要重复换标' : '本单没有该 SKU,不需要换标'))
|
||
}
|
||
if (matches.length > 1) { this.skuChoices = matches; return }
|
||
await this.commitSku(matches[0])
|
||
},
|
||
async commitSku(row) {
|
||
if (this.busy || this.quickSku.trim() !== row.sku.skuCode) return
|
||
if (row.item.returnCartonId && (!row.item.cartonMark || !row.sku.newSkuCode || !Number.isSafeInteger(Number(row.sku.quantity)) || Number(row.sku.quantity) <= 0)) return this.setError(new Error('来源箱号、目标 SKU 或申请件数缺失,请先维护换标明细'))
|
||
const payload = { itemId: row.item.itemId, ...(row.item.returnCartonId ? { cartonMark: row.item.cartonMark, newSkuCode: row.sku.newSkuCode, quantity: Number(row.sku.quantity) } : {}), skuIndex: row.index, oldSku: this.quickSku.trim(), expectedVersion: this.current.version }
|
||
const key = warehouseIdempotencyKey('relabel', 'sku-scan', { relabelNo: this.current.relabelNo, ...payload })
|
||
await this.run('sku-scan', async () => {
|
||
const value = await scanRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })
|
||
this.applyCurrent(value); const confirmed = value.items.find(item => item.itemId === row.item.itemId)?.skuItems?.[row.index]
|
||
this.lastSku = confirmed ? `${confirmed.skuCode} 换标 ${confirmed.newSkuCode} · ${confirmed.quantity} 件` : '已保存,请重新读取标签绑定信息'
|
||
this.quickSku = ''; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.skuChoices = []; this.activeTarget = 'quick-sku'
|
||
await new Promise(resolve => uni.showModal({ title: '请粘贴对应 SKU 标签', content: `${row.item.cartonMark}\n${this.lastSku}`, showCancel: false, confirmText: '知道了', complete: resolve }))
|
||
}, true, key)
|
||
},
|
||
relabelTypeName(type) { return ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', CARTON_AND_SKU: '混合换标(SKU + 箱唛)', RETURN_BULK_SKU_LABEL: '退货贴标(按 SKU 数量)' })[type] || '换标类型待确认' },
|
||
remainingReturnQuantity(item) { return Math.max(0, Number(item.expectedPackages || 0) - Number(item.relabelledPackages || 0) - Number(item.skippedPackages || 0) - Number(item.exceptionPackages || 0)) },
|
||
verifyReturnSku(raw) {
|
||
if (this.busy) return
|
||
const code = String(raw?.detail?.value ?? raw ?? this.returnSkuCode).trim()
|
||
this.returnSkuCode = code; this.verifiedReturnSkuKey = ''
|
||
if (!this.verifiedReturnCarton || this.returnCartonCode.trim() !== this.verifiedReturnCarton) return this.setError(new Error('请先确认来源包裹/箱'))
|
||
const matches = this.returnBulkSources
|
||
if (!matches.length) { this.setError(new Error('该 SKU 不属于已扫描来源的待贴标明细,或已全部登记')); return }
|
||
this.error = null; this.verifiedReturnSkuKey = code.toUpperCase(); this.activeTarget = 'return-sku'
|
||
},
|
||
verifyReturnCarton(raw) {
|
||
if (this.busy) return
|
||
const mark = String(raw?.detail?.value ?? raw ?? this.returnCartonCode).trim()
|
||
this.returnCartonCode = mark; this.verifiedReturnCarton = ''; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickSku = ''; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.skuChoices = []
|
||
if (!(this.current?.items || []).some(item => item.returnCartonId && item.cartonMark === mark)) return this.setError(new Error('该包裹/箱不属于本单真实退货来源'))
|
||
this.verifiedReturnCarton = mark; this.activeTarget = this.taskTab === 'carton' ? 'quick-carton' : 'quick-sku'
|
||
},
|
||
async fillAllReturn(item) {
|
||
const no = this.current.relabelNo; const version = this.current.version; const mark = this.verifiedReturnCarton
|
||
if (this.busy || item.cartonMark !== mark || this.returnCartonCode.trim() !== mark) return
|
||
const confirmed = await new Promise(resolve => uni.showModal({ title: '登记本来源全部', content: mark + ' · ' + item.skuCode + ':确认实际已贴标剩余 ' + this.remainingReturnQuantity(item) + ' 件?', success: result => resolve(result.confirm), fail: () => resolve(false) }))
|
||
if (confirmed && this.current.relabelNo === no && this.current.version === version && this.verifiedReturnCarton === mark) this.bulkQuantities[item.itemId] = String(this.remainingReturnQuantity(item))
|
||
},
|
||
async completeBulkSku(item) {
|
||
if (this.busy) return
|
||
if (!this.verifiedReturnCarton || this.returnCartonCode.trim() !== this.verifiedReturnCarton || item.cartonMark !== this.verifiedReturnCarton || this.returnSkuCode.trim().toUpperCase() !== item.skuCode.toUpperCase() || !item.newSkuCode || this.returnTargetLabels[item.itemId]?.trim() !== item.newSkuCode) return this.setError(new Error('请先确认本来源包裹、商品 SKU 和目标商品标签'))
|
||
const entered = String(this.bulkQuantities[item.itemId] ?? '').trim(); const quantity = entered ? Number(entered) : NaN
|
||
const remaining = this.remainingReturnQuantity(item)
|
||
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > remaining) return this.setError(new Error('本来源实际贴标数量必填,须为 1 至 ' + remaining + ' 件'))
|
||
const payload = { itemId: item.itemId, cartonMark: item.cartonMark, skuCode: item.skuCode, quantity, expectedVersion: this.current.version }
|
||
const key = warehouseIdempotencyKey('relabel', 'return-bulk-sku', { relabelNo: this.current.relabelNo, ...payload })
|
||
await this.run('return-bulk-sku', async () => { this.applyCurrent(await completeReturnRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })); delete this.bulkQuantities[item.itemId]; this.successMessage = item.cartonMark + ' · ' + item.skuCode + ' 已登记 ' + quantity + ' 件' }, true, key)
|
||
},
|
||
async loadPhoto(fileId) {
|
||
const no = this.current?.relabelNo
|
||
if (!no || this.photoUrls[fileId] || !this.photoFileIds.includes(fileId)) return
|
||
this.photoErrors[fileId] = false
|
||
try { const path = await downloadRelabelPhoto(no, fileId); if (this.current?.relabelNo === no && this.photoFileIds.includes(fileId)) this.photoUrls[fileId] = path }
|
||
catch (_) { if (this.current?.relabelNo === no) this.photoErrors[fileId] = true }
|
||
},
|
||
previewPhoto(fileId) { const urls = this.photoFileIds.map((id) => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
|
||
retakePhoto(index) {
|
||
if (this.busy || !this.current) return
|
||
const no = this.current.relabelNo; const oldId = this.photoFileIds[index]
|
||
uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => {
|
||
if (this.current?.relabelNo !== no || !this.photoFileIds.includes(oldId) || !tempFilePaths?.[0]) return
|
||
this.run('photo', async () => {
|
||
if (Number(tempFiles?.[0]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB')
|
||
const file = await uploadRelabelPhoto(no, tempFilePaths[0])
|
||
const ids = [...this.photoFileIds]; const names = [...this.photoNames]; const position = ids.indexOf(oldId)
|
||
if (position < 0 || this.current?.relabelNo !== no) return
|
||
ids[position] = String(file.fileId); names[position] = file.fileName || '换标现场照片'
|
||
this.photoUrls[String(file.fileId)] = tempFilePaths[0]
|
||
updateWarehouseWork('relabel', { photoFileIds: ids, photoNames: names })
|
||
this.successMessage = '照片已重新拍摄并替换'
|
||
}, false)
|
||
} })
|
||
},
|
||
async loadDocuments() { if (!this.current) return; const no = this.current.relabelNo; try { const files = await listRelabelDocuments(no); if (this.current?.relabelNo === no) this.documents = files || [] } catch (error) { this.setError(error, '标签文件加载失败') } },
|
||
async printDocument(file) { await this.run('document', async () => { const path = await downloadRelabelDocument(this.current.relabelNo, file);
|
||
// #ifdef H5
|
||
const frame = document.createElement('iframe'); frame.style.cssText = 'position:fixed;width:1px;height:1px;border:0'; frame.src = path; frame.onload = () => { try { frame.contentWindow.focus(); frame.contentWindow.print() } catch (_) { window.open(path, '_blank', 'noopener') } }; document.body.appendChild(frame); setTimeout(() => frame.remove(), 120000);
|
||
// #endif
|
||
// #ifndef H5
|
||
await new Promise((resolve, reject) => uni.openDocument({ filePath: path, fileType: 'pdf', showMenu: true, success: resolve, fail: reject }));
|
||
// #endif
|
||
}, false) },
|
||
async submitCarton(raw) {
|
||
const mark = String(raw?.detail?.value ?? raw ?? this.quickCarton).trim()
|
||
if (!mark || this.busy || !this.current) return
|
||
const item = this.current.items.find((row) => row.cartonMark === mark && row.operationType !== 'SKU_LABEL')
|
||
if (!item) return this.setError(new Error('该仓库唛头不属于本单箱唛换标明细'))
|
||
// The carton scan identifies the physical return source and records its label stage in one request.
|
||
const payload = { relabelNo: this.current.relabelNo, cartonMark: mark, expectedVersion: this.current.version }
|
||
const key = warehouseIdempotencyKey('relabel', 'carton-scan', payload)
|
||
await this.run('carton-scan', async () => {
|
||
const result = await scanRelabelCarton(payload.relabelNo, { cartonMark: mark, expectedVersion: payload.expectedVersion, idempotencyKey: key.value })
|
||
this.applyCurrent(result)
|
||
const confirmedItem = result.items.find((row) => row.itemId === item.itemId)
|
||
this.lastCarton = { cartonMark: mark, fbaCartonMark: confirmedItem?.fbaCartonMark || item.fbaCartonMark, destinationFbaWarehouseCode: confirmedItem?.destinationFbaWarehouseCode || item.destinationFbaWarehouseCode }
|
||
this.quickCarton = ''
|
||
this.activeTarget = 'quick-carton'
|
||
this.successMessage = ''
|
||
await new Promise((resolve) => uni.showModal({ title: '请粘贴对应箱唛', content: `仓库唛头:${mark}\n请贴 FBA 箱唛:${this.lastCarton.fbaCartonMark}\nFBA 目的仓:${this.lastCarton.destinationFbaWarehouseCode || '待维护'}`, showCancel: false, confirmText: '知道了', complete: resolve }))
|
||
}, true, key)
|
||
},
|
||
removePhoto(index) { updateWarehouseWork('relabel', { photoFileIds: this.photoFileIds.filter((_, i) => i !== index), photoNames: this.photoNames.filter((_, i) => i !== index) }) },
|
||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.busy) return; if (this.activeTarget === 'pallet-work') { this.$refs.palletWork?.handleScan(value); return } if (this.activeTarget === 'quick-carton') { this.quickCarton = value; this.submitCarton(value) } else this.handleHardwareScan(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||
handleHardwareScan(value) { if (this.activeTarget === 'return-carton') { this.verifyReturnCarton(value); return } if (this.activeTarget.startsWith('target-')) { this.returnTargetLabels[this.activeTarget.slice(7)] = value; return } if (this.activeTarget === 'quick-target') { this.quickTargetLabel = value; return } if (this.activeTarget === 'return-sku') { this.verifyReturnSku(value); return } if (this.activeTarget === 'quick-sku') { this.quickSku = value; this.submitSku(value); return } if (this.activeTarget === 'old') { this.oldLabel = value; return } this.taskCode = value; this.openTask(value) },
|
||
handleOldLabel(value) { this.oldLabel = String(value?.detail?.value ?? value ?? '').trim(); if (this.action === 'RELABEL') this.activeTarget = this.taskTab === 'sku' ? 'sku-new-0' : 'new' },
|
||
handleNewLabel(value) { this.newLabel = String(value?.detail?.value ?? value ?? '').trim() },
|
||
async bootstrap() { await this.loadCandidates(); if (this.error || this.stage !== 'execute' || !this.entryTaskNo) return; const target = this.candidates.find((item) => item.relabelNo === this.entryTaskNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); this.entryTaskNo = ''; if (target) await this.openTask(target.relabelNo, false) },
|
||
async loadCandidates() { if (this.loading) return; this.loading = true; this.error = null; try { this.candidates = await listRelabelCandidates() } catch (error) { this.setError(error, '换标任务加载失败') } finally { this.loading = false } },
|
||
async openTask(raw, sound = true) { const no = String(raw?.detail?.value ?? raw ?? this.taskCode).trim(); if (!no || this.busy) return; this.taskCode = no; await this.run('detail', async () => { this.applyCurrent(await getRelabelTask(no)); if (sound) playSuccessSound() }, false) },
|
||
applyCurrent(value) { const changed = this.current?.relabelNo !== value.relabelNo; this.current = value; this.taskCode = value.relabelNo; if (changed) { this.unitDrafts = {}; this.documents = []; this.quickCarton = ''; this.lastCarton = null; this.returnCartonCode = ''; this.verifiedReturnCarton = ''; this.returnTargetLabels = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.bulkQuantities = {}; if (warehouseWork.relabel.photoTaskNo !== value.relabelNo) updateWarehouseWork('relabel', { photoTaskNo: value.relabelNo, photoFileIds: [], photoNames: [], remark: '' }); this.changeTab(value.items.some((item) => item.operationType === 'SKU_LABEL') ? 'sku' : 'carton'); this.loadDocuments() } if (this.selectedItem) this.selectedItem = this.visibleItems.find((item) => item.itemId === this.selectedItem.itemId) || null },
|
||
changeTab(value) { this.taskTab = value; this.selectedItem = null; this.reason = ''; this.quickSku = ''; this.skuChoices = []; this.activeTarget = value === 'carton' ? 'quick-carton' : this.isReturnBulk ? 'return-carton' : 'quick-sku' },
|
||
skuDetails(item) { return item.skuItems?.length ? item.skuItems : [{ skuCode: item.skuCode, newSkuCode: item.newSkuCode, quantity: null }] },
|
||
selectItem(item) { if (this.taskTab === 'carton') { this.activeTarget = 'quick-carton'; return } if (this.remaining(item) <= 0) return; if (!this.unitDrafts[item.itemId]) this.unitDrafts[item.itemId] = { cartonMark: '', fbaCartonMark: '', skuVerifications: this.skuDetails(item).map((sku) => ({ skuCode: sku.skuCode || '', newSkuCode: '', quantity: sku.quantity })) }; this.selectedItem = item; this.reason = ''; this.activeTarget = 'old' },
|
||
focusNextSku(index) { const next = index + 1; this.activeTarget = next < this.unitDraft.skuVerifications.length ? `sku-new-${next}` : 'old' },
|
||
remaining(item) { const processed = this.taskTab === 'sku' ? item.skuProcessedPackages : item.cartonProcessedPackages; const completed = item.operationType === 'CARTON_AND_SKU' ? Number(processed || 0) : Number(processed ?? item.relabelledPackages); return Math.max(0, item.expectedPackages - completed - item.skippedPackages - item.exceptionPackages) },
|
||
changeAction(value) { this.action = value; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
|
||
async claimTask() { if (!this.current) return; const payload = { relabelNo: this.current.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(this.current.relabelNo, { idempotencyKey: key.value })); this.successMessage = '换标任务已认领,可进入换标执行'; await this.loadCandidates() }, true, key) },
|
||
async promptActiveTask(active) {
|
||
if (this.busy) return
|
||
this.busy = true
|
||
this.error = null
|
||
try {
|
||
const confirmed = await new Promise((resolve) => uni.showModal({
|
||
title: '已有正在执行的换标任务',
|
||
content: `当前账号正在执行 ${active.relabelNo},请先完成该任务。是否前往继续作业?`,
|
||
confirmText: '前往任务', cancelText: '取消',
|
||
success: (result) => resolve(result.confirm), fail: () => resolve(false)
|
||
}))
|
||
if (!confirmed) return
|
||
this.taskCode = active.relabelNo
|
||
uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(active.relabelNo)}` })
|
||
} finally { this.busy = false }
|
||
},
|
||
async confirmStartTask(item) {
|
||
if (this.busy) return false
|
||
this.busy = true
|
||
try {
|
||
return await new Promise((resolve) => uni.showModal({
|
||
title: '开始贴标作业',
|
||
content: `是否开始贴标作业?\n贴标申请:${item.relabelNo}`,
|
||
confirmText: '是', cancelText: '否',
|
||
success: (result) => resolve(result.confirm), fail: () => resolve(false)
|
||
}))
|
||
} finally { this.busy = false }
|
||
},
|
||
async openExecution(item) {
|
||
if (this.busy || this.stage === 'review') return
|
||
if (this.stage === 'execute') {
|
||
if (item.taskStatus !== 'PROCESSING' || item.claimedByCurrentOperator !== true) return
|
||
return this.openTask(item.relabelNo)
|
||
}
|
||
const active = this.candidates.find((candidate) => candidate.taskStatus === 'PROCESSING' && candidate.claimedByCurrentOperator === true)
|
||
if (active && active.relabelNo !== item.relabelNo) return this.promptActiveTask(active)
|
||
if (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator) return uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(item.relabelNo)}` })
|
||
const confirmed = await this.confirmStartTask(item)
|
||
if (!confirmed) return
|
||
this.taskCode = item.relabelNo
|
||
const payload = { relabelNo: item.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); let claimed = false
|
||
await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(item.relabelNo, { idempotencyKey: key.value })); claimed = true }, true, key)
|
||
if (claimed) uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(item.relabelNo)}` })
|
||
},
|
||
async openReview(item) { if (this.busy || this.stage !== 'review' || item.taskStatus !== 'WAIT_REVIEW') return; await this.openTask(item.relabelNo) },
|
||
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), skuVerifications: this.unitDraft.skuVerifications.map((sku) => ({ ...sku, skuCode: sku.skuCode.trim(), newSkuCode: sku.newSkuCode.trim() })), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), skuVerifications: payload.skuVerifications, idempotencyKey: key.value }); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本箱处理结果已保存' }, true, key) },
|
||
choosePhoto() { if (this.busy || !this.current || this.photoFileIds.length >= 20) return; const no = this.current.relabelNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { if (this.current?.relabelNo !== no) return; await this.run('photo', async () => { for (const [index, path] of (tempFilePaths || []).entries()) { const selected = tempFiles?.[index]?.file || tempFiles?.[index]; if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const uploadSource = typeof Blob !== 'undefined' && selected instanceof Blob ? selected : path; const file = await uploadRelabelPhoto(no, uploadSource); this.photoUrls[String(file.fileId)] = path; updateWarehouseWork('relabel', { photoTaskNo: no, photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }) } this.successMessage = '现场照片已上传' }, false) } }) },
|
||
async uploadPhoto(path) { await this.run('photo', async () => { const file = await uploadRelabelPhoto(this.current.relabelNo, path); updateWarehouseWork('relabel', { photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }); this.successMessage = '现场照片已上传' }, false) },
|
||
async completeTask() { if (this.busy || this.completionBlockReason) return; const payload = { photoFileIds: [...this.photoFileIds], expectedVersion: this.current.version, remark: this.remark.trim(), manualCompletion: !this.allProcessed }; const no = this.current.relabelNo; if (payload.manualCompletion) { const confirmed = await new Promise((resolve) => uni.showModal({ title: '手动完成换标', content: '未处理箱将登记异常,关联尾程订单将标为异常。确认提交?', success: (result) => resolve(result.confirm), fail: () => resolve(false) })); if (!confirmed || this.current?.relabelNo !== no || this.current.version !== payload.expectedVersion) return } const key = warehouseIdempotencyKey('relabel', 'complete', { relabelNo: no, ...payload }); let completed = false; await this.run('complete', async () => { await completeRelabelTask(no, { ...payload, idempotencyKey: key.value }); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() },
|
||
goToClaimList() { this.taskCode = ''; uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelReceive' }) },
|
||
closeCurrent() { this.current = null; this.selectedItem = null; this.error = null; this.successMessage = ''; this.activeTarget = 'task'; this.taskCode = '' },
|
||
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
|
||
async retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() },
|
||
async reloadCurrent() { if (!this.current?.relabelNo) return; this.verifiedReturnCarton = ''; this.returnCartonCode = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
|
||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error) && !isRelabelFundsBlocked(error)) { try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
|
||
setError(error, fallback = '换标作业失败') {
|
||
this.error = { message: relabelErrorMessage(error, fallback), code: error?.code, requestId: error?.requestId, traceId: error?.traceId }
|
||
playErrorSound()
|
||
const content = [this.error.message, this.error.code && `错误码:${this.error.code}`, this.error.requestId && `requestId:${this.error.requestId}`, this.error.traceId && `traceId:${this.error.traceId}`].filter(Boolean).join('\n')
|
||
uni.showModal({ title: '操作提示', content, showCancel: false, confirmText: '知道了' })
|
||
},
|
||
statusLabel(value) { return ({ AVAILABLE: '待认领', PENDING_PROCESSING: '待认领', PROCESSING: '处理中', WAIT_REVIEW: '待客户/平台处理', COMPLETED: '已完成' })[value] || value || '待认领' },
|
||
formatTime(value) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' }
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.task-card.rework-card{border:2px solid #f29b38;background:#fffaf2;box-shadow:0 5px 16px rgba(221,116,20,.13)}.rework-tag{display:inline-flex;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:700;line-height:18px;background:#d94343;color:#fff}.detail-toolbar{display:flex;align-items:center;justify-content:flex-end;margin:0 0 10px}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.tab-row{display:flex;gap:8px;margin:12px 0 8px}.tab-button{flex:none;width:auto;margin:0;border:1px solid #d7e1ef;border-radius:7px;background:#fff;color:#52647c;font-size:12px;line-height:30px;padding:0 14px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600}
|
||
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:140px;background:#f1f4f8}.photo-placeholder{margin:0;border:0;border-radius:0;font-size:13px;white-space:normal;line-height:1.5;display:flex;align-items:center;justify-content:center}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-actions{display:flex;justify-content:space-around;padding:4px}.photo-actions .link-button{margin:0}.item-card>input{box-sizing:border-box;width:100%;padding:10px;border:1px solid #d7e1ef;border-radius:8px;margin:8px 0}.bulk-total-button{margin-top:14px}.bulk-quantity{box-sizing:border-box;width:100%;padding:12px;border:1px solid #d7e1ef;border-radius:8px;background:#fff}.source-allocation{margin-top:14px;padding:12px;border-radius:10px;background:#f6f8fb}.source-title{display:block;font-weight:700;color:#24364b;margin-bottom:8px}.source-row,.source-total{display:flex;justify-content:space-between;gap:12px;padding:8px 0;color:#52647c}.source-row+ .source-row{border-top:1px solid #e4eaf2}.source-row text{min-width:0;overflow-wrap:anywhere}.source-row strong,.source-total strong{white-space:nowrap;color:#1f5fca}.source-total{border-top:1px solid #d7e1ef;font-weight:700}
|
||
.destination-summary{display:flex;align-items:center;flex-wrap:wrap;gap:5px;margin-top:6px}.destination-summary .meta-line{margin:0}.destination-tag{display:inline-block;padding:3px 7px;border-radius:5px;background:#edf4ff;color:#285d9f;font-size:10px;line-height:14px}
|
||
.carton-label-list{margin-top:10px;gap:6px}.carton-label-item{display:flex;align-items:center;flex-wrap:nowrap;gap:6px;padding:7px 0;min-width:0}.carton-label{display:flex;flex:1;flex-direction:column;gap:2px;box-sizing:border-box;min-width:0;overflow:hidden;padding:5px 7px;border:1px solid #dce5ef;border-radius:6px;background:#f6f8fb}.fba-label{border-color:#cddff9;background:#eff5ff}.label-caption{color:#718095;font-size:9px;line-height:13px}.label-code{display:block;color:#253e5b;font-size:10px;font-weight:600;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.label-arrow{flex-shrink:0;color:#8090a3;font-size:12px}.carton-label-item>.destination-tag{flex-shrink:0}.sku-detail-card{padding:8px 10px}.sku-detail-card .business-code{font-size:12px;font-weight:600;line-height:18px;overflow-wrap:anywhere}.sku-detail-card .meta-line{font-size:9px;line-height:14px}.sku-detail-card .link-button{height:26px;font-size:9px;line-height:26px}
|
||
.summary-metrics{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px;margin-left:auto;font-size:9px;line-height:18px}.summary-metric{display:inline-block;padding:1px 4px;border-radius:4px;background:#dcfce7;color:#16a34a;font-weight:700;white-space:nowrap}
|
||
.candidate-toolbar{align-items:center;margin-bottom:12px}.candidate-toolbar .card-title{margin-bottom:0;font-size:16px;line-height:24px}.candidate-toolbar .link-button{display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:88px;height:44px;min-height:44px;margin:0;padding:0 16px;border:1px solid #9dc0f4;border-radius:8px;color:#135ee8;font-size:16px;font-weight:700;line-height:normal;background:#eaf2ff}.candidate-toolbar .link-button::after{border:0}.candidate-toolbar .link-button[disabled]{color:#718095;border-color:#dce5f0;background:#e8edf3}
|
||
.relabel-candidate .business-code{min-width:0;font-size:18px;line-height:24px;overflow-wrap:anywhere}.relabel-candidate .status-pill{font-size:12px;line-height:18px}.candidate-kind{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;color:#52647c;font-size:14px;line-height:22px}.candidate-footer{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin-top:10px}.candidate-progress{color:#52647c;font-size:14px;line-height:24px}.candidate-progress-value{color:#135ee8;font-size:18px;font-weight:700}.candidate-warning{color:#b54708;font-size:12px;line-height:20px}.candidate-enter{margin-left:auto;color:#135ee8;font-size:14px;font-weight:600;line-height:24px}</style>
|