Files
pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue
T

281 lines
39 KiB
Vue
Raw Normal View History

2026-09-17 13:36:08 +08:00
<template>
<view v-if="user" class="page-shell portal-page transfer-page">
2026-09-24 08:55:38 +08:00
<PortalHeader :title="entryMode === 'list' ? '仓储转运测量列表' : '转运测量作业'" :user="user" fallback-url="/pages/module/module?key=inbound" />
2026-09-17 13:36:08 +08:00
<scroll-view scroll-y class="transfer-scroll"><view class="portal-content transfer-content">
<view class="transfer-heading">
2026-09-24 08:55:38 +08:00
<view><text class="transfer-title">{{ entryMode === 'list' ? '仓储转运测量列表' : '转运测量作业' }}</text><text class="transfer-subtitle">{{ entryMode === 'list' ? '选择待测转运单后进入作业详情' : '按实际托盘分别记录箱规、重量和箱数' }}</text></view>
2026-09-17 13:36:08 +08:00
<button class="refresh-button" :disabled="loading" aria-label="刷新待测转运单" @tap="loadCandidates"><uni-icons type="refresh" color="#135ee8" size="18" /><text>{{ loading ? '加载中' : '刷新' }}</text></button>
</view>
2026-09-24 08:55:38 +08:00
<template v-if="!selected && entryMode === 'list'">
2026-09-17 13:36:08 +08:00
<view class="section-heading"><text>待测转运单</text><text>{{ candidates.length }} 单</text></view>
<view v-if="!candidates.length && !loading" class="transfer-empty"><text class="empty-title">暂无待测量任务</text><text>新任务进入后可点击右上角刷新</text></view>
<view class="candidate-list">
<button v-for="item in candidates" :key="item.transferNo" class="transfer-card" :class="{ selected: selected?.transferNo === item.transferNo }" @tap="select(item)">
2026-09-24 08:55:38 +08:00
<view class="transfer-card-head"><text class="transfer-no">{{ item.transferNo }}</text><text class="status-pill">{{ statusLabel(item.status, item.transferNo) }}</text></view>
2026-09-22 17:27:56 +08:00
<view class="task-metrics"><view><text>计划箱数</text><strong>{{ item.packageCount || 0 }}</strong></view><view><text>{{ item.destinationType === 'PRIVATE' ? '目的仓' : 'FBA 目的仓' }}</text><strong>{{ destinationLabel(item) }}</strong></view><view><text>换标</text><strong>{{ item.needRelabel ? '需要' : '不需要' }}</strong></view></view>
2026-09-17 13:36:08 +08:00
<text class="transfer-meta">入仓码 {{ item.inboundCode || '—' }}</text>
</button>
</view>
2026-09-24 08:55:38 +08:00
</template>
<view v-if="!selected && entryMode === 'work' && !detailLoading" class="transfer-empty"><text class="empty-title">暂无进行中的转运测量作业</text><text>请先从“仓储转运测量列表”选择待测转运单。</text></view>
2026-09-17 13:36:08 +08:00
<view v-if="detailLoading" class="transfer-empty"><text class="empty-title">正在读取转运明细</text><text>请稍候,不要重复选择</text></view>
<template v-if="selected && !detailLoading">
2026-09-24 08:55:38 +08:00
<view class="operation-detail-heading"><button class="back-to-list" @tap="backToCandidates">‹ 返回待测列表</button><view><text class="operation-detail-title">作业详情</text><text class="operation-detail-subtitle">进入后完成当前转运单的测量、打托和拍照</text></view></view>
2026-09-23 09:06:31 +08:00
<view v-if="isInventoryTransfer" class="transfer-success inventory-transfer-panel"><text class="feedback-title">库存转运 · 无需测量</text><text>该单来源于仓库现有库存,不需要重复测量或上传现场照片。请进入托盘作业,按来源托盘执行整托转运、拆托重打,或与相同目的仓合托。</text><button class="transfer-primary" @tap="openPalletOperations">进入托盘作业</button></view>
<template v-if="!isInventoryTransfer">
2026-09-17 13:36:08 +08:00
<view class="order-summary">
<view><text class="summary-eyebrow">当前测量任务</text><text class="summary-no">{{ selected.transferNo }}</text></view>
<view class="summary-count"><strong>{{ totalCartons }}</strong><text>实收箱</text></view>
</view>
<view class="receipt-comparison"><view><strong>{{ selected.packageCount || 0 }}</strong><text>客户预报</text></view><view><strong>{{ totalCartons }}</strong><text>当前实收</text></view><view :class="{ difference: cartonDifference !== 0 }"><strong>{{ cartonDifference > 0 ? `+${cartonDifference}` : cartonDifference }}</strong><text>{{ cartonDifference === 0 ? '无差异' : cartonDifference > 0 ? '超收差异' : '短收差异' }}</text></view></view>
<view class="summary-metrics"><view><strong>{{ pallets.length }}</strong><text>托盘</text></view><view><strong>{{ totalVolume }}</strong><text>总体积 m³</text></view><view><strong>{{ totalWeight }}</strong><text>总重量 kg</text></view></view>
<view v-for="(pallet, index) in pallets" :key="pallet.key" class="pallet-card">
<view class="pallet-head">
<view class="pallet-index"><text>{{ String(index + 1).padStart(2, '0') }}</text></view>
<view class="pallet-heading-copy"><text>托盘 {{ index + 1 }} · {{ pallet.bound ? '已完成' : pallet.generated ? '待绑定' : '待测量' }}</text><text>{{ pallet.collapsed && pallet.palletNo ? pallet.palletNo : '本卡数据仅属于当前托盘' }}</text></view>
<button v-if="pallets.length > 1 && !pallet.generated" class="remove-button" @tap.stop="removePallet(index)">移除</button>
<button v-if="pallet.generated" class="collapse-button" :aria-label="pallet.collapsed ? '展开托盘明细' : '折叠托盘明细'" @tap.stop="pallet.collapsed = !pallet.collapsed">{{ pallet.collapsed ? '⌄' : '⌃' }}</button>
</view>
<view v-show="!pallet.collapsed">
<view class="measure-section-head"><view><text class="measure-title">本托实测</text><text class="measure-hint">尺寸单位 cm,重量单位 kg</text></view><view class="volume-chip"><text>自动体积</text><strong>{{ palletVolume(pallet) }} m³</strong></view></view>
<view class="dimension-grid">
<label><text class="field-label">长</text><view class="input-with-unit"><input type="digit" v-model.number="pallet.lengthCm" :disabled="pallet.generated" placeholder="长度" /><text>cm</text></view></label>
<label><text class="field-label">宽</text><view class="input-with-unit"><input type="digit" v-model.number="pallet.widthCm" :disabled="pallet.generated" placeholder="宽度" /><text>cm</text></view></label>
<label><text class="field-label">高</text><view class="input-with-unit"><input type="digit" v-model.number="pallet.heightCm" :disabled="pallet.generated" placeholder="高度" /><text>cm</text></view></label>
</view>
<view class="measure-grid">
<label><text class="field-label">当前托重量</text><view class="input-with-unit important"><input type="digit" v-model.number="pallet.grossWeightKg" :disabled="pallet.generated" placeholder="毛重" /><text>kg</text></view></label>
<label><text class="field-label">当前托箱数</text><view class="input-with-unit important"><input type="number" v-model.number="pallet.cartonCount" :disabled="pallet.generated" placeholder="箱数" /><text>箱</text></view></label>
</view>
<button v-if="!pallet.generated" class="generate-button" :disabled="!palletReady(pallet) || pallet.busy" @tap="generatePallet(pallet)">{{ pallet.busy ? '生成中…' : '生成托盘号' }}</button>
2026-09-22 17:27:56 +08:00
<view v-else class="generated-card"><view><text class="print-code">{{ pallet.palletNo }}</text><text>{{ destinationLabel(pallet, selected) }}</text></view><button :disabled="pallet.busy" @tap="printDraftPallet(pallet)">打印标签</button></view>
2026-09-17 13:36:08 +08:00
<view v-if="pallet.generated && !pallet.bound" class="bind-panel"><ScanTargetInput :name="`transfer-pallet-${index}`" v-model="pallet.bindCode" label="确认实物托盘码" placeholder="扫描或手输新托盘标签号" :active="activePalletKey === pallet.key" @activate="activePalletKey = pallet.key" @submit="bindPallet(pallet)" /><button :disabled="pallet.busy || !String(pallet.bindCode || '').trim()" @tap="bindPallet(pallet)">{{ pallet.busy ? '确认中…' : '确认绑定' }}</button></view>
<view v-if="pallet.bound" class="bound-state">已绑定实物托盘 {{ pallet.palletNo }}</view>
</view>
</view>
<button class="add-pallet-button" @tap="addPallet"><uni-icons type="plus" color="#135ee8" size="18" /><text>新增一个托盘</text></button>
<view class="photo-card">
<view class="photo-head"><view><text class="measure-title">测量现场照片</text><text class="measure-hint">完成测量并打托前至少拍摄 1 张</text></view><text class="photo-count">{{ photoFileIds.length }} / 20 张</text></view>
<view v-if="photoFileIds.length" class="photo-grid"><view v-for="(fileId, index) in photoFileIds" :key="fileId" class="photo-tile">
<image class="photo-image" :src="photoUrls[fileId]" mode="aspectFill" @tap="previewPhoto(fileId)" />
<button class="photo-remove" :disabled="uploadingPhoto || submitting" @tap="removePhoto(index)">移除</button>
</view></view>
<button v-if="photoFileIds.length < 20" class="take-photo-button" :disabled="uploadingPhoto || submitting" @tap="takePhoto"><uni-icons type="camera-filled" color="#135ee8" size="20" /><text>{{ uploadingPhoto ? '照片上传中…' : '拍摄测量现场照片' }}</text></button>
</view>
<view v-if="validationMessage" class="transfer-validation" role="alert"><text class="validation-title">还不能提交</text><text>{{ validationMessage }}</text></view>
<view class="submit-panel"><view><text>整单测量汇总</text><strong>{{ pallets.length }} 托 · {{ totalCartons }} / {{ selected.packageCount || 0 }} 箱</strong></view><button class="transfer-primary" :disabled="submitting || !canSubmit" @tap="submit">{{ submitting ? '提交中…' : '完成测量并打托' }}</button></view>
2026-09-23 09:06:31 +08:00
</template>
2026-09-17 13:36:08 +08:00
</template>
<view v-if="error" class="transfer-error" role="alert"><text class="feedback-title">操作失败</text><text>{{ error.message || error }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text></view>
<view v-if="success" class="transfer-success" role="status"><text class="feedback-title">提交成功</text><text>测量与托盘打托已完成</text></view>
<view v-if="completedPallets.length" class="print-card">
2026-09-22 17:27:56 +08:00
<view class="photo-head"><view><text class="measure-title">新托盘标签</text><text class="measure-hint">{{ completedDestinationLabel }} · 逐托打开 PDF 后打印并粘贴</text></view></view>
<view v-for="pallet in completedPallets" :key="pallet.palletNo" class="print-row"><view><text class="print-code">{{ pallet.palletNo }}</text><text>{{ pallet.cartonCount }} 箱 · {{ destinationLabel(pallet, { destinationType: completedDestinationType }) }}</text></view><button @tap="printPallet(pallet)">打印标签</button></view>
2026-09-17 13:36:08 +08:00
</view>
</view></scroll-view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { session, requireSession } from '@/stores/session.js'
2026-09-24 08:55:38 +08:00
import { restoreWarehouseWork, resetWarehouseDomain, updateWarehouseWork, warehouseWork } from '@/stores/warehouse-work.js'
2026-09-17 13:36:08 +08:00
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { bindWarehouseTransferPallet, deleteWarehouseTransferPallet, downloadWarehouseTransferMeasurementPhoto, downloadWarehouseTransferPalletLabel, generateWarehouseTransferPallet, getWarehouseTransferDetail, listWarehouseTransferCandidates, listWarehouseTransferMeasurementPhotos, palletizeWarehouseTransfer, saveWarehouseTransferPalletDraft, uploadWarehouseTransferMeasurementPhoto } from '@/api/warehouse-transfers.js'
const newPallet = () => ({ key: `${Date.now()}-${Math.random()}`, cartonCount: 0, lengthCm: null, widthCm: null, heightCm: null, grossWeightKg: null, generated: false, bound: false, collapsed: false, palletNo: '', fbaWarehouseCode: '', labelDownloadPath: '', bindCode: '', busy: false })
const restoredPallet = (value, index) => ({
key: `server-${value.draftId || value.id || index}`,
cartonCount: Number(value.cartonCount || 0), lengthCm: value.lengthCm, widthCm: value.widthCm, heightCm: value.heightCm, grossWeightKg: value.grossWeightKg,
generated: Boolean(value.palletNo), bound: Boolean(value.boundAt || value.status === 'BOUND'), collapsed: Boolean(value.boundAt || value.status === 'BOUND'),
palletNo: String(value.palletNo || ''), fbaWarehouseCode: String(value.fbaWarehouseCode || ''), labelDownloadPath: String(value.labelDownloadPath || ''), bindCode: '', busy: false,
draftId: value.draftId || value.id, version: value.version
})
export default {
components: { PortalHeader, ScanTargetInput },
2026-09-24 08:55:38 +08:00
data() { return { user: session.currentUser, entryMode: 'work', pendingTaskNo: '', candidates: [], selected: null, pallets: [newPallet()], activePalletKey: '', photoFileIds: [], photoNames: [], photoUrls: {}, completedPallets: [], completedTransferNo: '', completedDestination: '', completedDestinationType: '', loading: false, detailLoading: false, submitting: false, uploadingPhoto: false, openingLabel: false, error: null, success: false, pendingKey: '' } },
2026-09-17 13:36:08 +08:00
computed: {
2026-09-22 17:27:56 +08:00
completedDestinationLabel() { return this.destinationLabel({ fbaWarehouseCode: this.completedDestination, destinationType: this.completedDestinationType }) },
2026-09-17 13:36:08 +08:00
totalCartons() { return this.pallets.reduce((sum, pallet) => sum + Math.max(0, Number(pallet.cartonCount) || 0), 0) },
cartonDifference() { return this.totalCartons - Number(this.selected?.packageCount || 0) },
2026-09-23 09:06:31 +08:00
isInventoryTransfer() { return Boolean(this.selected && this.selected.inboundCode && this.selected.inboundCode !== this.selected.transferNo) },
2026-09-17 13:36:08 +08:00
totalVolume() { return this.pallets.reduce((sum, pallet) => sum + this.rawPalletVolume(pallet), 0).toFixed(3) },
totalWeight() { return this.pallets.reduce((sum, pallet) => sum + Math.max(0, Number(pallet.grossWeightKg) || 0), 0).toFixed(2) },
validationMessage() {
2026-09-23 09:06:31 +08:00
if (this.isInventoryTransfer) return ''
2026-09-17 13:36:08 +08:00
if (!this.selected || !this.pallets.length) return '转运单缺少可测量的托盘'
for (let index = 0; index < this.pallets.length; index += 1) {
const pallet = this.pallets[index]
if (![pallet.cartonCount, pallet.lengthCm, pallet.widthCm, pallet.heightCm, pallet.grossWeightKg].every((value) => Number(value) > 0)) return `托盘 ${index + 1} 的箱数、箱规和重量必须大于 0`
if (!Number.isInteger(Number(pallet.cartonCount))) return `托盘 ${index + 1} 的箱数必须是整数`
}
if (this.pallets.some((pallet) => !pallet.bound)) return '请先生成并扫描确认绑定全部托盘'
if (!this.photoFileIds.length) return '请至少拍摄 1 张测量现场照片'
return ''
},
canSubmit() { return !this.validationMessage }
},
watch: {
pallets: { deep: true, handler() { if (!this.submitting) this.pendingKey = '' } }
},
2026-09-24 11:38:51 +08:00
onShow() { if (requireSession()) { this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.loadCandidates().then(() => { const activeNo = String(warehouseWork.warehouseTransfer.currentNo || ''); const taskNo = this.pendingTaskNo || activeNo; if (this.entryMode === 'work' && taskNo) { const active = this.candidates.find((item) => String(item.transferNo) === taskNo); if (active) { const fromExplicitTask = Boolean(this.pendingTaskNo); this.pendingTaskNo = ''; void this.select(active, fromExplicitTask) } } }) } },
2026-09-24 08:55:38 +08:00
onLoad(options) { this.entryMode = options?.mode === 'list' ? 'list' : 'work'; this.pendingTaskNo = String(options?.task || '') },
2026-09-17 13:36:08 +08:00
onHide() { this.stopScanner() },
beforeUnmount() { this.stopScanner() },
2026-09-23 09:06:31 +08:00
methods: {
openPalletOperations() { uni.navigateTo({ url: '/pages/pallet/pallet' }) },
2026-09-22 17:27:56 +08:00
destinationLabel(value, fallback = {}) { const code = String(value?.fbaWarehouseCode || fallback?.fbaWarehouseCode || '').trim(); if (code) return code; const type = value?.destinationType || fallback?.destinationType; return type === 'PRIVATE' ? '私人仓' : '目的仓待定' },
2026-09-17 13:36:08 +08:00
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handlePalletScan(value), onError: (error) => { this.error = error } }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handlePalletScan(value) { const pallet = this.pallets.find((item) => item.key === this.activePalletKey && item.generated && !item.bound); if (!pallet) return; pallet.bindCode = String(value || '').trim(); this.bindPallet(pallet) },
2026-09-24 08:55:38 +08:00
statusLabel(status, transferNo = '') { if (String(status) === 'WAIT_PDA_RECEIVING' && String(warehouseWork.warehouseTransfer.currentNo) === String(transferNo)) return '测量中'; return ({ WAIT_PDA_RECEIVING: '待测量', APPROVED: '待测量', PARTIALLY_RECEIVED: '测量中' })[status] || '待测量' },
2026-09-17 13:36:08 +08:00
rawPalletVolume(pallet) { const values = [pallet.lengthCm, pallet.widthCm, pallet.heightCm].map(Number); return values.every((value) => value > 0) ? values[0] * values[1] * values[2] / 1000000 : 0 },
palletVolume(pallet) { return this.rawPalletVolume(pallet).toFixed(3) },
palletReady(pallet) { return Number.isInteger(Number(pallet.cartonCount)) && [pallet.cartonCount, pallet.lengthCm, pallet.widthCm, pallet.heightCm, pallet.grossWeightKg].every((value) => Number(value) > 0) },
applyPallet(target, value) { Object.assign(target, restoredPallet(value, this.pallets.indexOf(target)), { key: target.key, busy: false, collapsed: false }) },
async generatePallet(pallet) {
if (!this.selected || pallet.busy || pallet.generated || !this.palletReady(pallet)) return
pallet.busy = true; this.error = null
try {
const measured = { palletId: pallet.draftId || null, idempotencyKey: `pda-transfer-draft-${this.selected.transferNo}-${pallet.key}`, cartonCount: Number(pallet.cartonCount), lengthCm: Number(pallet.lengthCm), widthCm: Number(pallet.widthCm), heightCm: Number(pallet.heightCm), volumeCbm: Number(this.rawPalletVolume(pallet).toFixed(6)), grossWeightKg: Number(pallet.grossWeightKg) }
const draftDetail = await saveWarehouseTransferPalletDraft(this.selected.transferNo, measured)
const draft = pallet.draftId ? (draftDetail.pallets || []).find((item) => String(item.id) === String(pallet.draftId)) : [...(draftDetail.pallets || [])].sort((a, b) => Number(b.sequenceNo || 0) - Number(a.sequenceNo || 0))[0]
if (!draft?.id) throw new Error('服务端未返回已保存的托盘草稿')
const generatedDetail = await generateWarehouseTransferPallet(this.selected.transferNo, draft.id, { idempotencyKey: `pda-transfer-generate-${this.selected.transferNo}-${draft.id}` })
const generated = (generatedDetail.pallets || []).find((item) => String(item.id) === String(draft.id))
if (!generated?.palletNo) throw new Error('服务端未返回新托盘号')
this.applyPallet(pallet, generated); this.pendingKey = ''
} catch (error) { this.error = error; pallet.busy = false }
},
async bindPallet(pallet) {
const code = String(pallet.bindCode || '').trim()
if (!this.selected || pallet.busy || !pallet.generated || pallet.bound || !code) return
pallet.busy = true; this.error = null
try { const detail = await bindWarehouseTransferPallet(this.selected.transferNo, pallet.draftId, { palletCode: code, idempotencyKey: `pda-transfer-bind-${this.selected.transferNo}-${pallet.draftId}-${code}` }); const value = (detail.pallets || []).find((item) => String(item.id) === String(pallet.draftId)); if (!value?.boundAt && value?.status !== 'BOUND') throw new Error('服务端未确认托盘绑定'); this.applyPallet(pallet, value); pallet.collapsed = true; this.pendingKey = '' }
catch (error) { this.error = error; pallet.busy = false }
},
async printDraftPallet(pallet) { if (!pallet.labelDownloadPath) { this.error = new Error('服务端未返回托盘标签下载地址'); return }; await this.openLabelPdf(() => downloadWarehouseTransferPalletLabel(pallet.labelDownloadPath)) },
2026-09-24 11:38:51 +08:00
async loadCandidates() {
this.loading = true; this.error = null
try {
const activeNo = String(warehouseWork.warehouseTransfer.currentNo || '')
const measuredStatuses = new Set(['WAIT_PDA_RECEIVING', 'APPROVED', 'ALLOCATED', 'PARTIALLY_RECEIVED'])
this.candidates = (await listWarehouseTransferCandidates()).map((item) => String(item.transferNo) === activeNo && item.status === 'WAIT_PDA_RECEIVING' ? { ...item, status: 'PARTIALLY_RECEIVED' } : item)
// 服务端已完成当前单但设备在提交响应前断网时,本地 currentNo 会遗留。
// 候选已成功读取且当前单不再可测,清掉本地锁,允许继续下一票。
if (activeNo && !this.candidates.some((item) => String(item.transferNo) === activeNo && measuredStatuses.has(String(item.status)))) {
resetWarehouseDomain('warehouseTransfer')
}
} catch (e) { this.error = e } finally { this.loading = false }
},
async select(item, fromExplicitTask = false) {
2026-09-17 13:36:08 +08:00
if (this.detailLoading || this.submitting) return
2026-09-24 08:55:38 +08:00
const requestedNo = String(item?.transferNo || '')
const activeNo = String(warehouseWork.warehouseTransfer.currentNo || '')
2026-09-24 11:38:51 +08:00
if (!activeNo && !fromExplicitTask) {
2026-09-24 08:55:38 +08:00
const result = await new Promise((resolve) => uni.showModal({ title: '开始测量', content: `确认开始测量转运单 ${requestedNo} 吗?开始后本设备需先完成该单,才能选择下一单。`, confirmText: '确认开始', cancelText: '取消', success: resolve, fail: () => resolve({ confirm: false }) }))
if (!result.confirm) return
}
if (activeNo && activeNo !== requestedNo) {
const result = await new Promise((resolve) => uni.showModal({ title: '存在未完结的测量单', content: `转运单 ${activeNo} 尚未测量完成,是否继续进入?`, confirmText: '去继续测量', cancelText: '取消', success: resolve, fail: () => resolve({ confirm: false }) }))
if (!result.confirm) return
item = this.candidates.find((candidate) => String(candidate.transferNo) === activeNo) || item
}
updateWarehouseWork('warehouseTransfer', { currentNo: String(item.transferNo) })
this.candidates = this.candidates.map((candidate) => String(candidate.transferNo) === String(item.transferNo) ? { ...candidate, status: 'PARTIALLY_RECEIVED' } : candidate)
if (this.entryMode === 'list') { uni.navigateTo({ url: `/pages/warehouse-transfer/warehouse-transfer?mode=work&task=${encodeURIComponent(String(item.transferNo))}` }); return }
2026-09-22 17:27:56 +08:00
this.detailLoading = true; this.error = null; this.success = false; this.completedPallets = []; this.completedTransferNo = ''; this.completedDestination = ''; this.completedDestinationType = ''
2026-09-17 13:36:08 +08:00
try { this.selected = await getWarehouseTransferDetail(item.transferNo); this.pendingKey = ''; this.pallets = this.selected.pallets?.length ? this.selected.pallets.map(restoredPallet) : [newPallet()]; await this.restorePhotos(item.transferNo, this.selected.measurementPhotos) }
catch (e) { this.selected = null; this.error = e }
finally { this.detailLoading = false }
},
2026-09-24 08:55:38 +08:00
backToCandidates() { if (this.submitting || this.detailLoading) return; this.entryMode = 'list'; this.selected = null; this.error = null; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = [] },
2026-09-17 13:36:08 +08:00
addPallet() { this.pendingKey = ''; this.pallets.push(newPallet()) },
async removePallet(index) { const pallet = this.pallets[index]; if (!pallet || pallet.generated || pallet.busy) return; pallet.busy = true; try { if (pallet.draftId) await deleteWarehouseTransferPallet(this.selected.transferNo, pallet.draftId); this.pallets.splice(index, 1); this.pendingKey = '' } catch (error) { pallet.busy = false; this.error = error } },
async restorePhotos(transferNo, knownPhotos) {
this.photoFileIds = []; this.photoUrls = {}; this.photoNames = []
try {
const photos = Array.isArray(knownPhotos) ? knownPhotos : await listWarehouseTransferMeasurementPhotos(transferNo)
this.photoFileIds = photos.map((photo) => String(photo.fileId)); this.photoNames = photos.map((photo) => photo.fileName || '测量现场照片')
await Promise.all(photos.map(async (photo) => { try { this.photoUrls[String(photo.fileId)] = await downloadWarehouseTransferMeasurementPhoto(transferNo, photo.fileId) } catch (_) { /* 单张恢复失败不阻断补拍 */ } }))
} catch (error) { this.error = error }
},
takePhoto() {
if (!this.selected || this.uploadingPhoto || this.submitting || this.photoFileIds.length >= 20) return
const transferNo = this.selected.transferNo
uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => {
const paths = (tempFilePaths || []).length ? tempFilePaths : (tempFiles || []).map((file) => file.path || file.tempFilePath).filter(Boolean)
if (this.selected?.transferNo !== transferNo) { this.error = new Error('所选转运单已切换,请重新拍摄'); return }
if (!paths.length) { this.error = new Error('未获取到可上传的照片路径,请重新拍摄'); return }
this.uploadingPhoto = true; this.error = null
try {
for (const [index, path] of paths.entries()) {
if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB')
const file = await uploadWarehouseTransferMeasurementPhoto(transferNo, path)
if (this.selected?.transferNo !== transferNo) throw new Error('所选转运单已切换,请重新拍摄')
const fileId = String(file.fileId); this.photoUrls[fileId] = path; this.photoFileIds.push(fileId); this.photoNames.push(file.fileName || '测量现场照片')
}
this.pendingKey = ''
} catch (error) { this.error = error } finally { this.uploadingPhoto = false }
}, fail: (cause) => { if (!/cancel/i.test(String(cause?.errMsg || ''))) this.error = new Error(cause?.errMsg || '选择照片失败,请重试') } })
},
previewPhoto(fileId) { const urls = this.photoFileIds.map((id) => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
removePhoto(index) { if (this.uploadingPhoto || this.submitting) return; const [fileId] = this.photoFileIds.splice(index, 1); this.photoNames.splice(index, 1); if (fileId) delete this.photoUrls[fileId]; this.pendingKey = '' },
async submit() {
if (!this.selected || this.submitting) return
if (this.validationMessage) { this.error = new Error(this.validationMessage); return }
this.submitting = true; this.error = null
try {
if (!this.pendingKey) this.pendingKey = `pda-transfer-${this.selected.transferNo}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
const payload = { idempotencyKey: this.pendingKey, photoFileIds: [...this.photoFileIds] }
2026-09-24 08:55:38 +08:00
const transferNo = this.selected.transferNo; const result = await palletizeWarehouseTransfer(transferNo, payload); this.completedTransferNo = transferNo; this.completedDestination = result?.fbaWarehouseCode || this.selected.fbaWarehouseCode || ''; this.completedDestinationType = result?.destinationType || this.selected.destinationType || ''; this.completedPallets = (result?.pallets || []).filter((pallet) => pallet?.palletNo); this.success = true; resetWarehouseDomain('warehouseTransfer'); this.selected = null; this.pendingKey = ''; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = []; await this.loadCandidates()
2026-09-17 13:36:08 +08:00
} catch (e) { this.error = e } finally { this.submitting = false }
},
async printPallet(pallet) { if (!pallet?.labelDownloadPath) { this.error = new Error('服务端未返回托盘标签下载地址,请刷新后重试'); return }; await this.openLabelPdf(() => downloadWarehouseTransferPalletLabel(pallet.labelDownloadPath)) },
async openLabelPdf(download) {
if (this.openingLabel) return
this.openingLabel = true; this.error = null
let h5View = null
// #ifdef H5
h5View = window.open('', '_blank'); if (!h5View) { this.error = new Error('请允许打开托盘标签 PDF 窗口'); this.openingLabel = false; return }
// #endif
try {
const filePath = await download()
// #ifdef H5
h5View.location.href = filePath
// #endif
// #ifndef H5
await new Promise((resolve, reject) => uni.openDocument({ filePath, fileType: 'pdf', showMenu: true, success: resolve, fail: (cause) => reject(new Error(cause?.errMsg || '托盘标签 PDF 打开失败')) }))
// #endif
} catch (error) { if (h5View) h5View.close(); this.error = error } finally { this.openingLabel = false }
}
}
}
</script>
<style scoped>
.transfer-page{height:100vh;overflow:hidden;background:#f2f5f9}.transfer-scroll{height:calc(100vh - 48px - var(--status-bar-height,0px))}.transfer-content{padding-bottom:52px}.transfer-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin:4px 2px 17px}.transfer-title,.transfer-subtitle,.transfer-no,.transfer-meta,.summary-eyebrow,.summary-no,.pallet-heading-copy text,.measure-title,.measure-hint,.sku-copy text,.validation-title,.transfer-validation>text,.feedback-title,.transfer-error>text,.transfer-success>text,.empty-title,.transfer-empty>text{display:block}.transfer-title{color:#14263d;font-size:22px;font-weight:850;letter-spacing:.2px}.transfer-subtitle{margin-top:4px;color:#718095;font-size:11px;line-height:17px}.refresh-button{display:flex;align-items:center;justify-content:center;gap:4px;width:auto;height:34px;margin:0;padding:0 11px;border:1px solid #b8d1f5;border-radius:17px;color:#135ee8;font-size:10px;line-height:34px;background:#f8fbff}.refresh-button[disabled]{opacity:.55}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:0 2px 8px;color:#243951;font-size:12px;font-weight:800}.section-heading text:last-child{color:#8290a2;font-size:10px;font-weight:600}.candidate-list{display:flex;flex-direction:column;gap:9px;margin-bottom:16px}.transfer-card{box-sizing:border-box;width:100%;margin:0;padding:13px;border:1px solid #dce5f0;border-radius:13px;text-align:left;background:#fff;box-shadow:0 4px 15px rgba(32,67,111,.055)}.transfer-card.selected{border-color:#6da3f1;background:#f7faff;box-shadow:0 5px 18px rgba(19,94,232,.11)}.transfer-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.transfer-no{overflow:hidden;color:#18324f;font-size:14px;font-weight:850;text-overflow:ellipsis;white-space:nowrap}.status-pill{flex-shrink:0;padding:4px 7px;border-radius:6px;color:#135ee8;font-size:9px;font-weight:750;background:#eaf2ff}.task-metrics{display:grid;grid-template-columns:.75fr 1.25fr .75fr;gap:6px;margin-top:11px}.task-metrics view{min-width:0;padding:7px 8px;border-radius:8px;background:#f4f7fa}.task-metrics text{display:block;color:#8492a4;font-size:8px}.task-metrics strong{display:block;overflow:hidden;margin-top:2px;color:#2c4058;font-size:11px;text-overflow:ellipsis;white-space:nowrap}.transfer-meta{margin-top:8px;color:#7b899b;font-size:9px}.transfer-empty{margin-bottom:12px;padding:26px 14px;border:1px dashed #ccd7e4;border-radius:13px;color:#8996a6;font-size:10px;line-height:18px;text-align:center;background:rgba(255,255,255,.65)}.empty-title{color:#56677b;font-size:12px;font-weight:800}.order-summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 15px;border-radius:14px 14px 0 0;color:#fff;background:linear-gradient(135deg,#123d73,#135ee8)}.summary-eyebrow{font-size:9px;opacity:.72}.summary-no{margin-top:3px;font-size:15px;font-weight:850}.summary-count{display:flex;align-items:baseline;gap:3px;flex-shrink:0}.summary-count strong{font-size:24px}.summary-count text{font-size:10px;opacity:.8}.summary-metrics{display:grid;grid-template-columns:repeat(3,1fr);margin-bottom:12px;padding:11px 8px;border-radius:0 0 14px 14px;background:#fff;box-shadow:0 5px 17px rgba(32,67,111,.07)}.summary-metrics view{position:relative;text-align:center}.summary-metrics view+view:before{position:absolute;top:3px;bottom:3px;left:0;width:1px;background:#e7edf4;content:''}.summary-metrics strong{display:block;color:#17314f;font-size:14px}.summary-metrics text{display:block;margin-top:2px;color:#8390a1;font-size:8px}.pallet-card{box-sizing:border-box;margin-bottom:12px;padding:14px;border:1px solid #d9e3ee;border-radius:15px;background:#fff;box-shadow:0 5px 18px rgba(32,67,111,.065)}.pallet-head{display:flex;align-items:center;gap:9px;margin:-2px -2px 14px;padding-bottom:11px;border-bottom:1px solid #edf1f5}.pallet-index{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:10px;color:#fff;font-size:13px;font-weight:850;background:#135ee8;box-shadow:0 4px 10px r
.photo-card{box-sizing:border-box;margin-bottom:11px;padding:14px;border:1px solid #d9e3ee;border-radius:13px;background:#fff;box-shadow:0 5px 18px rgba(32,67,111,.06)}
.photo-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:9px}
.photo-count{flex-shrink:0;padding:5px 8px;border-radius:7px;color:#135ee8;font-size:9px;font-weight:800;background:#eaf2ff}
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin:11px 0}
.photo-tile{position:relative;overflow:hidden;border-radius:10px;background:#eef2f6}
.photo-image{display:block;width:100%;height:112px}
.photo-remove{position:absolute;right:5px;bottom:5px;width:auto;height:26px;margin:0;padding:0 8px;border-radius:6px;color:#fff;font-size:9px;line-height:26px;background:rgba(152,35,35,.88)}
.take-photo-button{display:flex;align-items:center;justify-content:center;gap:6px;box-sizing:border-box;width:100%;height:42px;margin:9px 0 0;border:1px solid #9fc0ef;border-radius:9px;color:#135ee8;font-size:10px;font-weight:800;background:#f8fbff}
.take-photo-button[disabled]{color:#9aa7b5;background:#e7ecf2;opacity:1}
.print-card{box-sizing:border-box;margin-bottom:11px;padding:14px;border:1px solid #b9d3f4;border-radius:13px;background:#f8fbff}.print-row button{width:auto;margin:0;padding:0 10px;color:#135ee8;border:1px solid #9fc0ef;border-radius:8px;background:#fff}.print-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-top:1px solid #dce8f5;color:#728196;font-size:9px}.print-code{display:block;color:#17314f;font-size:13px;font-weight:850}
.generate-button{width:100%;margin-top:12px;color:#fff;background:#135ee8}.generated-card{display:flex;align-items:center;justify-content:space-between;margin-top:12px;padding:10px;border-radius:9px;background:#edf5ff}.generated-card button,.bind-panel>button{width:auto;margin:0;color:#135ee8;background:#fff}.bind-panel{margin-top:10px}.bound-state{margin-top:10px;padding:9px;border-radius:8px;color:#08705f;font-weight:800;background:#dff5ed}
2026-09-24 08:55:38 +08:00
.operation-detail-heading{display:flex;align-items:flex-start;gap:10px;margin:0 2px 12px;padding:2px 0}.back-to-list{width:auto;height:30px;margin:0;padding:0 9px;border:1px solid #c8d8ec;border-radius:8px;color:#135ee8;font-size:10px;line-height:30px;background:#f8fbff}.operation-detail-heading>view{min-width:0}.operation-detail-title{display:block;color:#243951;font-size:15px;font-weight:850}.operation-detail-subtitle{display:block;margin-top:3px;color:#8794a4;font-size:9px;line-height:14px}
2026-09-17 13:36:08 +08:00
.collapse-button{width:30px;height:28px;margin:0;padding:0;color:#135ee8;font-size:16px;line-height:28px;background:#edf5ff}
.receipt-comparison{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-bottom:9px}.receipt-comparison view{padding:9px;border-radius:9px;text-align:center;background:#fff}.receipt-comparison strong,.receipt-comparison text{display:block}.receipt-comparison strong{color:#17314f;font-size:15px}.receipt-comparison text{margin-top:2px;color:#8390a1;font-size:8px}.receipt-comparison .difference{border:1px solid #e3a044;background:#fff4df}.receipt-comparison .difference strong,.receipt-comparison .difference text{color:#a35b0c;font-weight:800}
</style>