优化
This commit is contained in:
@@ -11,12 +11,20 @@
|
||||
<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.inherited ? '原托盘货物未变,沿用原测量;包装变化请重新测量。' : '拆托、合托后请确认本托最终尺寸和毛重,原托盘剩余货物也需补测。' }}</text>
|
||||
<text v-if="pallet.volumeCbm != null" class="hint">已确认:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg</text>
|
||||
<view class="measurement-inputs">
|
||||
<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 :disabled="busy" @tap="saveMeasurement(pallet)">确认本托实测</button>
|
||||
<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">
|
||||
@@ -37,7 +45,13 @@
|
||||
<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="marks = []" /><text>箱</text></view></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>
|
||||
@@ -60,9 +74,11 @@ export default {
|
||||
components: { ScanTargetInput },
|
||||
props: { task: { type: Object, required: true }, allowDisassemble: { type: Boolean, default: true } },
|
||||
emits: ['activate', 'busy', 'saved'],
|
||||
data() { return { measurements: [], 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: '' } },
|
||||
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() {
|
||||
@@ -103,6 +119,18 @@ export default {
|
||||
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 }
|
||||
@@ -166,7 +194,7 @@ export default {
|
||||
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.rebindSelection = null; this.selectedSource = row; this.source = null; this.sourceCode = ''; this.quantity = ''; this.marks = []
|
||||
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)
|
||||
@@ -241,6 +269,7 @@ export default {
|
||||
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')
|
||||
@@ -280,8 +309,11 @@ export default {
|
||||
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') })
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createSSRApp } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
|
||||
const source = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
|
||||
const script = source.split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
|
||||
function setup() {
|
||||
const calls = []
|
||||
const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', script)(
|
||||
const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'bindRelabelPallet', script)(
|
||||
{}, async payload => { calls.push(payload); return [{ palletCode: payload.palletCode, volumeCbm: 0.25, grossWeightKg: payload.grossWeightKg }] },
|
||||
() => ({ value: 'stable-key', signature: 'signature' }), () => {})
|
||||
() => ({ value: 'stable-key', signature: 'signature' }), () => {}, async payload => calls.push(payload))
|
||||
const value = { ...component.data(), task: { relabelNo: 'RL-1' }, $emit() {} }
|
||||
for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
|
||||
return { component, value, calls }
|
||||
@@ -33,3 +35,74 @@ test('only whole physical pallet quantities offer original pallet reuse', () =>
|
||||
ctx.plannedPackages = 10;ctx.isReturnBulk = true
|
||||
assert.equal(canRetain.call(ctx), false)
|
||||
})
|
||||
|
||||
async function renderMeasurements(measurements) {
|
||||
const { component } = setup()
|
||||
const template = source.slice(source.indexOf('<template>') + 10, source.indexOf('<script>')).trim().replace(/<\/template>$/, '')
|
||||
return renderToString(createSSRApp({ ...component, template,
|
||||
components: { ScanTargetInput: { template: '<view />' } },
|
||||
props: undefined,
|
||||
data() { return { ...component.data(), task: { relabelNo: 'RL-1', items: [], history: [] }, measurements } }
|
||||
}))
|
||||
}
|
||||
test('unchanged source renders real grouped box specs and totals without zero pallet dimension inputs', async () => {
|
||||
const html = await renderMeasurements([
|
||||
{ palletCode: 'FIRST', packages: 2, inherited: true, lengthCm: null, widthCm: null, heightCm: null,
|
||||
grossWeightKg: 5, volumeCbm: 0.3, sourceCartonSpecs: [{ lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5, cartons: 2 }] },
|
||||
{ palletCode: 'SECOND', packages: 3, inherited: true, lengthCm: null, widthCm: null, heightCm: null,
|
||||
grossWeightKg: 9.7, volumeCbm: 0.31, sourceCartonSpecs: [
|
||||
{ lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5, cartons: 1 },
|
||||
{ lengthCm: 50, widthCm: 40, heightCm: 40, grossWeightKg: 3.6, cartons: 2 }] }
|
||||
])
|
||||
assert.match(html, /60 × 50 × 50 cm · 2.5 kg\/箱 · 2 箱/)
|
||||
assert.match(html, /50 × 40 × 40 cm · 3.6 kg\/箱 · 2 箱/)
|
||||
assert.match(html, /来源汇总:0.31 m³ · 9.7 kg/)
|
||||
assert.match(html, /未测整托长宽高/)
|
||||
assert.equal((html.match(/class="measurement-inputs"/g) || []).length, 0)
|
||||
assert.doesNotMatch(html, /确认本托实测/)
|
||||
})
|
||||
test('changed pallets and explicit packaging changes still render final pallet measurement inputs', async () => {
|
||||
for (const pallet of [{ inherited: false }, { inherited: true, remeasuring: true }]) {
|
||||
const html = await renderMeasurements([{ ...pallet, palletCode: 'P', packages: 1 }])
|
||||
assert.match(html, /class="measurement-inputs"/)
|
||||
assert.match(html, /确认本托实测/)
|
||||
assert.match(html, /长 cm/)
|
||||
}
|
||||
})
|
||||
test('box aggregated pallets render current totals and no manual pallet measurement even after moving', async () => {
|
||||
const html = await renderMeasurements([{ palletCode: 'NEW', packages: 1, inherited: true, cartonAggregated: true,
|
||||
grossWeightKg: 3.6, volumeCbm: 0.08, sourceCartonSpecs: [{lengthCm:50,widthCm:40,heightCm:40,grossWeightKg:3.6,cartons:1}] }])
|
||||
assert.match(html, /移出扣减、移入增加/)
|
||||
assert.match(html, /0.08 m³ · 3.6 kg/)
|
||||
assert.doesNotMatch(html, /确认本托实测|重新测量|measurement-inputs/)
|
||||
})
|
||||
test('mixed box selection is required and submits exact measured source box with binding', async () => {
|
||||
const { value, calls } = setup()
|
||||
Object.assign(value, { readyToBind:true, requiresMeasuredBoxChoice:true, marks:['CUSTOMER'],
|
||||
source:{palletCode:'OLD',palletVersion:1}, target:{palletCode:'NEW',palletVersion:1},
|
||||
selectedSource:{positionId:'1',positionVersion:1},quantity:'1', loadSources:async()=>{}, showBindingMessage(){} })
|
||||
await value.bind()
|
||||
assert.equal(calls.length,0)
|
||||
assert.match(value.error,/请选择/)
|
||||
value.measuredSelections={CUSTOMER:'SMALL1'}
|
||||
await value.bind()
|
||||
assert.deepEqual(calls[0].measuredCartons,[{cartonMark:'CUSTOMER',sourceCartonMark:'SMALL1'}])
|
||||
})
|
||||
test('measured box picker excludes a box selected for another item', () => {
|
||||
const { value } = setup()
|
||||
Object.assign(value,{marks:['X','Y'],measuredSelections:{X:'A'},measuredBoxChoices:[
|
||||
{cartonMark:'A',lengthCm:60,widthCm:50,heightCm:50,grossWeightKg:2.5},
|
||||
{cartonMark:'B',lengthCm:50,widthCm:40,heightCm:40,grossWeightKg:3.6}]})
|
||||
assert.deepEqual(value.measuredBoxOptions('Y').map(box=>box.cartonMark),['B'])
|
||||
assert.equal(value.measuredBoxOptions('X').length,2)
|
||||
})
|
||||
test('entering split quantity prepares measured box choices before target binding', () => {
|
||||
const { value } = setup()
|
||||
Object.assign(value,{measuredBoxChoices:[{cartonMark:'A'}],eligibleItems:[{cartonMark:'X'}],
|
||||
selectedSource:{movablePackages:1},measuredSelections:{OLD:'A'},fillBatch(){this.marks=['X']}})
|
||||
value.prepareMeasuredBatch({detail:{value:'1'}})
|
||||
assert.deepEqual(value.marks,['X'])
|
||||
assert.deepEqual(value.measuredSelections,{})
|
||||
value.prepareMeasuredBatch({detail:{value:'0'}})
|
||||
assert.deepEqual(value.marks,[])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user