From b8c9fddf8e166c8ed38acaec528a0af8b12291b1 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 16 Oct 2026 18:26:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RelabelPalletWork.vue | 62 +++++++++------------ tests/relabel-pallet-measurement.test.mjs | 67 ++++++++++++++--------- tests/relabel-pallet-work.test.mjs | 6 +- 3 files changed, 71 insertions(+), 64 deletions(-) diff --git a/src/components/RelabelPalletWork.vue b/src/components/RelabelPalletWork.vue index 033c253..9b1a5df 100644 --- a/src/components/RelabelPalletWork.vue +++ b/src/components/RelabelPalletWork.vue @@ -5,20 +5,17 @@ - 已绑定箱唛:选择后扫描新目标托盘可改绑(已排车或出库不可直接改绑)。 {{ pallet.palletCode }} · {{ pallet.totalPackages }} 箱 · 体积 {{ pallet.volumeCbm == null ? '待补测' : pallet.volumeCbm + ' m³' }} · 重量 {{ pallet.grossWeightKg == null ? '待补测' : pallet.grossWeightKg + ' kg' }} {{ pallet.palletCode }} · {{ pallet.packages }} 箱 - {{ pallet.cartonAggregated ? '按当前托盘内实测箱自动汇总,移出扣减、移入增加。' : pallet.inherited ? '原托盘货物未变,沿用来源测量。' : '请核对托盘测量数据。' }} {{ pallet.inherited ? '来源汇总' : '已确认' }}:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg 来源箱规(长 × 宽 × 高) {{ spec.lengthCm }} × {{ spec.widthCm }} × {{ spec.heightCm }} cm · {{ spec.grossWeightKg }} kg/箱 · {{ spec.cartons }} 箱 原整托尺寸:{{ pallet.lengthCm }} × {{ pallet.widthCm }} × {{ pallet.heightCm }} cm - 来源按箱测量,未测整托长宽高。 @@ -35,32 +32,20 @@ 当前没有可操作的来源托盘或来源箱 - 来源已确认:{{ source.palletCode }};尚未拆出,请完成贴标登记、核对拆出数量并扫描目标托盘,绑定成功后才保存拆托明细。 + 来源已确认:{{ source.palletCode }} - 本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交) - - 来源有多种箱规,请选择本次移出的实测箱,系统自动扣减体积和重量。 - - {{ mark }}:{{ measuredBoxLabel(mark) }} - - {{ message }}{{ error }} @@ -74,11 +59,11 @@ 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: '' } }, + 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: '' } }, 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 }, + requiresMeasuredBoxMapping() { 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() { @@ -111,8 +96,8 @@ export default { 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 '本托盘还有未完成的换标明细,请先完成贴标登记,再确认目标托盘绑定。' + if (missingSku) return '请先完成 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')) }, @@ -121,16 +106,21 @@ export default { methods: { prepareMeasuredBatch(event) { this.quantity = String(event.detail.value) - this.marks = []; this.measuredSelections = {} + this.marks = [] 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` })) + // 按申请时的仓库箱唛或客户箱唛对应原实测箱,禁止重新选择其他箱。 + measuredCartonBindings() { + if (!this.requiresMeasuredBoxMapping) return [] + const used = new Set() + return this.marks.map(mark => { + const matches = this.measuredBoxChoices.filter(box => box.cartonMark === mark || box.customerCartonMark === mark) + const box = matches.length === 1 && !used.has(matches[0].cartonMark) ? matches[0] : null + if (box) used.add(box.cartonMark) + return { cartonMark: mark, sourceCartonMark: box?.cartonMark } + }) }, - 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 } @@ -171,9 +161,9 @@ export default { const binding = this.rebindSelection const targetCode = this.targetCode.trim().toUpperCase() if (!binding || !targetCode || this.busy) return - if (targetCode === binding.pallet.toUpperCase()) { this.showBindingMessage('无需改绑', '该箱唛已经绑定此托盘,不会重复扣库存。'); 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) })) + 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 () => { @@ -194,7 +184,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.measuredSelections = {}; this.rebindSelection = null; this.selectedSource = row; this.source = null; this.sourceCode = ''; this.quantity = ''; this.marks = [] + 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) @@ -265,17 +255,17 @@ export default { 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 (!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.measuredCartonBindings().some(row => !row.sourceCartonMark)) 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 } + if (!await this.confirmBindingChoice(`是否绑定目标托盘 ${this.target.palletCode}(${this.quantity} ${this.isReturnBulk ? '件' : '箱'})?`)) { this.target = null; return } await this.bind() }, destination(mark) { return this.task.items.find(item => item.cartonMark === mark)?.destinationFbaWarehouseCode || '目的仓待维护' }, @@ -309,8 +299,8 @@ 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 measuredCartons = this.measuredCartonBindings() + 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 diff --git a/tests/relabel-pallet-measurement.test.mjs b/tests/relabel-pallet-measurement.test.mjs index 2e3e6ec..bfe9647 100644 --- a/tests/relabel-pallet-measurement.test.mjs +++ b/tests/relabel-pallet-measurement.test.mjs @@ -57,7 +57,7 @@ test('unchanged source renders real grouped box specs and totals without zero pa 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.doesNotMatch(html, /未测整托长宽高/) assert.equal((html.match(/class="measurement-inputs"/g) || []).length, 0) assert.doesNotMatch(html, /确认本托实测/) }) @@ -72,37 +72,54 @@ test('changed pallets and explicit packaging changes still render final pallet m 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.doesNotMatch(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 mixedBoxes = [ + { cartonMark: 'OTHER', lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5 }, + { cartonMark: 'SMALL1', customerCartonMark: 'CUSTOMER', lengthCm: 50, widthCm: 40, heightCm: 40, grossWeightKg: 3.6 } +] + +test('mixed specs automatically bind the submitted carton without another picker', 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(){} }) + Object.assign(value, { readyToBind: true, requiresMeasuredBoxMapping: true, marks: ['CUSTOMER'], measuredBoxChoices: mixedBoxes, + 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'}]) + assert.deepEqual(calls[0].cartonMarks, ['CUSTOMER']) + assert.deepEqual(calls[0].measuredCartons, [{ cartonMark: 'CUSTOMER', sourceCartonMark: 'SMALL1' }]) + assert.doesNotMatch(source, / { + +test('warehouse carton marks also match automatically and follow the current batch', () => { 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) + Object.assign(value, { requiresMeasuredBoxMapping: true, measuredBoxChoices: mixedBoxes, marks: ['SMALL1'] }) + assert.deepEqual(value.measuredCartonBindings(), [{ cartonMark: 'SMALL1', sourceCartonMark: 'SMALL1' }]) + value.marks = ['OTHER'] + assert.deepEqual(value.measuredCartonBindings(), [{ cartonMark: 'OTHER', sourceCartonMark: 'OTHER' }]) }) -test('entering split quantity prepares measured box choices before target binding', () => { + +test('unmatched, ambiguous or duplicate physical boxes cannot bind another carton', async () => { + for (const [marks, boxes] of [ + [['MISSING'], mixedBoxes], + [['CUSTOMER'], [...mixedBoxes, { ...mixedBoxes[0], customerCartonMark: 'CUSTOMER' }]], + [['CUSTOMER', 'SMALL1'], mixedBoxes] + ]) { + const { value, calls } = setup() + Object.assign(value, { readyToBind: true, requiresMeasuredBoxMapping: true, measuredBoxChoices: boxes, marks }) + await value.bind() + assert.equal(calls.length, 0) + assert.match(value.error, /不匹配/) + } +}) + +test('entering split quantity prepares the batch and clears invalid quantities', () => { 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,[]) + Object.assign(value, { measuredBoxChoices: [{ cartonMark: 'A' }], eligibleItems: [{ cartonMark: 'X' }], + selectedSource: { movablePackages: 1 }, fillBatch() { this.marks = ['X'] } }) + value.prepareMeasuredBatch({ detail: { value: '1' } }) + assert.deepEqual(value.marks, ['X']) + value.prepareMeasuredBatch({ detail: { value: '0' } }) + assert.deepEqual(value.marks, []) }) diff --git a/tests/relabel-pallet-work.test.mjs b/tests/relabel-pallet-work.test.mjs index 6b489af..208ec6f 100644 --- a/tests/relabel-pallet-work.test.mjs +++ b/tests/relabel-pallet-work.test.mjs @@ -59,7 +59,7 @@ test('pallet comparison tolerates mixed API versions and canonical code formatti test('quantity disassembly has no extra carton scanner', () => { assert.doesNotMatch(source, /relabel-move-carton/) assert.match(source, /actualSource:/) - assert.match(source, /来源已确认:.*尚未拆出.*绑定成功后才保存拆托明细/) + assert.match(source, /来源已确认:\{\{ source.palletCode \}\}<\/text>/) assert.match(source, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/) }) @@ -96,7 +96,7 @@ test('rescan same bound pallet does not call stock movement', async () => { value.showBindingMessage = (title, content) => { message = content } value.run = async () => assert.fail('same pallet must not move stock') await value.submitRebind() - assert.match(message, /不会重复扣库存/) + assert.match(message, /已绑定此托盘/) }) test('target confirmation submits binding directly, with no second submit button', async () => { @@ -183,7 +183,7 @@ test('missing source scan shows modal and never submits a binding', async () => value.bind = async () => assert.fail('must not bind without source confirmation') value.resolve = async () => assert.fail('must validate source first') await value.confirmTargetAndBind() - assert.match(message, /扫描并确认实际来源托盘/) + assert.match(message, /扫描确认来源托盘/) }) test('target lookup failure cannot submit binding or report success', async () => {