Files
pda-overseas-uniapp/src/pages/relabel/relabel.vue
T

394 lines
57 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view v-if="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>