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

469 lines
64 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-10-15 15:45:32 +08:00
<scroll-view scroll-y :scroll-into-view="cartonScrollTarget" scroll-with-animation class="transfer-scroll"><view class="portal-content transfer-content">
2026-09-17 13:36:08 +08:00
<view class="transfer-heading">
2026-10-15 15:45:32 +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-24 12:06:44 +08:00
<view v-if="isMeasurementComplete" class="transfer-success"><text class="feedback-title">测量已完成</text><text>该转运单已完成 PDA 测量,不再重复进入测量或托盘作业。</text></view>
<view v-else-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 && !isMeasurementComplete">
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>
2026-10-15 15:45:32 +08:00
<view class="summary-count"><strong>{{ cartonsCounted ? actualReceivedCartons : receivedCartonBoxes.length }}</strong><text>{{ cartonsCounted ? '实收箱' : '待确认箱' }}</text></view>
2026-09-17 13:36:08 +08:00
</view>
2026-10-15 15:45:32 +08:00
<view class="receipt-comparison"><view><strong>{{ selected.packageCount || 0 }}</strong><text>客户预报</text></view><view><strong>{{ cartonsCounted ? actualReceivedCartons : '—' }}</strong><text>{{ cartonsCounted ? '当前实收' : '清点后确认' }}</text></view><view :class="{ difference: cartonsCounted && cartonDifference !== 0 }"><strong>{{ !cartonsCounted ? '—' : cartonDifference > 0 ? `+${cartonDifference}` : cartonDifference }}</strong><text>{{ !cartonsCounted ? '差异待确认' : 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>
2026-09-17 13:36:08 +08:00
2026-10-15 15:45:32 +08:00
<view class="workflow-step"><text class="step-number">1</text><view><text class="step-title">创建签收托盘</text><text class="step-caption">支持多托盘,先生成标签,再分配箱子</text></view></view>
<view v-for="(pallet, index) in pallets" :key="pallet.key" class="pallet-card" :class="{ 'active-receiving-pallet': String(receivingPalletId) === String(pallet.draftId) }">
2026-09-17 13:36:08 +08:00
<view class="pallet-head">
<view class="pallet-index"><text>{{ String(index + 1).padStart(2, '0') }}</text></view>
2026-10-15 15:45:32 +08:00
<view class="pallet-heading-copy"><text>托盘 {{ index + 1 }} · {{ pallet.bound ? '已绑定' : pallet.generated ? '待绑定' : '待创建' }}</text><text>{{ pallet.collapsed && pallet.palletNo ? pallet.palletNo : '先创建托盘号,再录入并分配签收箱子' }}</text></view>
2026-09-17 13:36:08 +08:00
<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>
2026-10-15 15:45:32 +08:00
<view v-if="pallet.generated" class="pallet-brief"><text>{{ palletSummary(pallet).cartonCount }} 箱</text><text>{{ palletVolume(pallet) }} m³</text><text>{{ palletSummary(pallet).weight.toFixed(2) }} kg</text><button :disabled="cartonsCounted || countingCartons" @tap="useReceivingPallet(pallet)">{{ String(receivingPalletId) === String(pallet.draftId) ? '当前签收托盘' : '在此托盘签收' }}</button></view>
2026-09-17 13:36:08 +08:00
<view v-show="!pallet.collapsed">
2026-10-15 15:45:32 +08:00
<view class="measure-section-head"><view><text class="measure-title">本托箱子汇总</text><text class="measure-hint">由分配到本托的实收箱自动汇总,无需填写托盘尺寸和重量</text></view></view>
<view class="summary-metrics"><view><strong>{{ palletSummary(pallet).cartonCount }}</strong><text>实收箱数</text></view><view><strong>{{ palletVolume(pallet) }}</strong><text>箱子体积 m³</text></view><view><strong>{{ palletSummary(pallet).weight.toFixed(2) }}</strong><text>箱子重量 kg</text></view></view>
<button v-if="!pallet.generated" class="generate-button" :disabled="pallet.busy || cartonsCounted || countingCartons" @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>
2026-10-15 15:45:32 +08:00
<button class="add-pallet-button" :disabled="cartonsCounted || countingCartons" @tap="addPallet"><uni-icons type="plus" color="#135ee8" size="18" /><text>新增一个托盘</text></button>
<view v-if="!generatedPallets.length" class="transfer-validation"><view class="workflow-step"><text class="step-number">2</text><view><text class="step-title">填写签收箱子</text><text class="step-caption">请先生成托盘号,再填写签收箱子。</text></view></view></view>
<view v-if="generatedPallets.length" class="photo-card">
<view id="transfer-boxes" class="workflow-step"><text class="step-number">2</text><view><text class="step-title">填写签收箱子</text><text class="step-caption">相同箱规批量填,差异箱单独改</text></view></view>
<view class="carton-filter"><button :class="{active: !cartonPalletFilter}" @tap="cartonPalletFilter = ''">全部箱子</button><button v-for="(pallet, index) in generatedPallets" :key="pallet.draftId" :class="{active: String(cartonPalletFilter) === String(pallet.draftId)}" @tap="cartonPalletFilter = pallet.draftId">托盘 {{ index + 1 }} · {{ palletSummary(pallet).cartonCount }} 箱</button></view>
<text class="measure-hint">扫描本单箱号定位;填写每箱尺寸、重量和箱内实点件数。申报未提供件数,按现场实点记录。</text>
<ScanTargetInput name="transfer-carton" v-model="cartonScan" label="箱号" placeholder="扫描系统箱号或客户箱唛" :active="!cartonsCounted" @activate="activePalletKey = ''" @submit="locateCarton" />
<text class="measure-title">{{ receivedCartonBoxes.length }} 箱 · {{ cartonVolume }} m³ · {{ cartonWeight }} kg · {{ cartonQuantity }} 件</text>
<view v-if="!cartonsCounted" class="batch-count-panel">
<button class="add-pallet-button" :disabled="countingCartons" @tap="toggleBatchPanel">{{ batchCountOpen ? '收起批量填写' : '相同箱规?批量填写' }}</button>
<view v-if="batchCountOpen">
<text class="measure-hint">选择同 SKU 的箱子,统一填写。应用后可逐箱修改;会覆盖所选箱的尺寸、重量和件数。</text>
<picker :disabled="countingCartons" :range="batchSkuCodes" :value="Math.max(0, batchSkuCodes.indexOf(batchSku))" @change="changeBatchSku(Number($event.detail.value))"><view class="batch-sku">批量 SKU:{{ batchSku || '请选择' }} ▾</view></picker>
<view class="batch-actions"><button :disabled="countingCartons" @tap="selectBatchCartons(true)">全选该 SKU</button><button :disabled="countingCartons" @tap="selectBatchCartons(false)">清空选择</button></view>
<view class="batch-box-options"><template v-for="(box, index) in cartonBoxes" :key="box.localKey || box.cartonMark"><button v-if="box.received !== false && box.items[0].skuCode === batchSku" :disabled="countingCartons" :class="{chosen: batchSelectedIndexes.includes(index)}" :aria-label="`选择第${index + 1}箱批量填写`" @tap="toggleBatchCarton(index)">{{ batchSelectedIndexes.includes(index) ? '✓' : '+' }} 第 {{ index + 1 }} 箱</button></template></view>
<picker :disabled="countingCartons" :range="generatedPallets.map(pallet => pallet.palletNo)" @change="batchPalletId = generatedPallets[Number($event.detail.value)]?.draftId || ''"><view class="batch-sku">批量所属托盘:{{ generatedPallets.find(pallet => String(pallet.draftId) === String(batchPalletId))?.palletNo || '请选择' }} ▾</view></picker>
<CartonCountFields :box="batchBox" :sku-codes="[batchSku]" :allow-sku-change="false" :disabled="countingCartons" />
<text class="batch-summary">已选 {{ batchTargetBoxes.length }} 箱 · 每箱 {{ Number(batchBox.items[0].quantity) || 0 }} 件 · 合计 {{ batchTotalQuantity }} 件</text>
<button class="transfer-primary" :disabled="!batchCountReady || countingCartons" @tap="applyBatchCartons">应用到所选 {{ batchTargetBoxes.length }} 箱</button>
<text v-if="batchCountMessage" class="batch-summary" role="status">{{ batchCountMessage }}</text>
</view>
</view>
<view v-for="(box, index) in cartonBoxes" v-show="!cartonPalletFilter || String(box.transferPalletId) === String(cartonPalletFilter)" :id="`transfer-box-${index}`" :key="box.localKey || box.cartonMark" class="carton-count-card" :class="{ 'carton-selected': box.cartonMark === selectedCartonMark }">
<text class="measure-title">第 {{ index + 1 }} 箱 · {{ box.cartonMark || '新增实收箱(确认后生成箱号)' }}</text>
<view v-if="!cartonsCounted" class="batch-actions">
<button v-if="previousMatchingCarton(index)" :disabled="countingCartons || box.received === false || !validCartonValues(previousMatchingCarton(index))" @tap="copyPreviousCarton(index)">复制上一同 SKU 箱</button>
</view>
<text v-if="box.customerCartonMark" class="measure-hint">客户箱唛:{{ box.customerCartonMark }}</text>
<text class="measure-hint">SKU 与该箱申报一致,数量为箱内件数</text>
<picker :disabled="cartonsCounted || countingCartons || box.received === false" :range="generatedPallets.map(pallet => pallet.palletNo)" :value="Math.max(0, generatedPallets.findIndex(pallet => String(pallet.draftId) === String(box.transferPalletId)))" @change="assignCartonPallet(box, Number($event.detail.value))"><view class="batch-sku">所属托盘:{{ cartonPalletLabel(box) }} ▾</view></picker>
<button v-if="!cartonsCounted" class="refresh-button" :disabled="countingCartons" @tap="box.received = box.received === false">{{ box.received === false ? '标记为已收到' : '标记为未收到' }}</button>
<text v-if="box.received === false" class="measure-hint">本箱未收到,不计入实收</text>
<picker v-if="!box.cartonMark && !cartonsCounted" :range="selected.items.map(item => item.skuCode)" @change="selectExtraCartonSku(box, Number($event.detail.value))"><text>选择 SKU:{{ box.items[0].skuCode }} ▾</text></picker>
<CartonCountFields :box="box" :sku-codes="box.items.map(line => line.skuCode)" :allow-sku-change="false" :disabled="cartonsCounted || countingCartons || box.received === false" />
</view>
<button v-if="cartonsCounted" class="add-pallet-button" @tap="printCartonLabels">打开箱标签打印</button>
<button v-if="!cartonsCounted" class="add-pallet-button" :disabled="countingCartons" @tap="addExtraCarton">新增实收箱</button>
<label v-if="receivedCartonBoxes.length !== Number(selected.packageCount)"><text class="measure-hint">差异原因(必填)</text><textarea v-model="countDiscrepancyReason" :disabled="cartonsCounted || countingCartons" maxlength="1000" placeholder="填写短收或超收原因" /></label>
<button class="transfer-primary" :disabled="!cartonCountReady || countingCartons || cartonsCounted" @tap="confirmCartons">{{ cartonsCounted ? '全部箱子已清点' : countingCartons ? '保存中…' : '确认逐箱测量清点' }}</button>
</view>
2026-09-17 13:36:08 +08:00
<view class="photo-card">
2026-10-15 15:45:32 +08:00
<view class="workflow-step"><text class="step-number">3</text><view><text class="step-title">拍照并确认入库</text><text class="step-caption">核对全部托盘与签收箱子</text></view></view>
2026-09-17 13:36:08 +08:00
<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>
2026-10-15 15:45:32 +08:00
<view class="submit-panel"><view><text>整单测量汇总</text><strong>{{ pallets.length }} 托 · 打托 {{ totalCartons }} / 已清点 {{ actualReceivedCartons }} 箱</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>
2026-10-15 15:45:32 +08:00
import CartonCountFields from '@/components/CartonCountFields.vue'
2026-09-17 13:36:08 +08:00
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'
2026-10-15 15:45:32 +08:00
import { getWarehouseTransferCartonLabel, countWarehouseTransferCartons, bindWarehouseTransferPallet, deleteWarehouseTransferPallet, downloadWarehouseTransferMeasurementPhoto, downloadWarehouseTransferPalletLabel, generateWarehouseTransferPallet, getWarehouseTransferDetail, listWarehouseTransferCandidates, listWarehouseTransferMeasurementPhotos, palletizeWarehouseTransfer, saveWarehouseTransferPalletDraft, uploadWarehouseTransferMeasurementPhoto } from '@/api/warehouse-transfers.js'
2026-09-17 13:36:08 +08:00
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 })
2026-09-28 17:26:50 +08:00
const MAX_MEASUREMENT_PHOTOS = 20
2026-10-15 15:45:32 +08:00
const newBatchBox = () => ({ length: '', width: '', height: '', weight: '', items: [{ skuCode: '', quantity: '' }] })
2026-09-17 13:36:08 +08:00
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,
2026-09-24 14:43:09 +08:00
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: String(value.palletNo || ''), busy: false,
2026-09-17 13:36:08 +08:00
draftId: value.draftId || value.id, version: value.version
})
export default {
2026-10-15 15:45:32 +08:00
components: { PortalHeader, ScanTargetInput, CartonCountFields },
data() { return { receivingPalletId: '', cartonPalletFilter: '', batchPalletId: '', batchCountOpen: false, batchSku: '', batchBox: newBatchBox(), batchSelectedIndexes: [], batchCountMessage: '', cartonScrollTarget: '', cartonBoxes: [], countDiscrepancyReason: '', cartonScan: '', selectedCartonMark: '', countingCartons: false, cartonRequestKey: '', cartonSignature: '', 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-10-15 15:45:32 +08:00
generatedPallets() { return this.pallets.filter(pallet => pallet.generated && pallet.draftId) },
batchSkuCodes() { return [...new Set(this.receivedCartonBoxes.map(box => box.items[0]?.skuCode).filter(Boolean))] },
batchTargetBoxes() { return this.cartonBoxes.filter((box, index) => this.batchSelectedIndexes.includes(index) && box.received !== false && box.items[0]?.skuCode === this.batchSku) },
batchTotalQuantity() { const quantity = Number(this.batchBox.items[0].quantity); return Number.isSafeInteger(quantity) && quantity > 0 ? this.batchTargetBoxes.length * quantity : 0 },
batchCountReady() { return this.batchTargetBoxes.length > 0 && this.validCartonValues(this.batchBox) && this.generatedPallets.some(pallet => String(pallet.draftId) === String(this.batchPalletId)) },
actualReceivedCartons() { return this.cartonsCounted ? this.receivedCartonBoxes.length : 0 },
cartonsCounted() { return Boolean(this.selected?.cartons?.length) && this.selected.cartons.every(box => box.countedAt) },
cartonVolume() { return this.receivedCartonBoxes.reduce((sum, box) => sum + (Number(box.length) || 0) * (Number(box.width) || 0) * (Number(box.height) || 0) / 1000000, 0).toFixed(6) },
cartonWeight() { return this.receivedCartonBoxes.reduce((sum, box) => sum + (Number(box.weight) || 0), 0).toFixed(2) },
cartonQuantity() { return this.receivedCartonBoxes.reduce((sum, box) => sum + (Number(box.items[0]?.quantity) || 0), 0) },
receivedCartonBoxes() { return this.cartonBoxes.filter(box => box.received !== false) },
cartonCountReady() { return this.receivedCartonBoxes.length > 0 && this.pallets.every(pallet => pallet.generated && this.palletSummary(pallet).cartonCount > 0) && (this.receivedCartonBoxes.length === Number(this.selected?.packageCount) || Boolean(this.countDiscrepancyReason.trim())) && this.receivedCartonBoxes.every(box => this.generatedPallets.some(pallet => String(pallet.draftId) === String(box.transferPalletId)) && [box.length, box.width, box.height, box.weight].every(value => Number(value) >= 0.01) && box.items[0]?.skuCode && Number.isSafeInteger(Number(box.items[0].quantity)) && Number(box.items[0].quantity) > 0 && Number(box.items[0].quantity) <= 2147483647) },
2026-09-22 17:27:56 +08:00
completedDestinationLabel() { return this.destinationLabel({ fbaWarehouseCode: this.completedDestination, destinationType: this.completedDestinationType }) },
2026-10-15 15:45:32 +08:00
totalCartons() { return this.pallets.reduce((sum, pallet) => sum + this.palletSummary(pallet).cartonCount, 0) },
cartonDifference() { return this.actualReceivedCartons - Number(this.selected?.packageCount || 0) },
2026-09-24 14:20:26 +08:00
isInventoryTransfer() { return Boolean(this.selected && this.selected.status === 'WAIT_PDA_RECEIVING' && this.selected.inboundCode && this.selected.inboundCode !== this.selected.transferNo) },
isMeasurementComplete() { return Boolean(this.selected && !['WAIT_PDA_RECEIVING', 'PARTIALLY_RECEIVED'].includes(String(this.selected.status))) },
2026-10-15 15:45:32 +08:00
totalVolume() { return this.pallets.reduce((sum, pallet) => sum + this.rawPalletVolume(pallet), 0).toFixed(6) },
totalWeight() { return this.pallets.reduce((sum, pallet) => sum + this.palletSummary(pallet).weight, 0).toFixed(2) },
2026-09-17 13:36:08 +08:00
validationMessage() {
2026-09-23 09:06:31 +08:00
if (this.isInventoryTransfer) return ''
2026-10-15 15:45:32 +08:00
if (!this.cartonsCounted) return '请先完成全部箱子的尺寸测量和箱内清点'
if (this.totalCartons !== this.receivedCartonBoxes.length) return '打托箱数必须与已清点实收箱数一致'
2026-09-17 13:36:08 +08:00
if (!this.selected || !this.pallets.length) return '转运单缺少可测量的托盘'
2026-10-15 15:45:32 +08:00
if (this.pallets.some(pallet => this.palletSummary(pallet).cartonCount === 0)) return '每个托盘必须至少分配一个实收箱'
2026-09-17 13:36:08 +08:00
if (this.pallets.some((pallet) => !pallet.bound)) return '请先生成并扫描确认绑定全部托盘'
2026-09-28 17:26:50 +08:00
if (this.photoFileIds.length > MAX_MEASUREMENT_PHOTOS) return `测量现场照片最多 ${MAX_MEASUREMENT_PHOTOS} 张,请移除多余照片`
2026-09-17 13:36:08 +08:00
if (!this.photoFileIds.length) return '请至少拍摄 1 张测量现场照片'
return ''
},
canSubmit() { return !this.validationMessage }
},
watch: {
2026-10-15 15:45:32 +08:00
countDiscrepancyReason() { this.saveCartonDraft() },
cartonBoxes: { deep: true, handler() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) } },
2026-09-17 13:36:08 +08:00
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: {
2026-10-15 15:45:32 +08:00
useReceivingPallet(pallet) {
if (this.cartonsCounted || this.countingCartons || !pallet.generated) return;
this.receivingPalletId = pallet.draftId; this.batchPalletId = pallet.draftId; this.cartonPalletFilter = pallet.draftId;
this.activePalletKey = ''; this.cartonScrollTarget = 'transfer-boxes';
},
toggleBatchPanel() {
if (this.countingCartons) return;
this.batchCountOpen = !this.batchCountOpen;
if (this.batchCountOpen && !this.batchSelectedIndexes.length) this.selectBatchCartons(true);
},
validCartonValues(box) { return Boolean(box && [box.length, box.width, box.height, box.weight].every(value => Number(value) >= 0.01 && Number.isFinite(Number(value))) && Number.isSafeInteger(Number(box.items[0]?.quantity)) && Number(box.items[0].quantity) > 0 && Number(box.items[0].quantity) <= 2147483647) },
changeBatchSku(index) {
if (this.countingCartons || this.cartonsCounted) return;
this.batchSku = this.batchSkuCodes[index] || ''; this.batchSelectedIndexes = []; this.batchCountMessage = ''; this.selectBatchCartons(true);
this.batchBox = newBatchBox(); this.batchBox.items[0].skuCode = this.batchSku;
},
selectBatchCartons(selected) {
if (this.countingCartons || this.cartonsCounted) return;
this.batchSelectedIndexes = selected ? this.cartonBoxes.flatMap((box, index) => box.received !== false && box.items[0]?.skuCode === this.batchSku ? [index] : []) : [];
this.batchCountMessage = '';
},
toggleBatchCarton(index) {
if (this.countingCartons || this.cartonsCounted) return;
const box = this.cartonBoxes[index];
if (!box || box.received === false || box.items[0]?.skuCode !== this.batchSku) return;
this.batchSelectedIndexes = this.batchSelectedIndexes.includes(index) ? this.batchSelectedIndexes.filter(value => value !== index) : [...this.batchSelectedIndexes, index]; this.batchCountMessage = '';
},
copyCartonValues(source, target) {
for (const key of ['length', 'width', 'height', 'weight']) target[key] = source[key];
target.items[0].quantity = source.items[0].quantity;
},
applyBatchCartons() {
if (this.cartonsCounted || this.countingCartons || !this.batchCountReady) return;
const targets = this.batchTargetBoxes;
targets.forEach(box => { this.copyCartonValues(this.batchBox, box); box.transferPalletId = this.batchPalletId });
this.cartonPalletFilter = ''; this.saveCartonDraft(); this.batchCountMessage = `已应用到 ${targets.length} 箱,共 ${this.batchTotalQuantity} 件,可继续逐箱调整`;
},
previousMatchingCarton(index) {
const sku = this.cartonBoxes[index]?.items[0]?.skuCode;
return this.cartonBoxes.slice(0, index).reverse().find(box => box.received !== false && box.items[0]?.skuCode === sku);
},
copyPreviousCarton(index) {
if (this.cartonsCounted || this.countingCartons) return;
const source = this.previousMatchingCarton(index), target = this.cartonBoxes[index];
if (!target || target.received === false || !this.validCartonValues(source)) return;
this.copyCartonValues(source, target); this.saveCartonDraft();
},
saveCartonDraft() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) },
cartonDraftKey() { return `transfer-carton-count:${session.apiBase}:${this.user?.id}:${this.user?.warehouseId}:${this.selected?.transferNo}` },
restoreCartons() {
const rows = (this.selected?.cartons || []).map(box => ({ ...box, transferPalletId: box.transferPalletId || (this.generatedPallets.length === 1 ? this.generatedPallets[0].draftId : ''), received: box.status !== 'NOT_RECEIVED', items: [{ skuCode: this.selected.items.find(item => String(item.id) === String(box.transferItemId))?.skuCode || '', quantity: box.quantity ?? '' }] }));
const draft = uni.getStorageSync(this.cartonDraftKey());
this.countDiscrepancyReason = this.selected?.cartons?.find(box => box.discrepancyReason)?.discrepancyReason || ''; this.cartonRequestKey = ''; this.cartonSignature = ''; this.selectedCartonMark = ''; this.cartonScan = '';
if (!this.cartonsCounted && draft?.boxes?.length >= rows.length && draft.boxes.every(box => box.cartonMark || this.selected.items.some(item => String(item.id) === String(box.transferItemId))) && rows.every(box => draft.boxes.some(row => row.cartonMark === box.cartonMark && row.items?.[0]?.skuCode === box.items[0].skuCode))) {
this.cartonBoxes = draft.boxes; this.countDiscrepancyReason = draft.discrepancyReason || ''; this.cartonRequestKey = draft.key || ''; this.cartonSignature = draft.signature || '';
} else this.cartonBoxes = rows;
this.receivingPalletId = this.generatedPallets[0]?.draftId || ''; this.cartonPalletFilter = ''; this.batchPalletId = this.receivingPalletId; this.batchCountOpen = false; this.batchSelectedIndexes = []; this.batchCountMessage = ''; this.batchSku = this.batchSkuCodes[0] || ''; this.batchBox = newBatchBox(); this.batchBox.items[0].skuCode = this.batchSku;
},
async printCartonLabels() {
// #ifdef H5
const view = window.open('', '_blank');
if (!view) { this.error = new Error('请允许打开标签打印窗口'); return }
try { const label = await getWarehouseTransferCartonLabel(this.selected.transferNo); view.document.write(label.printableHtml); view.document.close(); view.focus(); view.print() }
catch (error) { view.close(); this.error = error }
// #endif
// #ifndef H5
this.error = new Error('当前设备未配置箱标签打印服务');
// #endif
},
addExtraCarton() {
if (this.cartonsCounted || this.countingCartons || !this.selected?.items?.length) return;
const item = this.selected.items[0];
this.cartonBoxes.push({ localKey: `extra-${Date.now()}-${Math.random()}`, cartonMark: '', transferItemId: item.id, transferPalletId: this.receivingPalletId || this.generatedPallets[0]?.draftId || '', received: true, length: '', width: '', height: '', weight: '', items: [{ skuCode: item.skuCode, quantity: '' }] });
},
selectExtraCartonSku(box, index) { const item = this.selected.items[index]; if (item) { box.transferItemId = item.id; box.items[0].skuCode = item.skuCode } },
locateCarton(value) {
const code = String(typeof value === 'string' ? value : this.cartonScan).trim();
const matches = this.cartonBoxes.filter(box => box.cartonMark === code || box.customerCartonMark === code);
if (matches.length !== 1) { this.error = new Error(matches.length ? '客户箱唛对应多箱,请扫描系统箱号' : '该箱不属于当前转运单'); return }
this.error = null; this.cartonPalletFilter = ''; this.selectedCartonMark = matches[0].cartonMark; this.cartonScan = '';
this.cartonScrollTarget = `transfer-box-${this.cartonBoxes.indexOf(matches[0])}`;
},
async confirmCartons() {
if (!this.cartonCountReady || this.countingCartons || this.cartonsCounted) return;
this.countingCartons = true; this.error = null;
try {
const boxes = this.receivedCartonBoxes.map(box => ({ cartonMark: box.cartonMark || null, transferItemId: box.transferItemId, transferPalletId: box.transferPalletId, length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight), skuCode: box.items[0].skuCode, quantity: Number(box.items[0].quantity) }));
const signature = JSON.stringify({ boxes, discrepancyReason: this.countDiscrepancyReason });
if (!this.cartonRequestKey || signature !== this.cartonSignature) { this.cartonRequestKey = `transfer-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.cartonSignature = signature }
uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature });
const detail = await countWarehouseTransferCartons(this.selected.transferNo, { idempotencyKey: this.cartonRequestKey, boxes, discrepancyReason: this.countDiscrepancyReason });
if (!detail.cartons?.length || !detail.cartons.every(box => box.countedAt)) throw new Error('清点结果未确认,请刷新核实');
uni.removeStorageSync(this.cartonDraftKey()); this.selected = detail; this.pallets = detail.pallets.map(restoredPallet); this.restoreCartons();
} catch (error) { this.error = error } finally { this.countingCartons = false }
},
2026-09-23 09:06:31 +08:00
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 },
2026-10-15 15:45:32 +08:00
handlePalletScan(value) { if (!this.activePalletKey && this.selected && !this.cartonsCounted) { this.locateCarton(value); return }; 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-10-15 15:45:32 +08:00
palletSummary(pallet) {
const boxes = this.receivedCartonBoxes.filter(box => pallet.draftId && String(box.transferPalletId) === String(pallet.draftId));
return { cartonCount: boxes.length, volume: boxes.reduce((sum,box) => sum + (Number(box.length)||0)*(Number(box.width)||0)*(Number(box.height)||0)/1000000,0), weight: boxes.reduce((sum,box) => sum + (Number(box.weight)||0),0) };
},
rawPalletVolume(pallet) { return this.palletSummary(pallet).volume },
palletVolume(pallet) { return this.rawPalletVolume(pallet).toFixed(6) },
cartonPalletLabel(box) { return this.generatedPallets.find(pallet => String(pallet.draftId) === String(box.transferPalletId))?.palletNo || '请选择托盘' },
assignCartonPallet(box, index) { if (!this.cartonsCounted && !this.countingCartons && box.received !== false) box.transferPalletId = this.generatedPallets[index]?.draftId || '' },
2026-09-17 13:36:08 +08:00
applyPallet(target, value) { Object.assign(target, restoredPallet(value, this.pallets.indexOf(target)), { key: target.key, busy: false, collapsed: false }) },
async generatePallet(pallet) {
2026-10-15 15:45:32 +08:00
if (!this.selected || pallet.busy || pallet.generated || this.cartonsCounted || this.countingCartons) return
2026-09-17 13:36:08 +08:00
pallet.busy = true; this.error = null
try {
2026-10-15 15:45:32 +08:00
const measured = { palletId: pallet.draftId || null, idempotencyKey: `pda-transfer-draft-${this.selected.transferNo}-${pallet.key}` }
2026-09-17 13:36:08 +08:00
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('服务端未返回新托盘号')
2026-10-15 15:45:32 +08:00
this.applyPallet(pallet, generated); this.pendingKey = ''; this.receivingPalletId = pallet.draftId; this.batchPalletId = pallet.draftId; if (this.generatedPallets.length === 1) this.receivedCartonBoxes.forEach(box => { if (!box.transferPalletId) box.transferPalletId = pallet.draftId }); this.saveCartonDraft()
2026-09-17 13:36:08 +08:00
} 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 || '')
2026-09-24 14:20:26 +08:00
const measuredStatuses = new Set(['WAIT_PDA_RECEIVING', 'PARTIALLY_RECEIVED'])
2026-09-24 11:38:51 +08:00
this.candidates = (await listWarehouseTransferCandidates()).map((item) => String(item.transferNo) === activeNo && item.status === 'WAIT_PDA_RECEIVING' ? { ...item, status: 'PARTIALLY_RECEIVED' } : item)
2026-09-24 14:20:26 +08:00
this.candidates = this.candidates.filter((item) => measuredStatuses.has(String(item.status)))
2026-09-24 11:38:51 +08:00
// 服务端已完成当前单但设备在提交响应前断网时,本地 currentNo 会遗留。
// 候选已成功读取且当前单不再可测,清掉本地锁,允许继续下一票。
if (activeNo && !this.candidates.some((item) => String(item.transferNo) === activeNo && measuredStatuses.has(String(item.status)))) {
resetWarehouseDomain('warehouseTransfer')
}
2026-09-24 14:20:26 +08:00
if (this.selected && !this.candidates.some((item) => String(item.transferNo) === String(this.selected.transferNo))) {
this.selected = null; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = []
}
2026-09-24 11:38:51 +08:00
} catch (e) { this.error = e } finally { this.loading = false }
},
async select(item, fromExplicitTask = false) {
2026-10-15 15:45:32 +08:00
if (this.detailLoading || this.submitting || this.countingCartons || this.pallets.some(pallet => pallet.busy)) 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-10-15 15:45:32 +08:00
try { this.selected = await getWarehouseTransferDetail(item.transferNo); this.pendingKey = ''; this.pallets = this.selected.pallets?.length ? this.selected.pallets.map(restoredPallet) : [newPallet()]; this.restoreCartons(); await this.restorePhotos(item.transferNo, this.selected.measurementPhotos) }
2026-09-17 13:36:08 +08:00
catch (e) { this.selected = null; this.error = e }
finally { this.detailLoading = false }
},
2026-10-15 15:45:32 +08:00
backToCandidates() { if (this.submitting || this.detailLoading || this.countingCartons || this.pallets.some(pallet => pallet.busy)) return; this.entryMode = 'list'; this.selected = null; this.error = null; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = [] },
addPallet() { if (this.cartonsCounted || this.countingCartons) return; this.pendingKey = ''; this.pallets.push(newPallet()) },
2026-09-17 13:36:08 +08:00
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) {
2026-09-28 17:26:50 +08:00
let photos
2026-09-17 13:36:08 +08:00
try {
2026-09-28 17:26:50 +08:00
// Detail responses contain submitted photos only. The PDA list endpoint
// is the authoritative source for uploads that have not been submitted.
photos = await listWarehouseTransferMeasurementPhotos(transferNo)
} catch (error) {
if (!Array.isArray(knownPhotos)) { this.error = error; return false }
photos = knownPhotos
}
const nextUrls = {}
await Promise.all(photos.map(async (photo) => { try { nextUrls[String(photo.fileId)] = await downloadWarehouseTransferMeasurementPhoto(transferNo, photo.fileId) } catch (_) { /* 单张恢复失败不阻断补拍 */ } }))
this.photoFileIds = photos.map((photo) => String(photo.fileId)); this.photoNames = photos.map((photo) => photo.fileName || '测量现场照片'); this.photoUrls = nextUrls
return true
2026-09-17 13:36:08 +08:00
},
takePhoto() {
2026-09-28 17:26:50 +08:00
if (!this.selected || this.uploadingPhoto || this.submitting || this.photoFileIds.length >= MAX_MEASUREMENT_PHOTOS) {
if (this.photoFileIds.length >= MAX_MEASUREMENT_PHOTOS) this.error = new Error(`测量现场照片最多 ${MAX_MEASUREMENT_PHOTOS} 张`)
return
}
2026-09-17 13:36:08 +08:00
const transferNo = this.selected.transferNo
2026-09-28 17:26:50 +08:00
uni.chooseImage({ count: Math.min(9, MAX_MEASUREMENT_PHOTOS - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => {
2026-09-17 13:36:08 +08:00
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 }
2026-09-28 17:26:50 +08:00
const remaining = MAX_MEASUREMENT_PHOTOS - this.photoFileIds.length
const uploadPaths = paths.slice(0, remaining)
const skipped = paths.length - uploadPaths.length
2026-09-17 13:36:08 +08:00
this.uploadingPhoto = true; this.error = null
try {
2026-09-28 17:26:50 +08:00
for (const [index, path] of uploadPaths.entries()) {
if (this.photoFileIds.length >= MAX_MEASUREMENT_PHOTOS) throw new Error(`测量现场照片最多 ${MAX_MEASUREMENT_PHOTOS} 张`)
2026-09-17 13:36:08 +08:00
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 || '测量现场照片')
}
2026-09-28 17:26:50 +08:00
if (skipped > 0) this.error = new Error(`最多上传 ${MAX_MEASUREMENT_PHOTOS} 张测量现场照片,已忽略超出的 ${skipped} 张`)
2026-09-17 13:36:08 +08:00
this.pendingKey = ''
2026-09-28 17:26:50 +08:00
} catch (error) {
this.error = error
// Another window/device may have filled the remaining slots. Re-read
// server state so a refresh cannot reset the UI back to 0/20.
if (/at most 20|最多.?20|supports at most/i.test(String(error?.message || ''))) {
await this.restorePhotos(transferNo)
this.error = error
}
} finally { this.uploadingPhoto = false }
2026-09-17 13:36:08 +08:00
}, 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>
2026-10-15 15:45:32 +08:00
.workflow-step{display:flex;align-items:center;gap:10px;margin:18px 0 12px}.step-number{display:flex;align-items:center;justify-content:center;flex:none;width:28px;height:28px;border-radius:9px;background:#e5efff;color:#135ee8;font-weight:800;font-size:14px}.step-title,.step-caption{display:block}.step-title{font-size:16px;font-weight:800;color:#17314f}.step-caption{margin-top:3px;font-size:11px;color:#7a899c;line-height:17px}.active-receiving-pallet{border-color:#79a7ec!important}.pallet-brief{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin:-3px 0 12px;color:#3f5671;font-size:12px}.pallet-brief button{margin:0 0 0 auto;padding:0 10px;height:36px;line-height:36px;border-radius:8px;background:#eaf2ff;color:#135ee8;font-size:11px}.carton-filter,.batch-box-options{display:flex;flex-wrap:wrap;gap:7px;margin:12px 0}.carton-filter button,.batch-box-options button{width:auto;margin:0;padding:0 11px;height:36px;line-height:36px;border:1px solid #d4dfed;border-radius:9px;background:#fff;color:#5b6e84;font-size:12px}.carton-filter .active,.batch-box-options .chosen{border-color:#135ee8;background:#eaf2ff;color:#135ee8}.batch-count-panel .add-pallet-button{margin:0}.carton-count-card>.measure-title{overflow-wrap:anywhere;font-size:12px;line-height:19px}.batch-sku{padding:10px;border:1px solid #cbdcf2;border-radius:8px;background:#fff}.summary-no{overflow-wrap:anywhere}.photo-card>.workflow-step{margin-top:0}
.batch-count-panel{margin:12px 0;padding:10px;border:1px solid #b9d3f4;border-radius:10px;background:#f4f8ff}.batch-sku{margin:10px 0;color:#17314f;font-size:12px;word-break:break-all}.batch-actions{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}.batch-actions button{width:auto;margin:0;padding:0 10px;min-height:34px;line-height:34px;font-size:11px;color:#135ee8;border:1px solid #b9d3f4;border-radius:7px;background:#fff}.batch-actions button[disabled]{opacity:.45}.batch-summary{display:block;margin:10px 0;color:#245981;font-size:11px;line-height:18px}
.carton-count-card{padding:14px 10px;margin:10px 0;border:1px solid #d8e3f1;border-radius:12px}.carton-selected{border-color:#135ee8;background:#f0f6ff}
2026-09-17 13:36:08 +08:00
.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}
.generated-card,.print-row{gap:10px}.generated-card>view,.print-row>view{flex:1;min-width:0;overflow-wrap:anywhere}.generated-card button,.print-row button{flex-shrink:0;white-space:nowrap;padding:0 10px}
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}
2026-10-15 15:45:32 +08:00
.transfer-content .measure-hint{font-size:11px;line-height:17px}.transfer-content .measure-title{font-size:13px}.transfer-content .summary-metrics text{font-size:10px}.transfer-content .transfer-primary{height:46px;line-height:46px;font-size:14px}.transfer-content .photo-card{padding:14px}.transfer-content .pallet-heading-copy text:last-child{font-size:10px;line-height:16px}.transfer-content .summary-no{font-size:13px}.transfer-content .batch-summary{font-size:12px}
2026-09-17 13:36:08 +08:00
</style>