From 785b4bca517cc97c1a069353c466bf9006fec2ed Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Mon, 12 Oct 2026 15:56:16 +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 | 23 ++++++++++-
src/pages/relabel/relabel.vue | 11 +++---
tests/relabel-pallet-work.test.mjs | 21 ++++++++++
tests/relabel-return-carton-mixed.test.mjs | 45 ++++++++++++++++++++--
4 files changed, 90 insertions(+), 10 deletions(-)
diff --git a/src/components/RelabelPalletWork.vue b/src/components/RelabelPalletWork.vue
index 268aa6d..b203ad7 100644
--- a/src/components/RelabelPalletWork.vue
+++ b/src/components/RelabelPalletWork.vue
@@ -9,13 +9,14 @@
正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}
+
托盘号{{ row.palletCode }}
{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}
当前没有可操作的来源托盘
- 已确认:{{ source.palletCode }}
+ 来源已确认:{{ source.palletCode }};尚未拆出,请完成贴标登记、核对拆出数量并扫描目标托盘,绑定成功后才保存拆托明细。
本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)
@@ -28,9 +29,14 @@
本次拆出箱数箱
本次需换标拆 {{ requiredSplitPackages }} 箱已拆 {{ splitPackages }} 箱
+
+
+
+ {{ labelBlockReason }}
已识别目标:{{ target.palletCode }},尚未绑定成功
托盘 {{ newPallet.palletCode }} 待绑定
+
{{ message }}{{ error }}
@@ -45,6 +51,11 @@ export default {
data() { return { 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: {
+ 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() {
@@ -65,6 +76,13 @@ export default {
isReturnBulk() { return this.task.operationType === 'RETURN_BULK_SKU_LABEL' },
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)) },
readyToBind() { return this.source && this.target && this.selectedSource && this.samePallet(this.source, this.selectedSource) && !this.samePallet(this.source, this.target) && Number.isSafeInteger(Number(this.quantity)) && Number(this.quantity) > 0 && Number(this.quantity) <= this.selectedSource.movablePackages && this.bindingSelectionValid() }
},
@@ -157,13 +175,14 @@ export default {
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
+ 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()
diff --git a/src/pages/relabel/relabel.vue b/src/pages/relabel/relabel.vue
index d91e125..b9ed6e6 100644
--- a/src/pages/relabel/relabel.vue
+++ b/src/pages/relabel/relabel.vue
@@ -24,7 +24,7 @@
{{ isReturnBulk ? '退货贴标 SKU 明细' : taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}
- 先扫描来源包裹/箱,再核对商品 SKU、目标标签和实际数量。已确认:{{ verifiedReturnCarton }}
+ 先扫描来源包裹/箱,再核对商品 SKU、目标标签和实际数量。已确认:{{ verifiedReturnCarton }}
{{ group.skuCode }} · 汇总待贴 {{ group.remaining }} 件(不自动分配)
{{ item.cartonMark }} · {{ item.skuCode }}目标商品标签:{{ item.newSkuCode || '未提供,禁止登记' }} · 本来源剩余 {{ remainingReturnQuantity(item) }} 件
@@ -46,8 +46,9 @@
-
+
+
完成照片{{ photoFileIds.length }} / 20 张
@@ -58,7 +59,6 @@
还不能完成换标{{ completionBlockReason }}
-
@@ -104,6 +104,7 @@
return { ...this.current, operationType: 'CARTON_LABEL', items: [...boxes.values()] }
},
isReturnTask() { return (this.current?.items || []).some(item => item.returnCartonId) },
+ showReturnPack() { return this.isReturnTask && (requiresReturnPack(this.current) || returnPackFacts(this.current).length > 0) },
requiresPalletBinding() { return this.isReturnTask ? this.current?.requiresPalletBinding === true || this.current?.deliveryMethod === 'TRUCK' : true },
returnBulkSources() { return (this.current?.items || []).filter(item => item.returnCartonId && item.operationType === 'RETURN_BULK_SKU_LABEL' && item.cartonMark === this.verifiedReturnCarton && item.skuCode.toUpperCase() === this.returnSkuCode.trim().toUpperCase() && this.remainingReturnQuantity(item) > 0) },
fbaDestinations() { return [...new Set((this.current?.items || []).map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
@@ -278,7 +279,7 @@
if (!mark || this.busy || !this.current) return
const item = this.current.items.find((row) => row.cartonMark === mark && row.operationType !== 'SKU_LABEL')
if (!item) return this.setError(new Error('该仓库唛头不属于本单箱唛换标明细'))
- if (this.isReturnTask && (!this.verifiedReturnCarton || this.returnCartonCode.trim() !== this.verifiedReturnCarton || item.cartonMark !== this.verifiedReturnCarton)) return this.setError(new Error('退货箱唛换标必须先确认同一来源包裹/箱'))
+ // The carton scan identifies the physical return source and records its label stage in one request.
const payload = { relabelNo: this.current.relabelNo, cartonMark: mark, expectedVersion: this.current.version }
const key = warehouseIdempotencyKey('relabel', 'carton-scan', payload)
await this.run('carton-scan', async () => {
@@ -303,7 +304,7 @@
async loadCandidates() { if (this.loading) return; this.loading = true; this.error = null; try { this.candidates = await listRelabelCandidates() } catch (error) { this.setError(error, '换标任务加载失败') } finally { this.loading = false } },
async openTask(raw, sound = true) { const no = String(raw?.detail?.value ?? raw ?? this.taskCode).trim(); if (!no || this.busy) return; this.taskCode = no; await this.run('detail', async () => { this.applyCurrent(await getRelabelTask(no)); if (sound) playSuccessSound() }, false) },
applyCurrent(value) { const changed = this.current?.relabelNo !== value.relabelNo; this.current = value; this.taskCode = value.relabelNo; if (changed) { this.unitDrafts = {}; this.documents = []; this.quickCarton = ''; this.lastCarton = null; this.returnCartonCode = ''; this.verifiedReturnCarton = ''; this.returnTargetLabels = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.bulkQuantities = {}; if (warehouseWork.relabel.photoTaskNo !== value.relabelNo) updateWarehouseWork('relabel', { photoTaskNo: value.relabelNo, photoFileIds: [], photoNames: [], remark: '' }); this.changeTab(value.items.some((item) => item.operationType === 'SKU_LABEL') ? 'sku' : 'carton'); this.loadDocuments() } if (this.selectedItem) this.selectedItem = this.visibleItems.find((item) => item.itemId === this.selectedItem.itemId) || null },
- changeTab(value) { this.taskTab = value; this.selectedItem = null; this.reason = ''; this.quickSku = ''; this.skuChoices = []; this.activeTarget = this.isReturnTask ? 'return-carton' : value === 'carton' ? 'quick-carton' : 'quick-sku' },
+ changeTab(value) { this.taskTab = value; this.selectedItem = null; this.reason = ''; this.quickSku = ''; this.skuChoices = []; this.activeTarget = value === 'carton' ? 'quick-carton' : this.isReturnTask ? 'return-carton' : 'quick-sku' },
skuDetails(item) { return item.skuItems?.length ? item.skuItems : [{ skuCode: item.skuCode, newSkuCode: item.newSkuCode, quantity: null }] },
selectItem(item) { if (this.taskTab === 'carton') { this.activeTarget = 'quick-carton'; return } if (this.remaining(item) <= 0) return; if (!this.unitDrafts[item.itemId]) this.unitDrafts[item.itemId] = { cartonMark: '', fbaCartonMark: '', skuVerifications: this.skuDetails(item).map((sku) => ({ skuCode: sku.skuCode || '', newSkuCode: '', quantity: sku.quantity })) }; this.selectedItem = item; this.reason = ''; this.activeTarget = 'old' },
focusNextSku(index) { const next = index + 1; this.activeTarget = next < this.unitDraft.skuVerifications.length ? `sku-new-${next}` : 'old' },
diff --git a/tests/relabel-pallet-work.test.mjs b/tests/relabel-pallet-work.test.mjs
index 192b7d3..6b489af 100644
--- a/tests/relabel-pallet-work.test.mjs
+++ b/tests/relabel-pallet-work.test.mjs
@@ -59,6 +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, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/)
})
@@ -107,9 +108,29 @@ test('target confirmation submits binding directly, with no second submit button
await value.confirmTargetAndBind()
assert.equal(binds, 1)
assert.match(source, /@submit="confirmTargetAndBind"/)
+ assert.match(source, /@tap="confirmTargetAndBind"/)
assert.doesNotMatch(source, /@tap="bind"/)
})
+test('mixed carton scan alone explains missing SKU and does not bind; completing SKU allows binding', async () => {
+ const value = context()
+ value.task.items = [{ cartonMark: 'A', operationType: 'CARTON_AND_SKU', expectedPackages: 1, relabelledPackages: 0, cartonProcessedPackages: 1, skuProcessedPackages: 0, destinationFbaWarehouseCode: 'ONT8' }]
+ value.source = { palletCode: 'SOURCE' }; value.targetCode = 'TARGET'; value.quantity = '1'
+ let message = ''; let binds = 0
+ value.showBindingMessage = (title, content) => { message = content }
+ value.resolve = async () => { value.target = { palletCode: 'TARGET' } }
+ value.bind = async () => { binds++ }
+ await value.confirmTargetAndBind()
+ assert.match(message, /SKU 贴标登记/)
+ assert.equal(binds, 0)
+ assert.equal(value.target, null)
+ value.task.items[0].skuProcessedPackages = 1
+ value.task.items[0].relabelledPackages = 1
+ assert.equal(value.labelBlockReason, '')
+ await value.confirmTargetAndBind()
+ assert.equal(binds, 1)
+})
+
test('source scan asks before accepting a listed pallet and rejects unknown pallets', async () => {
const value = context()
value.sourceRows = [{ positionId: '1', palletCode: 'SOURCE', movablePackages: 2 }]
diff --git a/tests/relabel-return-carton-mixed.test.mjs b/tests/relabel-return-carton-mixed.test.mjs
index 56f71cc..6ffcb49 100644
--- a/tests/relabel-return-carton-mixed.test.mjs
+++ b/tests/relabel-return-carton-mixed.test.mjs
@@ -3,11 +3,50 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
-const { returnDeductions } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
-test('return carton label path requires source confirmation before carton scan', () => {
- assert.match(page, /退货箱唛换标必须先确认同一来源包裹\/箱/)
+const { returnDeductions, requiresReturnPack, returnPackFacts } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
+const body = page.split('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
+const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', body)({}, {}, {}, {}, requiresReturnPack, returnPackFacts)
+test('return carton label identifies source with one carton scan and retains source check for SKU tab', () => {
+ assert.doesNotMatch(page, /退货箱唛换标必须先确认同一来源包裹\/箱/)
+ assert.match(page, /v-if="isReturnTask && taskTab !== 'carton'"/)
+ assert.match(page, /this.activeTarget = value === 'carton' \? 'quick-carton'/)
assert.match(page, /this\.taskTab === 'carton' \? 'quick-carton' : 'quick-sku'/)
})
+
+test('whole carton and full SKU relabel hide new box dimensions; partial SKU keeps isolation form', () => {
+ const value = { isReturnTask: true, current: { items: [{ returnCartonId: '1', cartonMark: 'C1', operationType: 'CARTON_LABEL', returnQuantity: 15 }], history: [] } }
+ assert.equal(component.computed.showReturnPack.call(value), false)
+ value.current.items[0].operationType = 'SKU_LABEL'
+ value.current.items[0].skuItems = [{ skuCode: 'A', quantity: 15 }]
+ assert.equal(component.computed.showReturnPack.call(value), false)
+ value.current.items[0].skuItems[0].quantity = 5
+ assert.equal(component.computed.showReturnPack.call(value), true)
+ assert.match(page, /ReturnRelabelPackWork v-if="showReturnPack"/)
+})
+
+test('one return carton scan saves label stage without a prior source scan; unrelated carton is rejected', async () => {
+ const calls = []
+ const item = { itemId: 'I1', cartonMark: 'C1', operationType: 'CARTON_LABEL', fbaCartonMark: 'FBA1' }
+ const result = { relabelNo: 'RL1', version: 1, items: [{ ...item, relabelledPackages: 1 }] }
+ const subject = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'scanRelabelCarton', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)(
+ {}, {}, {}, {},
+ async (no, payload) => { calls.push({ no, payload }); return result },
+ () => ({ value: 'KEY', signature: 'SIG' }), () => {},
+ { showModal: options => options.complete() }
+ )
+ const value = {
+ busy: false, current: { relabelNo: 'RL1', version: 0, items: [item] }, verifiedReturnCarton: '',
+ run: async (action, work) => work(), applyCurrent(task) { this.current = task },
+ setError(error) { this.error = error.message }
+ }
+ await subject.methods.submitCarton.call(value, ' C1\n')
+ assert.deepEqual(calls, [{ no: 'RL1', payload: { cartonMark: 'C1', expectedVersion: 0, idempotencyKey: 'KEY' } }])
+ assert.equal(value.current.version, 1)
+ assert.equal(value.lastCarton.fbaCartonMark, 'FBA1')
+ await subject.methods.submitCarton.call(value, 'OTHER')
+ assert.equal(calls.length, 1)
+ assert.match(value.error, /不属于本单/)
+})
test('mixed return carton keeps one physical package while SKU deductions use pieces', () => {
const task={items:[{returnCartonId:'1',cartonMark:'P1',operationType:'CARTON_AND_SKU',returnQuantity:5,skuItems:[{skuCode:'S',quantity:5}]}]}
assert.deepEqual(returnDeductions(task),[{cartonMark:'P1',skuCode:'S',quantity:5}])