358 lines
36 KiB
Vue
358 lines
36 KiB
Vue
<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>
|