Files
pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue
T
2026-11-04 19:27:34 +08:00

669 lines
82 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view v-if="user" class="page-shell portal-page transfer-page" :class="{ 'transfer-list-page': entryMode === 'list' }">
<PortalHeader :title="entryMode === 'list' ? '仓储转运测量' : '转运测量作业'" :user="user" />
<view v-if="entryMode === 'work'" class="transfer-work-toolbar">
<button class="back-to-list" @tap="backToCandidates"><uni-icons type="back" color="#135ee8" size="18" /><text>返回列表</text></button>
<button class="refresh-button" :disabled="loading" aria-label="刷新当前转运单" @tap="refreshCurrentTransfer"><uni-icons type="refresh" color="#135ee8" size="18" /><text>{{ loading ? '加载中' : '刷新' }}</text></button>
</view>
<scroll-view scroll-y :scroll-into-view="cartonScrollTarget" scroll-with-animation class="transfer-scroll"><view class="portal-content transfer-content">
<view v-if="entryMode === 'list'" class="transfer-heading">
<text class="transfer-list-count">待测转运单 · {{ candidates.length }} 单</text>
<button class="refresh-button" :disabled="loading" aria-label="刷新待测转运单" @tap="loadCandidates"><uni-icons type="refresh" color="#135ee8" size="18" /><text>{{ loading ? '刷新中…' : '刷新' }}</text></button>
</view>
<template v-if="!selected && entryMode === 'list'">
<view v-if="loading && !candidates.length" class="transfer-empty"><uni-load-more status="loading" /><text>正在加载待测转运单…</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" :aria-label="`${item.transferNo},${statusLabel(item.status, item.transferNo)}`" hover-class="transfer-card-pressed" @tap="select(item)">
<view class="transfer-card-head"><text class="transfer-no">{{ item.transferNo }}</text><text class="status-pill" :class="{ 'status-measuring': item.status === 'PARTIALLY_RECEIVED' }">{{ statusLabel(item.status, item.transferNo) }}</text></view>
<view class="task-metrics"><view><strong>{{ item.packageCount || 0 }}</strong><text>计划箱数</text></view><view><strong>{{ destinationLabel(item) }}</strong><text>{{ item.destinationType === 'PRIVATE' ? '目的仓' : 'FBA 目的仓' }}</text></view><view><strong>{{ item.needRelabel ? '需要' : '不需要' }}</strong><text>换标</text></view></view>
<view class="transfer-card-footer"><text v-if="listInboundCode(item)" class="transfer-meta">入仓码 {{ listInboundCode(item) }}</text><view class="transfer-card-action"><text>{{ item.status === 'PARTIALLY_RECEIVED' ? '继续测量' : '测量' }}</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
</button>
</view>
</template>
<view v-if="!selected && entryMode === 'work' && !detailLoading" class="transfer-empty"><text class="empty-title">暂无进行中的转运测量作业</text><text>请先从“仓储转运测量”选择待测转运单。</text></view>
<view v-if="detailLoading" class="transfer-empty"><text class="empty-title">正在读取转运明细</text><text>请稍候,不要重复选择</text></view>
<template v-if="selected && !detailLoading">
<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">
<view class="order-summary" @tap="summaryCollapsed = !summaryCollapsed" role="button" :aria-expanded="!summaryCollapsed" aria-label="展开或收起任务汇总">
<view><text class="summary-eyebrow">当前测量任务</text><text class="summary-no">{{ selected.transferNo }}</text></view>
<view class="summary-count"><strong>{{ cartonsCounted ? actualReceivedCartons : receivedCartonBoxes.length }}</strong><text>{{ cartonsCounted ? '实收箱' : '待确认箱' }}</text></view>
<text class="summary-toggle">{{ summaryCollapsed ? '展开 ⌄' : '收起 ⌃' }}</text>
</view>
<view v-show="!summaryCollapsed" class="receipt-comparison"><view><strong>{{ forecastSkuQuantity }}</strong><text>客户预报(件)</text></view><view><strong>{{ cartonsCounted ? cartonQuantity : '—' }}</strong><text>SKU实收(件)</text></view><view :class="{ difference: cartonsCounted && hasSkuReceiptDifference }"><strong>{{ !cartonsCounted ? '—' : hasSkuReceiptDifference ? skuReceiptDifferences.length + ' 种' : '无差异' }}</strong><text>{{ !cartonsCounted ? '差异待确认' : 'SKU签收差异' }}</text></view></view>
<view v-show="!summaryCollapsed" 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" :class="{ 'active-receiving-pallet': String(receivingPalletId) === String(pallet.draftId) }">
<view class="pallet-head">
<view class="pallet-index"><text>{{ String(index + 1).padStart(2, '0') }}</text></view>
<view class="pallet-heading-copy"><view class="pallet-title-line"><text class="pallet-name">托盘 {{ index + 1 }}</text><text class="pallet-status" :class="{ 'is-bound': pallet.bound }">{{ pallet.bound ? '已绑定' : pallet.generated ? '待绑定' : '待创建' }}</text></view></view>
<button class="remove-button" aria-label="删除托盘" :disabled="countingCartons || submitting || pallets.some(item => item.busy)" @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-if="pallet.generated" class="pallet-stats"><view><strong>{{ palletSummary(pallet).cartonCount }}<text> 箱</text></strong><text>实收箱数</text></view><view><strong>{{ palletVolume(pallet) }}<text> m³</text></strong><text>体积</text></view><view><strong>{{ palletSummary(pallet).weight.toFixed(2) }}<text> kg</text></strong><text>重量</text></view></view>
<view v-if="pallet.generated" class="pallet-brief"><text class="pallet-business-code" selectable>{{ pallet.palletNo }}</text><text v-if="String(receivingPalletId) === String(pallet.draftId)" class="receiving-badge">当前签收</text><button v-else :disabled="cartonsCounted || countingCartons" @tap="useReceivingPallet(pallet)">设为签收托盘</button></view>
<view v-show="!pallet.collapsed">
<view class="measure-section-head"><view><text class="measure-title">本托箱子汇总</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>
<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>
<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" :disabled="cartonsCounted || countingCartons" @tap="addPallet"><uni-icons type="plus" color="#135ee8" size="18" /><text>新增一个托盘</text></button>
<text v-if="!generatedPallets.length" class="carton-prerequisite">请先生成托盘号,再填写签收箱子。</text>
<view v-if="generatedPallets.length" class="photo-card">
<view class="photo-head"><text id="transfer-boxes" class="measure-title">签收箱子</text><button v-if="cartonsCounted" class="remeasure-button" :disabled="countingCartons || submitting || pallets.some(item => item.busy)" @tap="remeasure">重新测量</button></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>
<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.length === 1 && 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" volume-unit="m3" :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-collapsed': cartonConfirmed(box) }">
<view v-if="cartonConfirmed(box)" class="collapsed-carton-row"><scroll-view class="collapsed-carton-mark" scroll-x><text @tap="editCarton(box)">{{ box.cartonMark || '新增实收箱' }}</text></scroll-view><button class="collapsed-carton-expand" aria-label="展开修改箱子" :disabled="countingCartons || submitting" @tap="editCarton(box)"><uni-icons type="down" color="#7185a0" size="16" /></button></view>
<template v-else>
<view class="carton-title-row"><text class="measure-title">第 {{ index + 1 }} 箱</text><view class="carton-icon-actions"><button v-if="!cartonsCounted" class="delete-carton-button" aria-label="删除箱子" :disabled="countingCartons" @tap="removeCarton(index)">×</button></view></view>
<text class="carton-business-code" selectable>{{ 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>
<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>
<CartonCountFields :box="box" :sku-codes="declaredSkuCodes" :unique-skus="true" volume-unit="m3" :disabled="cartonsCounted || countingCartons || box.received === false"><template #sku-action><button v-if="!cartonsCounted && declaredSkuCodes.length > 1" class="add-sku-button" aria-label="添加 SKU" :disabled="countingCartons || box.received === false || box.items.length >= Math.min(200, declaredSkuCodes.length)" @tap="addCartonSku(box)">+</button></template></CartonCountFields>
<button class="confirm-carton-button" :disabled="countingCartons || submitting || !validCartonValues(box) || !generatedPallets.some(pallet => String(pallet.draftId) === String(box.transferPalletId))" @tap="confirmSingleCarton(box)">确认本箱</button>
</template>
</view>
<button v-if="cartonsCounted" class="add-pallet-button" @tap="printCartonLabels">打印唛头</button>
<button v-if="restorableCartons.length" class="add-pallet-button" :disabled="countingCartons || submitting || deletingCarton" @tap="restoreDeletedCarton">恢复已删除箱子({{ restorableCartons.length }})</button>
<button class="transfer-primary" :disabled="!allCartonsConfirmed || !cartonCountReady || countingCartons || cartonsCounted" @tap="confirmCartons">{{ cartonsCounted ? '全部箱子已清点' : countingCartons ? '保存中…' : '保存全部清点结果' }}</button>
</view>
<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 }} / 已清点 {{ actualReceivedCartons }} 箱</strong></view><button class="transfer-primary" :disabled="submitting || !canSubmit" @tap="submit">{{ submitting ? '提交中…' : '完成测量并打托' }}</button></view>
</template>
</template>
<view v-if="error" class="transfer-error" role="alert"><text class="feedback-title">操作失败</text><text>{{ simpleErrorMessage(error) }}</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">
<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>
</view>
</view></scroll-view>
</view>
</template>
<script>
import CartonCountFields from '@/components/CartonCountFields.vue'
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { session, requireSession } from '@/stores/session.js'
import { restoreWarehouseWork, resetWarehouseDomain, updateWarehouseWork, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { getWarehouseTransferCartonLabel, countWarehouseTransferCartons, reopenWarehouseTransferMeasurement, 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 MAX_MEASUREMENT_PHOTOS = 20
const newBatchBox = () => ({ length: '', width: '', height: '', weight: '', items: [{ skuCode: '', quantity: '' }] })
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: String(value.palletNo || ''), busy: false,
draftId: value.draftId || value.id, version: value.version
})
export default {
components: { PortalHeader, ScanTargetInput, CartonCountFields },
data() { return { deletedCartonBoxes: [], deletingCarton: false, summaryCollapsed: false, receivingPalletId: '', cartonPalletFilter: '', batchPalletId: '', batchCountOpen: false, batchSku: '', batchBox: newBatchBox(), batchSelectedIndexes: [], batchCountMessage: '', cartonScrollTarget: '', cartonBoxes: [], countDiscrepancyReason: '', 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: '' } },
computed: {
restorableCartons() {
const current = new Set(this.cartonBoxes.map(box => box.cartonMark || box.localKey));
const removed = new Map();
for (const box of [...(this.selected?.cartons || []), ...this.deletedCartonBoxes]) {
const key = box.cartonMark || box.localKey;
if (key && !current.has(key)) removed.set(key, box);
}
return [...removed.values()];
},
generatedPallets() { return this.pallets.filter(pallet => pallet.generated && pallet.draftId) },
declaredSkuCodes() { return [...new Set((this.selected?.items || []).map(item => item.skuCode))] },
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.length === 1 && 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)) },
// 确认快照与当前内容一致,才允许保存整单清点结果。
// 按 SKU 分别核对件数,不能让不同 SKU 的短收与超收相互抵消。
forecastSkuQuantity() { return (this.selected?.items || []).reduce((sum, item) => sum + Number(item.quantity || 0), 0) },
skuReceiptDifferences() {
const expected = new Map(), received = new Map()
for (const item of this.selected?.items || []) expected.set(item.skuCode, (expected.get(item.skuCode) || 0) + Number(item.quantity || 0))
for (const box of this.receivedCartonBoxes) for (const line of box.items) received.set(line.skuCode, (received.get(line.skuCode) || 0) + Number(line.quantity || 0))
return [...new Set([...expected.keys(), ...received.keys()])].filter(sku => (received.get(sku) || 0) !== (expected.get(sku) || 0))
},
hasSkuReceiptDifference() { return this.skuReceiptDifferences.length > 0 },
allCartonsConfirmed() { return this.receivedCartonBoxes.length > 0 && this.receivedCartonBoxes.every(box => this.cartonConfirmed(box)) },
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(3) },
cartonWeight() { return this.receivedCartonBoxes.reduce((sum, box) => sum + (Number(box.weight) || 0), 0).toFixed(2) },
cartonQuantity() { return this.receivedCartonBoxes.reduce((sum, box) => sum + box.items.reduce((total, line) => total + (Number(line.quantity) || 0), 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.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) && this.validCartonSkuLines(box)) },
completedDestinationLabel() { return this.destinationLabel({ fbaWarehouseCode: this.completedDestination, destinationType: this.completedDestinationType }) },
totalCartons() { return this.pallets.reduce((sum, pallet) => sum + this.palletSummary(pallet).cartonCount, 0) },
cartonDifference() { return this.actualReceivedCartons - Number(this.selected?.packageCount || 0) },
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))) },
totalVolume() { return this.pallets.reduce((sum, pallet) => sum + this.rawPalletVolume(pallet), 0).toFixed(3) },
totalWeight() { return this.pallets.reduce((sum, pallet) => sum + this.palletSummary(pallet).weight, 0).toFixed(2) },
validationMessage() {
if (this.isInventoryTransfer) return ''
if (!this.cartonsCounted) return '请先完成全部箱子的尺寸测量和箱内清点'
if (this.totalCartons !== this.receivedCartonBoxes.length) return '打托箱数必须与已清点实收箱数一致'
if (!this.selected || !this.pallets.length) return '转运单缺少可测量的托盘'
if (this.pallets.some(pallet => this.palletSummary(pallet).cartonCount === 0)) return '每个托盘必须至少分配一个实收箱'
if (this.pallets.some((pallet) => !pallet.bound)) return '请先生成并扫描确认绑定全部托盘'
if (this.photoFileIds.length > MAX_MEASUREMENT_PHOTOS) return `测量现场照片最多 ${MAX_MEASUREMENT_PHOTOS} 张,请移除多余照片`
if (!this.photoFileIds.length) return '请至少拍摄 1 张测量现场照片'
return ''
},
canSubmit() { return !this.validationMessage }
},
watch: {
countDiscrepancyReason() { this.saveCartonDraft() },
cartonBoxes: { deep: true, handler() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason }) } },
pallets: { deep: true, handler() { if (!this.submitting) this.pendingKey = '' } }
},
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) } } }) } },
onLoad(options) { this.entryMode = options?.mode === 'list' ? 'list' : 'work'; this.pendingTaskNo = String(options?.task || '') },
onHide() { this.stopScanner() },
beforeUnmount() { this.stopScanner() },
methods: {
simpleErrorMessage(error) { return String(error?.message || error || '操作失败,请重试').replace(/[((]\s*(?:错误码|code|requestId|traceId)\s*[::=][\s\S]*$/i, '').replace(/(?:requestId|traceId)\s*[::=][\s\S]*$/i, '').trim() || '操作失败,请重试' },
// 快照只包含业务数据;复制、批量修改或改托后会自动失效并重新展开。
cartonConfirmationSignature(box) {
return JSON.stringify({ pallet: String(box.transferPalletId || ''), dimensions: [box.length, box.width, box.height, box.weight].map(Number), items: box.items.map(line => [line.skuCode, Number(line.quantity)]) })
},
cartonConfirmed(box) { return Boolean(box.confirmedSignature && box.confirmedSignature === this.cartonConfirmationSignature(box)) },
// 单箱先保存设备草稿,最后一箱确认后统一提交后端已有的整单清点事务。
async confirmSingleCarton(box) {
if (this.countingCartons || this.submitting || this.cartonsCounted || !this.validCartonValues(box) || !this.generatedPallets.some(pallet => String(pallet.draftId) === String(box.transferPalletId))) return
box.confirmedSignature = this.cartonConfirmationSignature(box)
this.error = null; this.saveCartonDraft()
if (this.allCartonsConfirmed && this.cartonCountReady) await this.confirmCartons()
},
// 已保存清点时先请求服务端重开;成功后只展开当前箱,其他箱继续保留确认状态。
async editCarton(box) {
if (this.countingCartons || this.submitting || this.pallets.some(pallet => pallet.busy)) return
if (!this.cartonsCounted) { box.confirmedSignature = ''; this.saveCartonDraft(); return }
const mark = box.cartonMark
this.countingCartons = true; this.error = null
try {
const detail = await reopenWarehouseTransferMeasurement(this.selected.transferNo)
uni.setStorageSync(this.cartonDraftKey(), { deletedBoxes: this.deletedCartonBoxes }); this.selected = detail; this.restoreCartons()
this.cartonBoxes.forEach(current => { current.confirmedSignature = current.cartonMark === mark ? '' : this.cartonConfirmationSignature(current) })
this.pendingKey = ''; this.saveCartonDraft()
} catch (error) { this.error = error } finally { this.countingCartons = false }
},
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))) && this.validCartonSkuLines(box)) },
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.length === 1 && 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.length !== 1 || 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 = source.items.map(line => ({ ...line }));
},
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) {
if (this.cartonBoxes[index]?.items.length !== 1) return null;
const sku = this.cartonBoxes[index]?.items[0]?.skuCode;
return this.cartonBoxes.slice(0, index).reverse().find(box => box.received !== false && box.items.length === 1 && 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, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason }) },
cartonDraftKey() { return `transfer-carton-count:${session.apiBase}:${this.user?.id}:${this.user?.warehouseId}:${this.selected?.transferNo}` },
restoreCartons() {
const rows = (this.selected?.cartons || []).filter(box => box.status !== 'NOT_RECEIVED').map(box => ({ ...box, transferPalletId: box.transferPalletId || (this.generatedPallets.length === 1 ? this.generatedPallets[0].draftId : ''), received: box.status !== 'NOT_RECEIVED', items: box.items?.length ? box.items.map(line => ({ skuCode: line.skuCode, quantity: line.quantity })) : [{ skuCode: this.selected.items.find(item => String(item.id) === String(box.transferItemId))?.skuCode || this.declaredSkuCodes[0] || '', quantity: box.quantity ?? '' }] }));
const draft = uni.getStorageSync(this.cartonDraftKey());
this.deletedCartonBoxes = Array.isArray(draft?.deletedBoxes) ? draft.deletedBoxes.filter(box => !box.cartonMark || (this.selected?.cartons || []).some(row => row.cartonMark === box.cartonMark)) : [];
this.countDiscrepancyReason = this.selected?.cartons?.find(box => box.discrepancyReason)?.discrepancyReason || ''; this.cartonRequestKey = ''; this.cartonSignature = '';
if (!this.cartonsCounted && Array.isArray(draft?.boxes) && draft.boxes.every(box => box.items?.length && box.items.every(line => this.declaredSkuCodes.includes(line.skuCode)) && (box.cartonMark || this.selected.items.some(item => String(item.id) === String(box.transferItemId)))) && draft.boxes.every(box => !box.cartonMark || (this.selected?.cartons || []).some(row => row.cartonMark === box.cartonMark))) {
const knownMarks = new Set(draft.forecastMarks || draft.boxes.map(box => box.cartonMark));
this.cartonBoxes = [...draft.boxes, ...rows.filter(box => !knownMarks.has(box.cartonMark))]; this.countDiscrepancyReason = draft.discrepancyReason || ''; this.cartonRequestKey = ''; this.cartonSignature = '';
} else this.cartonBoxes = rows;
this.cartonBoxes.forEach(box => { if (this.cartonsCounted || (box.status === 'COUNTED' && this.validCartonValues(box))) box.confirmedSignature = this.cartonConfirmationSignature(box) });
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
},
async removeCarton(index) {
if (this.cartonsCounted || this.countingCartons || this.submitting || this.deletingCarton) return;
const box = this.cartonBoxes[index], transferNo = this.selected?.transferNo;
if (!box) return;
this.deletingCarton = true;
try {
const answer = await new Promise((resolve, reject) => uni.showModal({ title: '确认删除箱子', content: `确定删除${box.cartonMark || `第 ${index + 1} 箱`}吗?删除后本箱不计入本次实收,保存清点时会记为未收到。误删可通过“恢复已删除箱子”找回。`, confirmText: '确认删除', confirmColor: '#b74545', cancelText: '取消', success: resolve, fail: reject }));
if (!answer.confirm || transferNo !== this.selected?.transferNo || this.cartonsCounted || this.countingCartons || this.submitting) return;
const position = this.cartonBoxes.indexOf(box);
if (position < 0) return;
if (!box.cartonMark && !box.localKey) box.localKey = `removed-${Date.now()}-${Math.random()}`;
this.deletedCartonBoxes.push(box); this.cartonBoxes.splice(position, 1);
this.batchSelectedIndexes = []; this.batchCountMessage = ''; this.saveCartonDraft();
} catch (error) { this.error = error } finally { this.deletingCarton = false }
},
async restoreDeletedCarton() {
if (this.countingCartons || this.submitting || this.deletingCarton || !this.restorableCartons.length) return;
const candidates = this.restorableCartons, transferNo = this.selected?.transferNo;
try {
const choice = candidates.length === 1 ? 0 : await new Promise((resolve, reject) => uni.showActionSheet({ itemList: candidates.map((box, index) => box.cartonMark || `已删除实收箱 ${index + 1}`), success: result => resolve(result.tapIndex), fail: () => resolve(-1) }));
if (choice < 0 || transferNo !== this.selected?.transferNo) return;
const removed = candidates[choice], key = removed.cartonMark || removed.localKey;
if (this.cartonsCounted) { await this.remeasure(); if (this.cartonsCounted || transferNo !== this.selected?.transferNo) return }
if (this.cartonBoxes.some(box => (box.cartonMark || box.localKey) === key)) return;
const box = { ...removed, received: true, status: 'EXPECTED', countedAt: null, confirmedSignature: '',
items: removed.items?.length ? removed.items.map(line => ({ ...line })) : [{ skuCode: this.selected.items.find(item => String(item.id) === String(removed.transferItemId))?.skuCode || this.declaredSkuCodes[0] || '', quantity: removed.quantity ?? '' }] };
if (!this.generatedPallets.some(pallet => String(pallet.draftId) === String(box.transferPalletId))) box.transferPalletId = this.receivingPalletId || this.generatedPallets[0]?.draftId || '';
this.deletedCartonBoxes = this.deletedCartonBoxes.filter(item => (item.cartonMark || item.localKey) !== key);
this.cartonBoxes.push(box); this.cartonPalletFilter = ''; this.cartonRequestKey = ''; this.cartonSignature = ''; this.saveCartonDraft();
} catch (error) { this.error = error }
},
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: '' }] });
},
addCartonSku(box) {
if (this.cartonsCounted || this.countingCartons || box.received === false) return;
const skuCode = this.declaredSkuCodes.find(sku => !box.items.some(line => line.skuCode === sku));
if (skuCode) box.items.push({ skuCode, quantity: '' });
},
validCartonSkuLines(box) {
return box.items.length > 0 && box.items.length <= 200 && new Set(box.items.map(line => line.skuCode)).size === box.items.length
&& box.items.every(line => this.declaredSkuCodes.includes(line.skuCode) && Number.isSafeInteger(Number(line.quantity)) && Number(line.quantity) > 0 && Number(line.quantity) <= 2147483647);
},
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.cartonMark ? box.transferItemId : this.selected.items.find(item => item.skuCode === box.items[0]?.skuCode)?.id, transferPalletId: box.transferPalletId, length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight), items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) }));
const signature = JSON.stringify({ boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
if (!this.cartonRequestKey || this.cartonSignature !== signature) {
this.cartonRequestKey = `transfer-count-${Date.now()}-${Math.random().toString(36).slice(2)}`
this.cartonSignature = signature
}
uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason });
const detail = await countWarehouseTransferCartons(this.selected.transferNo, { idempotencyKey: this.cartonRequestKey, boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
if (!detail.cartons?.length || !detail.cartons.every(box => box.countedAt)) throw new Error('清点结果未确认,请刷新核实');
uni.setStorageSync(this.cartonDraftKey(), { deletedBoxes: this.deletedCartonBoxes }); this.selected = detail; this.pallets = detail.pallets.map(restoredPallet); this.restoreCartons();
} catch (error) { this.error = error } finally { this.countingCartons = false }
},
openPalletOperations() { uni.navigateTo({ url: '/pages/pallet/pallet' }) },
destinationLabel(value, fallback = {}) { const code = String(value?.fbaWarehouseCode || fallback?.fbaWarehouseCode || '').trim(); if (code) return code; const type = value?.destinationType || fallback?.destinationType; return type === 'PRIVATE' ? '私人仓' : '目的仓待定' },
listInboundCode(item) { const code = String(item?.inboundCode || '').trim(); return code !== String(item?.transferNo || '').trim() ? code : '' },
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) },
statusLabel(status, transferNo = '') { if (String(status) === 'WAIT_PDA_RECEIVING' && String(warehouseWork.warehouseTransfer.currentNo) === String(transferNo)) return '测量中'; return ({ WAIT_PDA_RECEIVING: '待测量', APPROVED: '待测量', PARTIALLY_RECEIVED: '测量中' })[status] || '待测量' },
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 Number(this.palletSummary(pallet).volume.toFixed(3)) },
palletVolume(pallet) { return this.rawPalletVolume(pallet).toFixed(3) },
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 || '' },
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.cartonsCounted || this.countingCartons) return
pallet.busy = true; this.error = null
try {
const measured = { palletId: pallet.draftId || null, idempotencyKey: `pda-transfer-draft-${this.selected.transferNo}-${pallet.key}` }
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 = ''; 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()
} 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)) },
// 刷新本单时合并预报新增箱号,保留本机已填写的测量草稿。
async refreshCurrentTransfer() {
if (!this.selected || this.loading || this.submitting || this.countingCartons || this.pallets.some(pallet => pallet.busy)) return
this.saveCartonDraft(); this.loading = true; this.error = null
try {
const detail = await getWarehouseTransferDetail(this.selected.transferNo)
this.selected = detail; this.pallets = detail.pallets?.length ? detail.pallets.map(restoredPallet) : [newPallet()]
this.restoreCartons(); this.pendingKey = ''; await this.restorePhotos(detail.transferNo, detail.measurementPhotos)
} catch (error) { this.error = error } finally { this.loading = false }
},
async loadCandidates() {
this.loading = true; this.error = null
try {
const activeNo = String(warehouseWork.warehouseTransfer.currentNo || '')
const measuredStatuses = new Set(['WAIT_PDA_RECEIVING', 'PARTIALLY_RECEIVED'])
this.candidates = (await listWarehouseTransferCandidates()).map((item) => String(item.transferNo) === activeNo && item.status === 'WAIT_PDA_RECEIVING' ? { ...item, status: 'PARTIALLY_RECEIVED' } : item)
this.candidates = this.candidates.filter((item) => measuredStatuses.has(String(item.status)))
// 服务端已完成当前单但设备在提交响应前断网时,本地 currentNo 会遗留。
// 候选已成功读取且当前单不再可测,清掉本地锁,允许继续下一票。
if (activeNo && !this.candidates.some((item) => String(item.transferNo) === activeNo && measuredStatuses.has(String(item.status)))) {
resetWarehouseDomain('warehouseTransfer')
}
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 = []
}
} catch (e) { this.error = e } finally { this.loading = false }
},
async select(item, fromExplicitTask = false) {
if (this.detailLoading || this.submitting || this.countingCartons || this.pallets.some(pallet => pallet.busy)) return
const requestedNo = String(item?.transferNo || '')
const activeNo = String(warehouseWork.warehouseTransfer.currentNo || '')
if (!activeNo && !fromExplicitTask) {
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 }
this.detailLoading = true; this.error = null; this.success = false; this.completedPallets = []; this.completedTransferNo = ''; this.completedDestination = ''; this.completedDestinationType = ''
try { this.selected = await getWarehouseTransferDetail(item.transferNo); this.summaryCollapsed = false; this.pendingKey = ''; this.pallets = this.selected.pallets?.length ? this.selected.pallets.map(restoredPallet) : [newPallet()]; this.restoreCartons(); await this.restorePhotos(item.transferNo, this.selected.measurementPhotos) }
catch (e) { this.selected = null; this.error = e }
finally { this.detailLoading = false }
},
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()) },
async remeasure() {
if (!this.selected || this.countingCartons || this.submitting || this.pallets.some(pallet => pallet.busy)) return;
this.countingCartons = true; this.error = null;
try {
const detail = await reopenWarehouseTransferMeasurement(this.selected.transferNo);
uni.setStorageSync(this.cartonDraftKey(), { deletedBoxes: this.deletedCartonBoxes }); this.selected = detail; this.restoreCartons(); this.pendingKey = '';
this.cartonScrollTarget = 'transfer-boxes';
} catch (error) { this.error = error } finally { this.countingCartons = false }
},
async removePallet(index) {
const pallet = this.pallets[index];
if (!pallet || this.countingCartons || this.submitting || this.pallets.some(item => item.busy)) return;
pallet.busy = true;
try {
const answer = await new Promise((resolve, reject) => uni.showModal({ title: '删除托盘', content: '删除后保留箱子和测量数据,本托箱子需重新选择托盘并确认清点。是否继续?', success: resolve, fail: reject }));
if (!answer.confirm) return;
this.error = null;
if (pallet.draftId) this.selected = await deleteWarehouseTransferPallet(this.selected.transferNo, pallet.draftId);
this.cartonBoxes.forEach(box => { if (pallet.draftId && String(box.transferPalletId) === String(pallet.draftId)) box.transferPalletId = '' });
this.pallets.splice(index, 1); if (!this.pallets.length) this.pallets = [newPallet()];
this.receivingPalletId = this.generatedPallets[0]?.draftId || ''; this.batchPalletId = this.receivingPalletId; this.cartonPalletFilter = ''; this.activePalletKey = '';
this.cartonRequestKey = ''; this.cartonSignature = ''; this.pendingKey = ''; this.batchSelectedIndexes = []; this.saveCartonDraft();
} catch (error) { this.error = error } finally { pallet.busy = false }
},
async restorePhotos(transferNo, knownPhotos) {
let photos
try {
// 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
},
takePhoto() {
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
}
const transferNo = this.selected.transferNo
uni.chooseImage({ count: Math.min(9, MAX_MEASUREMENT_PHOTOS - 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 }
const remaining = MAX_MEASUREMENT_PHOTOS - this.photoFileIds.length
const uploadPaths = paths.slice(0, remaining)
const skipped = paths.length - uploadPaths.length
this.uploadingPhoto = true; this.error = null
try {
for (const [index, path] of uploadPaths.entries()) {
if (this.photoFileIds.length >= MAX_MEASUREMENT_PHOTOS) throw new Error(`测量现场照片最多 ${MAX_MEASUREMENT_PHOTOS} 张`)
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 || '测量现场照片')
}
if (skipped > 0) this.error = new Error(`最多上传 ${MAX_MEASUREMENT_PHOTOS} 张测量现场照片,已忽略超出的 ${skipped} 张`)
this.pendingKey = ''
} 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 }
}, 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 {
// 清点、绑托和拍照完成后,仅 SKU 件数差异要求填写原因。
if (this.hasSkuReceiptDifference) {
const answer = await new Promise((resolve, reject) => uni.showModal({
title: 'SKU签收数量存在差异', content: this.countDiscrepancyReason || '', editable: true,
placeholderText: '请填写SKU签收差异原因(必填)', confirmText: '确认完成',
success: resolve, fail: reject
}))
if (!answer.confirm) return
const reason = String(answer.content || '').trim()
if (!reason) { this.error = new Error('请填写差异原因后再完成测量'); return }
if (reason.length > 1000) { this.error = new Error('差异原因不能超过1000字'); return }
if (reason !== this.countDiscrepancyReason) this.pendingKey = ''
this.countDiscrepancyReason = reason
this.saveCartonDraft()
}
this.pendingKey = `pda-transfer-${this.selected.transferNo}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
const payload = { idempotencyKey: this.pendingKey, photoFileIds: [...this.photoFileIds], discrepancyReason: this.hasSkuReceiptDifference ? this.countDiscrepancyReason : null }
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()
} 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>
.collapsed-carton-row{position:relative;min-height:40px;padding:0 36px 0 0;color:#415a78;font-size:12px;line-height:40px}.collapsed-carton-mark{width:100%;white-space:nowrap}.collapsed-carton-mark text{display:inline-block;white-space:nowrap;word-break:normal;overflow-wrap:normal}.collapsed-carton-expand{position:absolute;top:0;right:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;margin:0;padding:0;border:0;border-radius:6px;background:transparent;line-height:32px}.collapsed-carton-expand::after{border:0}.transfer-content .carton-count-card.carton-collapsed{padding:6px 12px}
.confirm-carton-button{margin:12px 0 0;border:1px solid #cbdcff;border-radius:8px;color:#135ee8;background:#eff5ff;font-size:13px;line-height:38px}
.pallet-title-line{display:flex;align-items:center;flex-wrap:wrap;gap:5px 10px}.pallet-inline-metrics{display:flex;flex-wrap:wrap;gap:4px 8px}.transfer-content .pallet-inline-metrics text{margin:0;color:#3f5671;font-size:10px;font-weight:500;white-space:nowrap}.pallet-index{flex-shrink:0}.remeasure-button{width:auto;margin:0;padding:0 12px;border:1px solid #b9d3f4;border-radius:8px;color:#135ee8;font-size:12px;line-height:36px;background:#f0f6ff}
.carton-title-row{display:flex;align-items:flex-start;gap:8px}.carton-title-row{flex-wrap:wrap}.carton-title-row>.measure-title{flex:1;min-width:150px}.delete-carton-button{flex:none;width:auto;margin:0;padding:0 8px;border:1px solid #f0caca;border-radius:7px;color:#b74545;background:#fff7f7;font-size:12px;line-height:30px}.add-sku-button{display:flex;align-items:center;justify-content:center;flex:none;box-sizing:border-box;width:24px;height:24px;margin:0;padding:0;border:1px solid #cbdcff;border-radius:7px;color:#135ee8;background:#f0f6ff;font-size:12px;line-height:30px}.add-sku-button[disabled]{color:#a0adbe;background:#f5f7fa}
.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 rgba(19,94,232,.2)}
.transfer-page{display:flex;flex-direction:column}.transfer-page .transfer-scroll{flex:1;min-height:0;height:0}
.transfer-work-toolbar{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;gap:12px;padding:8px 14px;border-bottom:1px solid #e1e7ef;background:#fff}
.transfer-work-toolbar .back-to-list,.transfer-work-toolbar .refresh-button{display:flex;align-items:center;justify-content:center;gap:4px;width:auto;min-height:40px;margin:0;padding:0 12px;border:1px solid #d6e2f1;border-radius:8px;color:#135ee8;font-size:13px;line-height:40px;background:#f8fbff}
.carton-prerequisite{display:block;margin:0 0 12px;color:#718095;font-size:12px;line-height:18px}
.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}
.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}
.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-list-page{background:#f1f5fd}
.transfer-list-page .transfer-content{padding-top:14px;padding-bottom:calc(24px + env(safe-area-inset-bottom,0px))}
.transfer-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 11px}
.transfer-list-count{min-width:0;color:#526174;font-size:14px;font-weight:700;line-height:20px}
.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}
.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}
.transfer-heading .refresh-button{box-sizing:border-box;flex-shrink:0;gap:5px;min-width:72px;height:36px;padding:0 14px;border-radius:20px;font-size:14px;font-weight:700;line-height:normal;background:#fff}
.refresh-button[disabled]{opacity:.55}
.candidate-list{display:flex;flex-direction:column;gap:11px;margin-bottom:16px}
.transfer-card{display:block;box-sizing:border-box;width:100%;margin:0;padding:14px;border:1px solid #dce5f0;border-radius:13px;color:#1f2937;line-height:normal;text-align:left;white-space:normal;background:#fff;box-shadow:none;cursor:pointer;transition:background-color .12s ease,border-color .12s ease}
.transfer-card-pressed{border-color:#93baf0;background:#f4f8ff}
.transfer-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
.transfer-no{flex:1;min-width:0;color:#1f2937;font-size:18px;font-weight:750;line-height:26px;overflow-wrap:anywhere;word-break:break-all}
.status-pill{flex-shrink:0;padding:6px 10px;border-radius:9px;color:#135ee8;font-size:13px;font-weight:700;line-height:16px;white-space:nowrap;background:#e5efff}
.status-measuring{color:#08705f;background:#e2f6f0}
.task-metrics{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr) minmax(0,1fr);margin-top:12px;padding:7px 0;border-top:1px solid #e1e7f0;border-bottom:1px solid #e1e7f0}
.task-metrics view{min-width:0;padding:0 6px;border-right:1px solid #e1e7f0;text-align:center}
.task-metrics view:last-child{border-right:0}
.task-metrics strong{display:block;color:#1f2937;font-size:22px;font-weight:750;line-height:26px;overflow-wrap:anywhere}
.task-metrics text{display:block;margin-top:2px;color:#6f8194;font-size:12px;line-height:18px}
.transfer-card-footer{display:flex;align-items:center;justify-content:flex-end;gap:10px;min-height:28px;margin-top:10px}
.transfer-meta{flex:1;min-width:0;color:#718095;font-size:12px;line-height:20px;overflow-wrap:anywhere}
.transfer-card-action{display:flex;flex-shrink:0;align-items:center;gap:2px;color:#135ee8;font-size:13px;font-weight:700;line-height:22px;white-space:nowrap}
.transfer-list-page .transfer-empty{font-size:12px;line-height:20px}
.transfer-list-page .empty-title{font-size:14px}
.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-heading-copy{min-width:0;flex:1}.pallet-heading-copy text:first-child{color:#17314f;font-size:13px;font-weight:850}.pallet-heading-copy>text:last-child{margin-top:2px;color:#8995a5;font-size:8px}.remove-button{width:auto;height:28px;margin:0;padding:0 9px;border-radius:7px;color:#b33030;font-size:9px;line-height:28px;background:#fff0f0}.field-label{display:block;margin-bottom:5px;color:#65768b;font-size:9px;font-weight:700}.field-label small{margin-left:3px;color:#9aa5b3;font-size:8px;font-weight:500}.measure-input,.input-with-unit{box-sizing:border-box;width:100%;height:42px;border:1px solid #cad7e5;border-radius:9px;color:#203750;background:#fafcfe}.measure-input{padding:0 11px;font-size:12px}.pallet-code-field{display:block;margin-bottom:14px}.measure-section-head,.sku-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:9px}.measure-title{color:#243a53;font-size:11px;font-weight:850}.measure-hint{margin-top:2px;color:#8a96a5;font-size:8px}.volume-chip{flex-shrink:0;padding:5px 8px;border-radius:8px;text-align:right;background:#edf5ff}.volume-chip text{display:block;color:#7189a7;font-size:7px}.volume-chip strong{display:block;margin-top:1px;color:#135ee8;font-size:10px}.dimension-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.measure-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:9px;margin-top:10px}.input-with-unit{display:flex;align-items:center;overflow:hidden}.input-with-unit input{box-sizing:border-box;min-width:0;height:40px;padding:0 4px 0 10px;flex:1;color:#203750;font-size:16px;font-weight:800}.input-with-unit>text{padding-right:8px;color:#8491a1;font-size:8px}.input-with-unit.important{border-color:#9ebff0;background:#f8fbff}.sku-section{margin:15px -2px -2px;padding:12px;border-radius:11px;background:#f5f8fb}.sku-total{flex-shrink:0;padding:5px 7px;border-radius:7px;color:#9a5c13;font-size:9px;font-weight:800;background:#fff1dd}.sku-total.matched{color:#08705f;background:#dff5ed}.sku-row{display:flex;align-items:center;gap:9px;min-height:48px;border-top:1px solid #e3eaf2}.sku-copy{min-width:0;flex:1}.sku-code{overflow:hidden;color:#233b56;font-size:11px;font-weight:800;text-overflow:ellipsis;white-space:nowrap}.sku-copy text:last-child{margin-top:2px;color:#8693a3;font-size:8px}.sku-input{display:flex;align-items:center;gap:4px;color:#718095;font-size:9px}.sku-input input{box-sizing:border-box;width:64px;height:34px;padding:0 7px;border:1px solid #abc4e8;border-radius:8px;color:#17314f;font-size:14px;font-weight:800;text-align:center;background:#fff}.add-pallet-button{display:flex;align-items:center;justify-content:center;gap:5px;box-sizing:border-box;width:100%;height:44px;margin:2px 0 11px;border:1px dashed #78a8ed;border-radius:11px;color:#135ee8;font-size:11px;font-weight:800;background:#f7faff}.transfer-validation,.transfer-error,.transfer-success{box-sizing:border-box;margin-bottom:11px;padding:11px 12px;border-radius:10px;font-size:9px;line-height:16px}.transfer-validation,.transfer-error{color:#9d3030;background:#fff0f0}.validation-title,.feedback-title{font-size:11px;font-weight:850}.transfer-success{color:#176b42;background:#eaf8f1}.submit-panel{box-sizing:border-box;padding:12px;border:1px solid #dce5f0;border-radius:13px;background:#fff;box-shadow:0 5px 18px rgba(32,67,111,.07)}.submit-panel>view{display:flex;align-items:center;justify-content:space-between;margin-bottom:9px;color:#778598;font-size:9px}.submit-panel strong{color:#243951;font-size:11px}.transfer-primary{box-sizing:border-box;width:100%;height:44px;margin:0;border-radius:9px;color:#fff;font-size:12px;font-weight:850;line-height:44px;background:#135ee8}.transfer-primary[disabled]{color:#9aa7b5;background:#e7ecf2;opacity:1}
.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}
.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}
.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}
.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{margin-top:2px;color:#8995a5;font-size:8px}.transfer-content .summary-no{font-size:13px}.transfer-content .batch-summary{font-size:12px}
/* 测量任务默认展开,收起后仍保留单号和实收箱数,方便现场核对。 */
.order-summary{position:relative;margin-bottom:10px;border-radius:12px;cursor:pointer;padding:12px;gap:8px}
.order-summary>view:first-child{flex:1;min-width:0}.summary-toggle{flex:none;font-size:11px;white-space:nowrap;opacity:.9}.transfer-content .summary-no{font-size:12px;line-height:18px;word-break:break-all}.summary-count strong{font-size:20px}
.receipt-comparison{margin-top:0}.summary-metrics{box-shadow:none}
/* 托盘卡片按身份、实测指标、业务码分层,所有信息沿左侧基线排列。 */
.transfer-content .pallet-card{padding:12px;border-radius:12px;border-color:#dce5f0;box-shadow:0 2px 8px rgba(32,67,111,.04);text-align:left}
.transfer-content .pallet-card.active-receiving-pallet{border-color:#91b7ef!important}
.pallet-card .pallet-head{display:flex;align-items:center;gap:9px;margin:0;padding:0 0 10px;border-bottom:0;text-align:left}
.pallet-card .pallet-index{width:30px;height:30px;min-width:30px;border-radius:8px;font-size:12px;box-shadow:none}
.pallet-card .pallet-heading-copy{flex:1;min-width:0;text-align:left}
.pallet-card .pallet-title-line{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;gap:8px;flex-wrap:wrap}
.pallet-card .pallet-title-line .pallet-name{margin:0;font-size:14px;font-weight:750;color:#18324f}
.pallet-card .pallet-title-line .pallet-status{margin:0;padding:3px 6px;border-radius:5px;color:#a47320;background:#fff6e5;font-size:10px;font-weight:500;line-height:15px}
.pallet-card .pallet-title-line .pallet-status.is-bound{color:#278365;background:#edf8f2}
.pallet-card .remove-button,.pallet-card .collapse-button{flex:none;box-sizing:border-box;width:28px;height:28px;margin:0;padding:0;line-height:26px;border-radius:7px;font-size:18px}
.pallet-card .remove-button{color:#c46868;background:#fff5f5}.pallet-card .collapse-button{color:#6180a6;background:#f2f6fc}
.pallet-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;padding:10px;border-radius:8px;background:#f5f8fc}
.pallet-stats>view{min-width:0;text-align:left}.pallet-stats strong{display:block;color:#213b58;font-size:15px;font-weight:750;line-height:21px}.pallet-stats strong text{font-size:10px;font-weight:400;color:#73859b}.pallet-stats>view>text{display:block;margin-top:3px;color:#8090a3;font-size:10px}
.pallet-card .pallet-brief{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:10px 0 0;padding:0;flex-wrap:nowrap}
.pallet-business-code{flex:1;min-width:0;color:#7c8ca0;font-size:9px;line-height:15px;overflow-wrap:anywhere;word-break:break-all;text-align:left}
.receiving-badge{flex:none;display:flex;align-items:center;gap:5px;color:#3273c7;font-size:10px;white-space:nowrap}.receiving-badge:before{content:'';width:5px;height:5px;border-radius:50%;background:#4184e1}
.pallet-card .pallet-brief button{flex:none;width:auto;height:28px;line-height:28px;margin:0;padding:0 8px;font-size:10px;border-radius:6px;background:#edf4ff;color:#246ac6}
.pallet-card .measure-section-head{margin-top:12px}.pallet-card .generated-card{margin-top:10px}
/* 箱号独占一行,操作按钮固定在标题右侧,长业务编号不会挤压按钮。 */
.transfer-content .carton-count-card{padding:12px;margin:12px 0;border-color:#dce5f0;border-radius:12px;background:#fff}
.carton-title-row{flex-wrap:nowrap;align-items:center;justify-content:space-between;gap:8px}.carton-title-row>.measure-title{min-width:0;font-size:14px!important}.carton-icon-actions{display:flex;flex:none;gap:6px}
.carton-icon-actions button{box-sizing:border-box;width:30px;height:30px;padding:0;margin:0;line-height:28px;font-size:20px;border-radius:8px}
.carton-business-code{display:block;margin:5px 0 10px;color:#7c8da2;font-size:10px;line-height:15px;overflow-wrap:anywhere;word-break:break-all}
.carton-count-card .batch-sku{padding:9px 10px;margin:8px 0;font-size:11px;background:#f7faff;border-color:#dce5f0}.carton-count-card .batch-actions:empty{display:none}
</style>