This commit is contained in:
2026-10-12 15:56:16 +08:00
parent aebe4f9528
commit 785b4bca51
4 changed files with 90 additions and 10 deletions
+21 -2
View File
@@ -9,13 +9,14 @@
<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> <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>
<view v-if="rebindSelection"><text class="hint">正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}</text><button :disabled="busy" @tap="rebindSelection = null; targetCode = ''; target = null">取消改绑</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 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 class="source-identifier"><text class="source-caption">托盘号</text><text class="source-code">{{ row.palletCode }}</text></view></view> <view class="source-identifiers"><view class="source-identifier"><text class="source-caption">托盘号</text><text class="source-code">{{ row.palletCode }}</text></view></view>
<text class="source-quantity">{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}</text> <text class="source-quantity">{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}</text>
</view> </view>
<text v-if="!busy && !operableSourceRows.length" class="hint">当前没有可操作的来源托盘</text> <text v-if="!busy && !operableSourceRows.length" class="hint">当前没有可操作的来源托盘</text>
<ScanTargetInput v-if="!isReturnBulk" v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" /> <ScanTargetInput v-if="!isReturnBulk" v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
<text v-if="source" class="hint">已确认:{{ source.palletCode }}</text> <text v-if="source" class="hint">来源已确认:{{ source.palletCode }};尚未拆出,请完成贴标登记、核对拆出数量并扫描目标托盘,绑定成功后才保存拆托明细。</text>
<view v-if="selectedSource"> <view v-if="selectedSource">
<text v-if="isReturnBulk" class="hint">本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)</text> <text v-if="isReturnBulk" class="hint">本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)</text>
<template v-if="isReturnBulk"> <template v-if="isReturnBulk">
@@ -28,9 +29,14 @@
<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="marks = []" /><text>箱</text></view></template>
</view> </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> <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" /> <ScanTargetInput v-model="targetCode" name="relabel-target" label="目标托盘" placeholder="扫描同目的仓目标托盘或新托盘号" :active="active === 'target'" @activate="activate('target')" @submit="confirmTargetAndBind" />
<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> <text v-if="target" class="hint">已识别目标:{{ target.palletCode }},尚未绑定成功</text>
<view v-if="newPallet" class="hint"><text>托盘 {{ newPallet.palletCode }} 待绑定</text><button @tap="printPallet">查看 / 打印托盘标签</button></view> <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> <text v-if="message" class="hint">{{ message }}</text><text v-if="error" class="error">{{ error }}</text>
</view> </view>
</template> </template>
@@ -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: '' } }, 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() }, mounted() { this.loadSources() },
computed: { 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) }, 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) }, 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() { currentBindings() {
@@ -65,6 +76,13 @@ export default {
isReturnBulk() { return this.task.operationType === 'RETURN_BULK_SKU_LABEL' }, isReturnBulk() { return this.task.operationType === 'RETURN_BULK_SKU_LABEL' },
operableSourceRows() { return this.sourceRows.filter(row => Number(row.movablePackages) > 0) }, operableSourceRows() { return this.sourceRows.filter(row => Number(row.movablePackages) > 0) },
plannedPackages() { return this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 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)) }, 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() } 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') await this.resolve('source')
if (this.error || !this.source) return if (this.error || !this.source) return
const source = this.source; this.source = null 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: '知道了' }) }, showBindingMessage(title, content) { uni.showModal({ title, content, showCancel: false, confirmText: '知道了' }) },
async confirmTargetAndBind() { async confirmTargetAndBind() {
if (this.busy) return if (this.busy) return
if (this.rebindSelection) return this.submitRebind() if (this.rebindSelection) return this.submitRebind()
if (!this.isReturnBulk && this.selectedSource && this.samePallet(this.source, this.selectedSource)) { 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.selectedDestination = this.completedItems()[0]?.destinationFbaWarehouseCode || ''
this.marks = [] this.marks = []
this.fillBatch() this.fillBatch()
+6 -5
View File
@@ -24,7 +24,7 @@
<template v-else> <template v-else>
<view v-if="availableTabs.length > 1" class="tab-row" role="tablist"><button v-for="tab in availableTabs" :key="tab.key" class="tab-button" role="tab" :aria-selected="taskTab === tab.key" :class="{ active: taskTab === tab.key }" :disabled="busy" @tap="changeTab(tab.key)">{{ tab.name }}</button></view> <view v-if="availableTabs.length > 1" class="tab-row" role="tablist"><button v-for="tab in availableTabs" :key="tab.key" class="tab-button" role="tab" :aria-selected="taskTab === tab.key" :class="{ active: taskTab === tab.key }" :disabled="busy" @tap="changeTab(tab.key)">{{ tab.name }}</button></view>
<text class="card-title section-gap">{{ isReturnBulk ? '退货贴标 SKU 明细' : taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}</text> <text class="card-title section-gap">{{ isReturnBulk ? '退货贴标 SKU 明细' : taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}</text>
<view v-if="isReturnTask" class="section-gap"><text class="hint">先扫描来源包裹/箱,再核对商品 SKU、目标标签和实际数量。</text><ScanTargetInput name="returnCarton" v-model="returnCartonCode" label="来源包裹/箱码" placeholder="扫描真实来源包裹/箱" :active="activeTarget === 'return-carton'" @activate="activeTarget = 'return-carton'" @submit="verifyReturnCarton" /><button class="secondary" :disabled="busy" @tap="verifyReturnCarton(returnCartonCode)">确认来源包裹/箱</button><text v-if="verifiedReturnCarton" class="success-title">已确认:{{ verifiedReturnCarton }}</text></view> <view v-if="isReturnTask && taskTab !== 'carton'" class="section-gap"><text class="hint">先扫描来源包裹/箱,再核对商品 SKU、目标标签和实际数量。</text><ScanTargetInput name="returnCarton" v-model="returnCartonCode" label="来源包裹/箱码" placeholder="扫描真实来源包裹/箱" :active="activeTarget === 'return-carton'" @activate="activeTarget = 'return-carton'" @submit="verifyReturnCarton" /><button class="secondary" :disabled="busy" @tap="verifyReturnCarton(returnCartonCode)">确认来源包裹/箱</button><text v-if="verifiedReturnCarton" class="success-title">已确认:{{ verifiedReturnCarton }}</text></view>
<view v-if="isReturnBulk" class="section-gap"><ScanTargetInput name="returnSku" v-model="returnSkuCode" label="商品 SKU" placeholder="扫描本来源待换标 SKU" :active="activeTarget === 'return-sku'" @activate="activeTarget = 'return-sku'" @submit="verifyReturnSku" /><button class="secondary" @tap="verifyReturnSku(returnSkuCode)">核对商品 SKU</button><text v-for="group in returnBulkSkuGroups" :key="group.key" class="meta-line">{{ group.skuCode }} · 汇总待贴 {{ group.remaining }} 件(不自动分配)</text></view> <view v-if="isReturnBulk" class="section-gap"><ScanTargetInput name="returnSku" v-model="returnSkuCode" label="商品 SKU" placeholder="扫描本来源待换标 SKU" :active="activeTarget === 'return-sku'" @activate="activeTarget = 'return-sku'" @submit="verifyReturnSku" /><button class="secondary" @tap="verifyReturnSku(returnSkuCode)">核对商品 SKU</button><text v-for="group in returnBulkSkuGroups" :key="group.key" class="meta-line">{{ group.skuCode }} · 汇总待贴 {{ group.remaining }} 件(不自动分配)</text></view>
<template v-if="isReturnBulk"><view v-for="item in returnBulkSources" :key="item.itemId" class="item-card section-gap"><text class="business-code">{{ item.cartonMark }} · {{ item.skuCode }}</text><text class="meta-line">目标商品标签:{{ item.newSkuCode || '未提供,禁止登记' }} · 本来源剩余 {{ remainingReturnQuantity(item) }} 件</text><ScanTargetInput :name="'target-' + item.itemId" v-model="returnTargetLabels[item.itemId]" label="目标商品 SKU 标签" placeholder="扫描目标商品标签" :active="activeTarget === 'target-' + item.itemId" @activate="activeTarget = 'target-' + item.itemId" /><input v-model="bulkQuantities[item.itemId]" type="number" placeholder="必填:本来源本次实际贴标件数" /><button class="secondary" :disabled="busy" @tap="fillAllReturn(item)">本来源全部(需确认)</button><button class="primary" :disabled="busy" @tap="completeBulkSku(item)">登记本来源实际数量</button></view></template> <template v-if="isReturnBulk"><view v-for="item in returnBulkSources" :key="item.itemId" class="item-card section-gap"><text class="business-code">{{ item.cartonMark }} · {{ item.skuCode }}</text><text class="meta-line">目标商品标签:{{ item.newSkuCode || '未提供,禁止登记' }} · 本来源剩余 {{ remainingReturnQuantity(item) }} 件</text><ScanTargetInput :name="'target-' + item.itemId" v-model="returnTargetLabels[item.itemId]" label="目标商品 SKU 标签" placeholder="扫描目标商品标签" :active="activeTarget === 'target-' + item.itemId" @activate="activeTarget = 'target-' + item.itemId" /><input v-model="bulkQuantities[item.itemId]" type="number" placeholder="必填:本来源本次实际贴标件数" /><button class="secondary" :disabled="busy" @tap="fillAllReturn(item)">本来源全部(需确认)</button><button class="primary" :disabled="busy" @tap="completeBulkSku(item)">登记本来源实际数量</button></view></template>
<view v-if="taskTab === 'carton' && !isReturnBulk" class="section-gap"><ScanTargetInput name="quickCarton" v-model="quickCarton" label="仓库唛头" placeholder="扫描箱唛自动确认,手输后按完成" :active="activeTarget === 'quick-carton'" @activate="activeTarget = 'quick-carton'" @submit="submitCarton" /></view> <view v-if="taskTab === 'carton' && !isReturnBulk" class="section-gap"><ScanTargetInput name="quickCarton" v-model="quickCarton" label="仓库唛头" placeholder="扫描箱唛自动确认,手输后按完成" :active="activeTarget === 'quick-carton'" @activate="activeTarget = 'quick-carton'" @submit="submitCarton" /></view>
@@ -46,8 +46,9 @@
<button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button> <button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button>
</template> </template>
<view class="section-gap"> <view class="section-gap">
<ReturnRelabelPackWork v-if="isReturnTask" :key="current.relabelNo + ':' + (current.reviewedAt || 'INITIAL')" :task="current" :locked="busy" @saved="applyCurrent" @refresh="reloadCurrent" @error="setError" @busy="busy = $event" /> <ReturnRelabelPackWork v-if="showReturnPack" :key="current.relabelNo + ':' + (current.reviewedAt || 'INITIAL')" :task="current" :locked="busy" @saved="applyCurrent" @refresh="reloadCurrent" @error="setError" @busy="busy = $event" />
<RelabelPalletWork v-if="requiresPalletBinding && (taskTab === 'carton' || isReturnTask)" ref="palletWork" :key="current.relabelNo" :task="palletTask" :allow-disassemble="true" @activate="activeTarget = 'pallet-work'" @busy="busy = $event" @saved="reloadCurrent" /> <RelabelPalletWork v-if="requiresPalletBinding && (taskTab === 'carton' || isReturnTask)" ref="palletWork" :key="current.relabelNo" :task="palletTask" :allow-disassemble="true" @activate="activeTarget = 'pallet-work'" @busy="busy = $event" @saved="reloadCurrent" />
<button class="primary" :disabled="busy || Boolean(completionBlockReason)" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : allProcessed ? '完成现场换标并等待复核' : '手动完成并登记异常' }}</button>
<view class="row-head"><text class="card-title">完成照片</text><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view> <view class="row-head"><text class="card-title">完成照片</text><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view>
<button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto">拍照或从相册选择</button> <button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto">拍照或从相册选择</button>
<view class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile"> <view class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile">
@@ -58,7 +59,6 @@
</view></view> </view></view>
<textarea v-model="remark" class="reason-input section-gap" maxlength="900" :placeholder="allProcessed ? '完成备注(选填)' : '必填:未全部扫描完成的仓库异常作业备注'" /> <textarea v-model="remark" class="reason-input section-gap" maxlength="900" :placeholder="allProcessed ? '完成备注(选填)' : '必填:未全部扫描完成的仓库异常作业备注'" />
<view v-if="completionBlockReason" class="error-box section-gap"><text class="error-title">还不能完成换标</text><text>{{ completionBlockReason }}</text></view> <view v-if="completionBlockReason" class="error-box section-gap"><text class="error-title">还不能完成换标</text><text>{{ completionBlockReason }}</text></view>
<button class="primary" :disabled="busy || Boolean(completionBlockReason)" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : allProcessed ? '完成现场换标并等待复核' : '手动完成并登记异常' }}</button>
</view> </view>
</template> </template>
</template> </template>
@@ -104,6 +104,7 @@
return { ...this.current, operationType: 'CARTON_LABEL', items: [...boxes.values()] } return { ...this.current, operationType: 'CARTON_LABEL', items: [...boxes.values()] }
}, },
isReturnTask() { return (this.current?.items || []).some(item => item.returnCartonId) }, 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 }, 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) }, 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))] }, fbaDestinations() { return [...new Set((this.current?.items || []).map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
@@ -278,7 +279,7 @@
if (!mark || this.busy || !this.current) return if (!mark || this.busy || !this.current) return
const item = this.current.items.find((row) => row.cartonMark === mark && row.operationType !== 'SKU_LABEL') const item = this.current.items.find((row) => row.cartonMark === mark && row.operationType !== 'SKU_LABEL')
if (!item) return this.setError(new Error('该仓库唛头不属于本单箱唛换标明细')) 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 payload = { relabelNo: this.current.relabelNo, cartonMark: mark, expectedVersion: this.current.version }
const key = warehouseIdempotencyKey('relabel', 'carton-scan', payload) const key = warehouseIdempotencyKey('relabel', 'carton-scan', payload)
await this.run('carton-scan', async () => { 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 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) }, 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 }, 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 }] }, 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' }, 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' }, focusNextSku(index) { const next = index + 1; this.activeTarget = next < this.unitDraft.skuVerifications.length ? `sku-new-${next}` : 'old' },
+21
View File
@@ -59,6 +59,7 @@ test('pallet comparison tolerates mixed API versions and canonical code formatti
test('quantity disassembly has no extra carton scanner', () => { test('quantity disassembly has no extra carton scanner', () => {
assert.doesNotMatch(source, /relabel-move-carton/) assert.doesNotMatch(source, /relabel-move-carton/)
assert.match(source, /actualSource:/) assert.match(source, /actualSource:/)
assert.match(source, /来源已确认:.*尚未拆出.*绑定成功后才保存拆托明细/)
assert.match(source, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/) 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() await value.confirmTargetAndBind()
assert.equal(binds, 1) assert.equal(binds, 1)
assert.match(source, /@submit="confirmTargetAndBind"/) assert.match(source, /@submit="confirmTargetAndBind"/)
assert.match(source, /@tap="confirmTargetAndBind"/)
assert.doesNotMatch(source, /@tap="bind"/) 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 () => { test('source scan asks before accepting a listed pallet and rejects unknown pallets', async () => {
const value = context() const value = context()
value.sourceRows = [{ positionId: '1', palletCode: 'SOURCE', movablePackages: 2 }] value.sourceRows = [{ positionId: '1', palletCode: 'SOURCE', movablePackages: 2 }]
+42 -3
View File
@@ -3,11 +3,50 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8') 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 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')) const { returnDeductions, requiresReturnPack, returnPackFacts } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
test('return carton label path requires source confirmation before carton scan', () => { const body = page.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
assert.match(page, /退货箱唛换标必须先确认同一来源包裹\/箱/) 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'/) 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', () => { 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}]}]} 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}]) assert.deepEqual(returnDeductions(task),[{cartonMark:'P1',skuCode:'S',quantity:5}])