Files
pda-overseas-uniapp/src/components/RelabelPalletWork.vue
T
2026-10-16 11:43:15 +08:00

358 lines
36 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 class="pallet-work">
<view class="pallet-heading">
<text class="heading">托盘</text>
<button class="refresh-button" :disabled="busy" @tap="loadSources">刷新</button>
</view>
<view v-if="currentBindings.length">
<text class="hint">已绑定箱唛:选择后扫描新目标托盘可改绑(已排车或出库不可直接改绑)。</text>
<button v-for="binding in currentBindings" :key="binding.mark" class="binding-row" :disabled="busy" @tap="selectRebind(binding)"><text class="binding-mark">{{ binding.mark }}</text><text class="binding-arrow">→</text><text class="binding-pallet">{{ binding.pallet }}</text><text class="binding-action">重新绑定</text></button>
</view>
<view v-for="pallet in boundPalletTotals" :key="pallet.palletCode" class="hint"><text>{{ pallet.palletCode }} · {{ pallet.totalPackages }} 箱 · 体积 {{ pallet.volumeCbm == null ? '待补测' : pallet.volumeCbm + ' m³' }} · 重量 {{ pallet.grossWeightKg == null ? '待补测' : pallet.grossWeightKg + ' kg' }}</text></view>
<view v-for="pallet in measurements" :key="pallet.palletCode" class="measurement-card">
<text class="heading">{{ pallet.palletCode }} · {{ pallet.packages }} 箱</text>
<text class="hint">{{ pallet.cartonAggregated ? '按当前托盘内实测箱自动汇总,移出扣减、移入增加。' : pallet.inherited ? '原托盘货物未变,沿用来源测量。' : '请核对托盘测量数据。' }}</text>
<text v-if="pallet.volumeCbm != null" class="hint">{{ pallet.inherited ? '来源汇总' : '已确认' }}:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg</text>
<view v-if="pallet.inherited && pallet.sourceCartonSpecs?.length" class="source-carton-specs">
<text class="source-caption">来源箱规(长 × 宽 × 高)</text>
<text v-for="(spec, index) in pallet.sourceCartonSpecs" :key="index" class="hint">{{ spec.lengthCm }} × {{ spec.widthCm }} × {{ spec.heightCm }} cm · {{ spec.grossWeightKg }} kg/箱 · {{ spec.cartons }} 箱</text>
</view>
<text v-if="pallet.inherited && pallet.lengthCm > 0 && pallet.widthCm > 0 && pallet.heightCm > 0" class="hint">原整托尺寸:{{ pallet.lengthCm }} × {{ pallet.widthCm }} × {{ pallet.heightCm }} cm</text>
<text v-else-if="pallet.inherited && !pallet.cartonAggregated" class="hint">来源按箱测量,未测整托长宽高。</text>
<button v-if="!pallet.cartonAggregated && pallet.inherited && !pallet.remeasuring" :disabled="busy" @tap="pallet.remeasuring = true">包装变化,重新测量</button>
<view v-if="!pallet.cartonAggregated && (!pallet.inherited || pallet.remeasuring)" class="measurement-inputs">
<label v-for="field in measurementFields" :key="field.key"><text>{{ field.label }}</text><input v-model="pallet[field.key]" type="digit" :disabled="busy" :placeholder="field.label" /></label>
</view>
<button v-if="!pallet.cartonAggregated && (!pallet.inherited || pallet.remeasuring)" :disabled="busy" @tap="saveMeasurement(pallet)">确认本托实测</button>
<button v-if="!pallet.cartonAggregated && pallet.inherited && pallet.remeasuring" :disabled="busy" @tap="pallet.remeasuring = false">取消重新测量</button>
</view>
<view v-if="rebindSelection"><text class="hint">正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}</text><button :disabled="busy" @tap="rebindSelection = null; targetCode = ''; target = null">取消改绑</button></view>
<template v-if="!allBindingsComplete && !rebindSelection">
<view v-for="row in operableSourceRows" :key="row.positionId" class="source-row" :class="{ selected: selectedSource?.positionId === row.positionId }" @tap="selectSource(row)">
<view class="source-identifiers"><view v-if="!unboundSourceMode" class="source-identifier"><text class="source-caption">托盘号</text><text class="source-code">{{ row.palletCode }}</text></view><view v-else class="source-identifier"><text class="source-caption">来源箱</text><text class="source-code">{{ (row.cartonMarks || []).join('、') }}</text></view></view>
<text class="source-quantity">{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}</text>
</view>
<text v-if="!busy && !operableSourceRows.length" class="hint">当前没有可操作的来源托盘或来源箱</text>
<ScanTargetInput v-if="!isReturnBulk && !unboundSourceMode" v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
<ScanTargetInput v-if="unboundSourceMode" v-model="sourceCode" name="relabel-source-carton" label="来源箱唛" placeholder="扫描来源箱唛确认" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
<text v-if="source" class="hint">来源已确认:{{ source.palletCode }};尚未拆出,请完成贴标登记、核对拆出数量并扫描目标托盘,绑定成功后才保存拆托明细。</text>
<view v-if="selectedSource">
<text v-if="isReturnBulk" class="hint">本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)</text>
<template v-if="isReturnBulk">
<text class="return-pallet-summary">{{ selectedSource.palletCode }}:{{ wholeSource ? '整托换标' : '部分拆箱' }} {{ quantity || 0 }} 件 → {{ selectedDestination || '目的仓待维护' }}</text>
<text v-if="wholeSource" class="hint">贴标数量等于本托盘全部 {{ selectedSource.stockPackages }} 件,无需填写拆箱数量;直接新增带目的仓的托盘标签并绑定。</text>
<template v-else-if="allowDisassemble"><input v-model="quantity" type="number" :disabled="busy" :placeholder="`请输入拆箱数量(应为 ${plannedPackages} 件)`" /><text class="hint">贴标数量少于本托盘库存,请按现场实际选择来源托盘并填写拆箱数量;本托盘应拆 {{ plannedPackages }} 件。</text></template>
<text v-else class="hint">退货换标按整托绑定,不支持拆箱;托盘仍会展示用于核对。</text>
<text class="hint">退货换标无需扫描箱唛。</text>
</template>
<template v-else><view class="split-quantity-row"><text>本次拆出箱数</text><input v-model="quantity" type="number" :disabled="busy" placeholder="箱数" @input="prepareMeasuredBatch" /><text>箱</text></view></template>
</view>
<view v-if="requiresMeasuredBoxChoice && marks.length" class="source-carton-specs">
<text class="hint">来源有多种箱规,请选择本次移出的实测箱,系统自动扣减体积和重量。</text>
<picker v-for="mark in marks" :key="mark" :range="measuredBoxOptions(mark)" range-key="label" :disabled="busy" @change="chooseMeasuredBox(mark, $event)">
<view class="hint">{{ mark }}:{{ measuredBoxLabel(mark) }}</view>
</picker>
</view>
<view class="split-progress"><template v-if="!isReturnBulk"><text>本次需换标拆 <strong>{{ requiredSplitPackages }}</strong> 箱</text><text>已拆 <strong>{{ splitPackages }}</strong> 箱</text></template><button class="create-pallet-button" :disabled="busy || !!newPallet" @tap="createPallet">新增托盘号</button></view>
</template>
<template v-if="!allBindingsComplete || rebindSelection">
<ScanTargetInput v-model="targetCode" name="relabel-target" label="目标托盘" placeholder="扫描同目的仓目标托盘或新托盘号" :active="active === 'target'" @activate="activate('target')" @submit="confirmTargetAndBind" />
<button v-if="canRetainOriginal" :disabled="busy" @tap="quantity = String(source.totalPackages); targetCode = source.palletCode; confirmTargetAndBind()">整托全部贴标,保留原托盘</button>
<button :disabled="busy || !targetCode.trim()" @tap="confirmTargetAndBind">{{ rebindSelection ? '确认改绑目标托盘' : '确认绑定目标托盘' }}</button>
<text v-if="labelBlockReason" class="hint">{{ labelBlockReason }}</text>
<text v-if="target" class="hint">已识别目标:{{ target.palletCode }},尚未绑定成功</text>
<view v-if="newPallet" class="hint"><text>托盘 {{ newPallet.palletCode }} 待绑定</text><button @tap="printPallet">查看 / 打印托盘标签</button></view>
</template>
<text v-if="message" class="hint">{{ message }}</text><text v-if="error" class="error">{{ error }}</text>
</view>
</template>
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources, listRelabelPalletMeasurements, measureRelabelPallet } from '@/api/pallet.js'
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default {
components: { ScanTargetInput },
props: { task: { type: Object, required: true }, allowDisassemble: { type: Boolean, default: true } },
emits: ['activate', 'busy', 'saved'],
data() { return { measurements: [], measuredSelections: {}, measurementFields: [{ key: 'lengthCm', label: '长 cm' }, { key: 'widthCm', label: '宽 cm' }, { key: 'heightCm', label: '高 cm' }, { key: 'grossWeightKg', label: '毛重 kg' }], palletTotals: {}, rebindSelection: null, active: 'source', sourceCode: '', targetCode: '', source: null, target: null, marks: [], busy: false, error: '', message: '', newPallet: null, sourceRows: [], selectedSource: null, quantity: '', selectedDestination: '' } },
mounted() { this.loadSources() },
computed: {
measuredBoxChoices() { return this.measurements.find(p => p.palletCode === this.selectedSource?.palletCode)?.cartons || [] },
requiresMeasuredBoxChoice() { return new Set(this.measuredBoxChoices.map(box => [box.lengthCm,box.widthCm,box.heightCm,box.grossWeightKg].join(':'))).size > 1 },
canRetainOriginal() { return !this.isReturnBulk && !this.unboundSourceMode && this.source && this.selectedSource && this.plannedPackages === Number(this.source.totalPackages) },
boundPalletTotals() { const codes = new Set(this.currentBindings.map(binding => binding.pallet)); return Object.values(this.palletTotals).filter(pallet => codes.has(pallet.palletCode) && !this.measurements.some(measured => measured.palletCode === pallet.palletCode)) },
allBindingsComplete() {
const required = (this.task.items || []).filter(item => Number(item.expectedPackages || 0) > 0 && (item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType)))
const bound = new Set(this.currentBindings.map(binding => binding.mark))
return required.length > 0 && required.every(item => bound.has(item.cartonMark))
},
requiredSplitPackages() { return (this.task.items || []).filter(item => ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType)).reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
splitPackages() { const bound = new Set(this.currentBindings.map(binding => binding.mark)); return (this.task.items || []).filter(item => ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType) && bound.has(item.cartonMark)).reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
currentBindings() {
const latest = new Map()
for (const entry of this.task.history || []) {
if (entry.action === 'REWORK_ARCHIVE') latest.clear()
if (entry.action !== 'PALLET_BIND') continue
try {
const data = JSON.parse(entry.detail)
if (data.targetPallets?.length !== 1) continue
for (const mark of data.cartonMarks || []) latest.set(mark, { mark, pallet: data.targetPallets[0] })
} catch { /* Invalid history is not used to infer a binding. */ }
}
return [...latest.values()]
},
destinations() { return [...new Set((this.isReturnBulk ? this.sourceItems() : this.completedItems()).map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
eligibleItems() { return (this.isReturnBulk ? this.sourceItems() : this.completedItems()).filter(item => item.destinationFbaWarehouseCode === this.selectedDestination) },
isReturnBulk() { return this.task.operationType === 'RETURN_BULK_SKU_LABEL' },
returnCartonItems() { return (this.task.items || []).filter(item => item.returnCartonId && !this.currentBindings.some(binding => binding.mark === item.cartonMark)) },
operableSourceRows() { return this.sourceRows.filter(row => Number(row.movablePackages) > 0) },
plannedPackages() { return this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
labelBlockReason() {
const pending = this.sourceItems().filter(item => Number(item.relabelledPackages || 0) < Number(item.expectedPackages || 1))
if (!pending.length) return ''
const missingSku = pending.some(item => item.operationType === 'CARTON_AND_SKU' && Number(item.skuProcessedPackages || 0) < Number(item.expectedPackages || 1))
if (missingSku) return '本托盘还有未完成的 SKU 贴标登记。混合换标须完成箱唛和 SKU 两项,请返回 SKU 页签处理后再确认绑定。'
return '本托盘还有未完成的换标明细,请先完成贴标登记,再确认目标托盘绑定。'
},
wholeSource() { return this.isReturnBulk && this.selectedSource && (!this.allowDisassemble || this.plannedPackages === Number(this.selectedSource.stockPackages || 0)) },
unboundSourceMode() { return !this.isReturnBulk && this.returnCartonItems.length > 0 && (this.selectedSource ? (!this.selectedSource.palletCode || this.selectedSource.palletCode === 'UNBOUND') : !this.operableSourceRows.some(row => row.palletCode && row.palletCode !== 'UNBOUND')) },
readyToBind() { return this.target && this.selectedSource && (this.unboundSourceMode || (this.source && this.samePallet(this.source, this.selectedSource) && (!this.samePallet(this.source, this.target) || this.canRetainOriginal))) && Number.isSafeInteger(Number(this.quantity)) && Number(this.quantity) > 0 && Number(this.quantity) <= this.selectedSource.movablePackages && this.bindingSelectionValid() }
},
methods: {
prepareMeasuredBatch(event) {
this.quantity = String(event.detail.value)
this.marks = []; this.measuredSelections = {}
const count = Number(this.quantity)
if (this.measuredBoxChoices.length && Number.isSafeInteger(count) && count > 0 && count <= this.selectedSource.movablePackages && count <= this.eligibleItems.length) this.fillBatch()
},
measuredBoxOptions(mark) {
const used = Object.entries(this.measuredSelections || {}).filter(([key]) => key !== mark && this.marks.includes(key)).map(([,value]) => value)
return this.measuredBoxChoices.filter(box => !used.includes(box.cartonMark)).map(box => ({ ...box, label: `${box.cartonMark} · ${box.lengthCm}×${box.widthCm}×${box.heightCm} cm · ${box.grossWeightKg} kg` }))
},
chooseMeasuredBox(mark, event) { const box = this.measuredBoxOptions(mark)[Number(event.detail.value)]; if (box) this.measuredSelections = { ...this.measuredSelections, [mark]: box.cartonMark } },
measuredBoxLabel(mark) { return this.measuredBoxOptions(mark).find(box => box.cartonMark === this.measuredSelections?.[mark])?.label || '点击选择实测箱' },
async saveMeasurement(pallet) {
const dimensions = Object.fromEntries(this.measurementFields.map(field => [field.key, Number(pallet[field.key])]))
if (!Object.values(dimensions).every(value => Number.isFinite(value) && value > 0)) { this.error = '请填写有效的长、宽、高和毛重'; return }
const payload = { relabelNo: this.task.relabelNo, palletCode: pallet.palletCode, stockVersion: pallet.stockVersion, ...dimensions }
const key = warehouseIdempotencyKey('relabel', 'measure-pallet', payload)
await this.run(async () => {
this.measurements = await measureRelabelPallet({ ...payload, idempotencyKey: key.value })
releaseWarehouseKey('relabel', key.signature)
this.message = `托盘 ${pallet.palletCode} 实测已保存`
this.$emit('saved')
})
},
savePalletTotals(result) { for (const pallet of result?.pallets || []) this.palletTotals = { ...this.palletTotals, [pallet.palletCode]: pallet } },
samePallet(left, right) {
if (!left || !right) return false
// 扫码枪可能在托盘号前后附带 CR/LF、零宽字符或全角空格;
// 这些字符不属于托盘号,不能让同一托盘被误判为不同托盘。
const code = value => String(value?.palletCode || '')
.replace(/[\u0000-\u001f\u007f\u200b-\u200d\u2060\ufeff]/g, '')
.replace(/[\u3000]/g, ' ')
.trim()
.toUpperCase()
const leftCode = code(left); const rightCode = code(right)
const sameCode = !!leftCode && leftCode === rightCode
if (sameCode) return true
if (left.palletId == null || right.palletId == null) return false
if (String(left.palletId) === String(right.palletId)) return true
return false
},
selectRebind(binding) {
if (this.busy) return
uni.showModal({ title: '重新绑定托盘', content: `箱唛 ${binding.mark} 当前在 ${binding.pallet}。请扫描新的目标托盘。`, showCancel: false })
this.rebindSelection = binding
this.targetCode = ''; this.target = null; this.message = ''; this.error = ''
this.activate('target')
},
async submitRebind() {
const binding = this.rebindSelection
const targetCode = this.targetCode.trim().toUpperCase()
if (!binding || !targetCode || this.busy) return
if (targetCode === binding.pallet.toUpperCase()) { this.showBindingMessage('无需改绑', '该箱唛已经绑定此托盘,不会重复扣库存。'); return }
this.busy = true; this.$emit('busy', true)
const confirmed = await new Promise(resolve => uni.showModal({ title: '确认改绑', content: `将 ${binding.mark} 从 ${binding.pallet} 移至 ${targetCode}?旧托盘扣箱,新托盘加箱。`, confirmText: '是', cancelText: '否', success: result => resolve(result.confirm), fail: () => resolve(false) }))
this.busy = false; this.$emit('busy', false)
if (!confirmed) return
await this.run(async () => {
const source = await resolvePallet(binding.pallet)
const target = await resolvePallet(targetCode)
const payload = { relabelNo: this.task.relabelNo, source: { palletCode: source.palletCode, expectedVersion: source.palletVersion }, target: { palletCode: target.palletCode, expectedVersion: target.palletVersion }, cartonMarks: [binding.mark], reason: 'PDA 已绑定箱唛确认改绑托盘' }
const key = warehouseIdempotencyKey('relabel', 'rebind-pallet', payload)
this.savePalletTotals(await bindRelabelPallet({ ...payload, idempotencyKey: key.value }))
releaseWarehouseKey('relabel', key.signature)
this.rebindSelection = null; this.targetCode = ''; this.target = null
this.$emit('saved')
this.showBindingMessage('绑定成功', `托盘 ${target.palletCode} 已绑定`)
})
if (this.error) this.showBindingMessage('改绑失败', this.error)
},
sourceItems() { return (this.task.items || []).filter(item => this.selectedSource?.cartonMarks.includes(item.cartonMark)) },
completedItems() { const bound = new Set(this.currentBindings.map(binding => binding.mark)); return this.sourceItems().filter(item => this.isReturnBulk ? Number(item.expectedPackages) > 0 && Number(item.relabelledPackages) >= Number(item.expectedPackages) : Number(item.relabelledPackages) >= 1 && !bound.has(item.cartonMark)) },
async loadSources() { await this.run(async () => { try { this.sourceRows = await findRelabelSources(this.task.relabelNo) } catch (error) { if (!this.isReturnBulk && this.returnCartonItems.length) this.sourceRows = []; else throw error } if (!this.isReturnBulk && this.returnCartonItems.length && !this.sourceRows.some(row => Number(row.movablePackages) > 0)) this.sourceRows = [{ positionId: this.returnCartonItems[0].returnCartonId, positionVersion: 0, palletCode: 'UNBOUND', stockPackages: this.returnCartonItems.length, movablePackages: this.returnCartonItems.length, cartonMarks: this.returnCartonItems.map(item => item.cartonMark).filter(Boolean) }]; for (const code of new Set(this.currentBindings.map(binding => binding.pallet))) { const pallet = await resolvePallet(code); this.savePalletTotals({ pallets: [pallet] }) } this.measurements = await listRelabelPalletMeasurements(this.task.relabelNo); this.selectedSource = null; this.source = null; this.marks = []; this.quantity = ''; if (this.unboundSourceMode && this.operableSourceRows.length === 1) { this.selectedSource = this.operableSourceRows[0]; this.selectedDestination = this.returnCartonItems[0]?.destinationFbaWarehouseCode || ''; this.marks = this.completedItems().map(item => item.cartonMark); this.quantity = String(this.marks.length); this.activate('target') } }) },
async selectSource(row) {
if (this.busy) return
this.measuredSelections = {}; this.rebindSelection = null; this.selectedSource = row; this.source = null; this.sourceCode = ''; this.quantity = ''; this.marks = []
this.selectedDestination = (this.isReturnBulk ? this.sourceItems() : this.completedItems())[0]?.destinationFbaWarehouseCode || ''
if (this.unboundSourceMode) {
this.marks = this.completedItems().map(item => item.cartonMark)
this.quantity = String(this.marks.length)
this.activate('target')
return
}
if (this.isReturnBulk) {
const items = this.eligibleItems
this.marks = items.map(item => item.cartonMark)
if (!this.allowDisassemble || items.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) === Number(row.stockPackages || 0)) this.quantity = String(row.stockPackages)
await this.run(async () => { const value = await resolvePallet(row.palletCode); if (this.selectedSource?.positionId === row.positionId) { this.source = value; this.sourceCode = value.palletCode } })
this.activate(this.quantity ? 'target' : 'source')
return
}
this.activate('source')
},
bindingSelectionValid() {
const count = Number(this.quantity)
if (this.isReturnBulk) {
const packages = this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0)
return count === packages && this.completedItems().length === this.eligibleItems.length && this.marks.length === this.eligibleItems.length
&& this.marks.every(mark => this.eligibleItems.some(item => item.cartonMark === mark))
}
return this.marks.length === count && this.marks.every(mark => this.eligibleItems.some(item => item.cartonMark === mark))
},
fillBatch() { const count = Number(this.quantity); const packages = this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0); const invalid = !Number.isSafeInteger(count) || count <= 0 || count > this.selectedSource.movablePackages || (this.isReturnBulk ? count !== packages : count > this.eligibleItems.length); if (invalid) { this.error = this.isReturnBulk ? '拆出件数必须等于本批已完成贴标件数,且不能超过来源托盘可拆数量' : '拆出数量必须为正整数,且不超过来源托盘可拆箱数和已完成换标箱数'; return } this.marks = (this.isReturnBulk ? this.eligibleItems : this.eligibleItems.slice(0, count)).map(item => item.cartonMark); this.error = '' },
toggleMark(mark) { if (this.busy) return; this.marks = this.marks.includes(mark) ? this.marks.filter(value => value !== mark) : [...this.marks, mark] },
activate(value) { this.active = value; this.$emit('activate') },
handleScan(value) { if (this.busy) return; this[this.active + 'Code'] = value; return this.active === 'target' ? this.confirmTargetAndBind() : this.confirmSourceScan() },
async confirmBindingChoice(content) {
this.busy = true; this.$emit('busy', true)
try { return await new Promise(resolve => uni.showModal({ title: '是否绑定', content, confirmText: '是', cancelText: '否', success: result => resolve(!!result.confirm), fail: () => resolve(false) })) }
finally { this.busy = false; this.$emit('busy', false) }
},
async confirmSourceScan() {
if (this.busy || !this.sourceCode.trim()) return
const matched = this.unboundSourceMode
? this.operableSourceRows.find(row => (row.cartonMarks || []).some(mark => String(mark).trim().toUpperCase() === this.sourceCode.trim().toUpperCase()))
: this.operableSourceRows.find(row => this.samePallet(row, { palletCode: this.sourceCode }))
this.source = null
if (!matched) { this.error = '该托盘不在本单可操作的来源托盘列表中'; this.showBindingMessage('来源托盘不匹配', this.error); return }
if (this.unboundSourceMode) {
this.selectedSource = matched
this.selectedDestination = this.completedItems()[0]?.destinationFbaWarehouseCode || ''
this.marks = this.completedItems().map(item => item.cartonMark)
this.quantity = String(this.marks.length)
this.activate('target')
return
}
await this.resolve('source')
if (this.error || !this.source) return
const source = this.source; this.source = null
if (await this.confirmBindingChoice(`是否绑定来源托盘 ${source.palletCode}?`)) { this.source = source; this.activate('target') }
},
showBindingMessage(title, content) { uni.showModal({ title, content, showCancel: false, confirmText: '知道了' }) },
async confirmTargetAndBind() {
if (this.busy) return
if (this.rebindSelection) return this.submitRebind()
if (!this.isReturnBulk && this.selectedSource && this.samePallet(this.source, this.selectedSource)) {
if (!this.completedItems().length && this.labelBlockReason) { this.error = this.labelBlockReason; this.showBindingMessage('尚未绑定', this.error); return }
this.selectedDestination = this.completedItems()[0]?.destinationFbaWarehouseCode || ''
this.marks = []
this.fillBatch()
if (this.error) { this.showBindingMessage('尚未绑定', this.error); return }
}
this.message = ''
let reason = ''
if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘或来源箱。'
else if (this.unboundSourceMode && !this.bindingSelectionValid()) reason = '请先完成来源箱换标,再绑定目标托盘。'
else if (!this.unboundSourceMode && !this.samePallet(this.source, this.selectedSource)) reason = this.isReturnBulk ? '来源托盘识别失败,请重新选择。' : '请先扫描并确认实际来源托盘;选择来源列表不等于扫描确认。'
else if (this.isReturnBulk && this.completedItems().length !== this.eligibleItems.length) reason = '请先完成本托盘的 SKU 贴标登记,再绑定新托盘;整托无需填写拆箱数量。'
else if (!Number.isSafeInteger(Number(this.quantity)) || Number(this.quantity) <= 0 || Number(this.quantity) > this.selectedSource.movablePackages) reason = this.isReturnBulk ? '本托盘可操作件数不足,请刷新来源托盘。' : '请填写有效的拆出箱数,不能超过来源托盘可拆数量。'
else if (!this.selectedDestination || !this.bindingSelectionValid()) reason = this.isReturnBulk ? `本托盘拆箱数量必须等于贴标预报分配数量 ${this.plannedPackages} 件。` : '本次拆出箱数必须对应已完成换标且尚未拆出的同目的仓箱唛。'
else if (this.requiresMeasuredBoxChoice && this.marks.some(mark => !this.measuredSelections?.[mark])) reason = '请选择本次移出的实测箱,系统将按所选箱扣减体积和重量。'
else if (!this.targetCode.trim()) reason = '请扫描或输入目标托盘号。'
if (reason) { this.error = reason; this.showBindingMessage('尚未绑定', reason); return }
await this.resolve('target')
if (this.error) { this.showBindingMessage('绑定失败', this.error); return }
if (!this.readyToBind) { this.error = '请核对来源箱、目标托盘和换标数量后重新确认。'; this.showBindingMessage('尚未绑定', this.error); return }
if (!await this.confirmBindingChoice(`是否绑定目标托盘 ${this.target.palletCode}(${this.quantity} ${this.isReturnBulk ? '件' : '箱'})?${this.samePallet(this.source, this.target) ? '整托保留原托盘且货物和包装未变可沿用原测量;包装变化请重新补测。' : ''}`)) { this.target = null; return }
await this.bind()
},
destination(mark) { return this.task.items.find(item => item.cartonMark === mark)?.destinationFbaWarehouseCode || '目的仓待维护' },
async run(work) { if (this.busy) return; this.busy = true; this.$emit('busy', true); this.error = ''; try { await work() } catch (error) { this.error = error?.message || '托盘作业失败,请重新核对'; if (['409', 'A0409'].includes(String(error?.code))) { this.source = null; this.target = null; this.selectedSource = null; this.marks = []; this.error += ';请刷新来源托盘并重新扫码'; } } finally { this.busy = false; this.$emit('busy', false) } },
async resolve(kind) {
const raw = String(this[kind + 'Code'] || '')
const code = raw.replace(/[\u0000-\u001f\u007f\u200b-\u200d\u2060\ufeff]/g, '').replace(/[\u3000]/g, ' ').trim()
if (!code) return
this[kind + 'Code'] = code
this[kind] = null
// 来源托盘以扫码结果为准:当页面当前选中的是另一张来源卡片时,
// 自动切换到扫码命中的来源,避免“已扫正确托盘但仍提示不匹配”。
if (kind === 'source') {
const normalized = value => String(value || '')
.replace(/[\u0000-\u001f\u007f\u200b-\u200d\u2060\ufeff]/g, '')
.replace(/[\u3000]/g, ' ').trim().toUpperCase()
const matched = this.sourceRows.find(row => normalized(row.palletCode) === normalized(code))
if (matched && this.selectedSource?.positionId !== matched.positionId) {
this.selectedSource = matched
this.selectedDestination = this.sourceItems()[0]?.destinationFbaWarehouseCode || ''
this.quantity = ''
this.marks = []
}
}
await this.run(async () => {
const value = await resolvePallet(code)
if (kind === 'source' && !this.samePallet(value, this.selectedSource)) throw new Error('请扫描所选原入仓单的实际来源托盘')
if (this[kind + 'Code'] === code) this[kind] = value
})
},
async createPallet() { const payload = { relabelNo: this.task.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'new-pallet', payload); await this.run(async () => { this.newPallet = await createRelabelPallet({ ...payload, idempotencyKey: key.value }); this.target = null; this.targetCode = this.newPallet.palletCode || ''; this.activate('target') }) },
async bind() {
if (!this.readyToBind) { this.error = '请核对来源托盘、拆出箱数、本批箱唛和目标托盘'; return }
const measuredCartons = this.requiresMeasuredBoxChoice ? this.marks.map(mark => ({ cartonMark: mark, sourceCartonMark: this.measuredSelections?.[mark] })) : []
if (measuredCartons.some(row => !row.sourceCartonMark)) { this.error = '请选择本次移出的实测箱'; return }
const expectation = value => ({ palletCode: value.palletCode, expectedVersion: value.palletVersion })
const payload = { relabelNo: this.task.relabelNo, ...(this.unboundSourceMode ? {} : { source: expectation(this.source), actualSource: { positionId: this.selectedSource.positionId, expectedPositionVersion: this.selectedSource.positionVersion, packages: Number(this.quantity) } }), target: expectation(this.target), cartonMarks: [...this.marks], reason: this.unboundSourceMode ? 'PDA 扫描无来源托盘退货箱并绑定目的托盘' : 'PDA 按原入仓单箱数拆托并按新目的仓合托' }
if (measuredCartons.length) payload.measuredCartons = measuredCartons
const key = warehouseIdempotencyKey('relabel', 'bind-pallet', payload)
let saved = false
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; releaseWarehouseKey('relabel', key.signature); const createdKey = warehouseIdempotencyKey('relabel', 'new-pallet', { relabelNo: this.task.relabelNo }); releaseWarehouseKey('relabel', createdKey.signature); this.message = `托盘 ${this.target.palletCode} 已绑定`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
if (saved) { this.showBindingMessage('绑定成功', this.message); await this.loadSources() }
else if (this.error) this.showBindingMessage('绑定失败', this.error)
},
printPallet() {
// #ifdef H5
const view = window.open('', '_blank'); if (view) { view.document.write(this.newPallet.printableHtml); view.document.close(); view.focus(); view.print() } else this.error = '请允许打开标签打印窗口'
// #endif
// #ifndef H5
this.error = '当前设备需通过已适配的打印服务打印托盘标签;请先在后台打印此托盘号,不要虚报打印成功。'
// #endif
}
}
}
</script>
<style scoped>
.pallet-work{margin-top:18px;padding:14px;border:1px solid #d7e1ef;border-radius:10px}
.measurement-card{margin:12px 0;padding:12px;border:1px solid #d7e1ef;border-radius:8px}
.measurement-inputs{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:10px 0}
.measurement-inputs input{padding:8px;border:1px solid #d7e1ef;border-radius:6px}
.pallet-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}
.heading{display:block;font-size:18px;font-weight:700}
.refresh-button{flex:none;margin:0;padding:6px 14px;font-size:13px;line-height:1.5}
.hint{display:block;font-size:13px;color:#52647c;margin:10px 0;white-space:normal}
.source-row{display:flex;align-items:center;gap:8px;padding:8px;margin:8px 0;border:1px solid #d7e1ef;border-radius:8px;white-space:nowrap}
.source-identifiers{flex:1;min-width:0}
.source-identifier{display:flex;align-items:baseline;gap:5px;max-width:100%;min-width:0}
.source-caption{flex-shrink:0;color:#718095;font-size:10px}
.source-code{min-width:0;color:#243951;font-size:11px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.source-quantity{flex-shrink:0;color:#52647c;font-size:9px;line-height:16px}
.split-quantity-row{display:flex;align-items:center;gap:8px;margin:10px 0;color:#52647c;font-size:12px}.split-quantity-row input{box-sizing:border-box;width:72px;height:34px;padding:6px 8px;text-align:center;font-size:13px}
.split-progress{display:flex;align-items:center;flex-wrap:wrap;gap:8px 12px;margin:10px 0;color:#52647c;font-size:12px;line-height:20px}.split-progress strong{color:#1f5fca;font-weight:700}.split-progress .create-pallet-button{margin:0 0 0 auto;padding:0 10px;border-radius:6px;background:#22c55e;color:#fff;font-size:10px;font-weight:700;line-height:28px}.split-progress .create-pallet-button[disabled]{background:#bbf7d0;color:#6b987b}.split-progress .create-pallet-button::after{border:none}
.source-row.selected{border-color:#2674d9;background:#eff6ff}
.binding-row{display:flex;align-items:center;gap:6px;box-sizing:border-box;width:100%;min-width:0;padding:8px 9px;font-size:11px;line-height:20px;text-align:left;white-space:nowrap}.binding-mark{max-width:25%;overflow:hidden;text-overflow:ellipsis}.binding-arrow,.binding-action{flex-shrink:0}.binding-pallet{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}.binding-action{color:#1f5fca;font-size:10px}
.line{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:8px 0}.line text{flex:1;overflow-wrap:anywhere}
input{padding:12px;background:#f3f6fa;border-radius:6px}button{font-size:13px;margin:8px 0}
.error{display:block;color:#bc3030;font-size:13px}
.return-pallet-summary{display:block;margin:10px 0;padding:12px;border-radius:8px;background:#eff6ff;color:#1f5fca;font-weight:700;overflow-wrap:anywhere}
</style>