优化
This commit is contained in:
@@ -4,6 +4,7 @@ const BASE = '/pda/v1/last-leg/relabel-tasks'
|
||||
const encoded = (value) => encodeURIComponent(String(value || '').trim())
|
||||
export const scanRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/skus/scan`, payload))
|
||||
export const completeReturnRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete`, payload))
|
||||
export const packReturnRelabelCartons = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-cartons/pack`, payload))
|
||||
export const completeReturnRelabelSkuTotal = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete-total`, payload))
|
||||
|
||||
function asList(value) { return Array.isArray(value) ? value : [] }
|
||||
@@ -11,6 +12,7 @@ function asList(value) { return Array.isArray(value) ? value : [] }
|
||||
export function normalizeRelabel(value = {}) {
|
||||
const items = asList(value.items).map((item) => ({
|
||||
...item, itemId: String(item.itemId || ''), skuCode: String(item.skuCode || ''),
|
||||
returnCartonId: item.returnCartonId == null ? null : String(item.returnCartonId), returnQuantity: Number(item.returnQuantity || 0),
|
||||
newSkuCode: String(item.newSkuCode || ''), operationType: String(item.operationType || 'CARTON_LABEL'), expectedPackages: Number(item.expectedPackages || 0),
|
||||
relabelledPackages: Number(item.relabelledPackages || 0), skippedPackages: Number(item.skippedPackages || 0),
|
||||
exceptionPackages: Number(item.exceptionPackages || 0)
|
||||
|
||||
@@ -108,3 +108,7 @@ export async function mergeReturnCartons(request) {
|
||||
export async function getReturnCartonLabel(cartonMark) {
|
||||
return normalizeReturnLabel(await overseasRequest('GET', `${BASE}/cartons/${encoded(cartonMark)}/label`))
|
||||
}
|
||||
|
||||
export async function confirmReturnLabel(cartonMark) {
|
||||
return normalizeReturnLabel(await overseasRequest('POST', `${BASE}/cartons/${encoded(cartonMark)}/confirm-label`))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<view>
|
||||
<view class="work-card">
|
||||
<text class="card-title">箱与箱合并</text>
|
||||
<text class="card-title">包裹/箱合箱</text>
|
||||
<text v-if="plan" class="business-code">{{ plan.planNo }}</text>
|
||||
<text v-if="plan && plan.remark" class="hint plan-remark">合箱备注:{{ plan.remark }}</text>
|
||||
<view class="choice-row"><button class="secondary" @tap="choose('NEW')">{{ mode === 'NEW' ? '✓ ' : '' }}新增箱子</button><button class="secondary" @tap="choose('EXISTING')">{{ mode === 'EXISTING' ? '✓ ' : '' }}使用已有箱子</button></view>
|
||||
<view class="choice-row"><button class="secondary" @tap="choose('NEW')">{{ mode === 'NEW' ? '✓ ' : '' }}新增箱子</button><button v-if="!plan || plan.mode !== 'SKU'" class="secondary" @tap="choose('EXISTING')">{{ mode === 'EXISTING' ? '✓ ' : '' }}使用已有箱子</button></view>
|
||||
<text class="hint">{{ mode === 'NEW' ? '从下方来源箱选择 SKU 和扣除数量,完成后生成新箱唛。' : '扫描来源箱,再扫描另一只箱作为目标;目标箱原有库存不扣除。' }}</text>
|
||||
<template v-if="mode === 'EXISTING' || (mode === 'NEW' && !plan)">
|
||||
<template v-if="mode">
|
||||
<ScanTargetInput name="source" v-model="sourceCode" label="来源箱唛" placeholder="扫描来源箱唛" :active="active === 'source'" @activate="active = 'source'" @submit="scanSource" />
|
||||
<button class="secondary" :disabled="busy || !mode || !sourceCode.trim()" @tap="scanSource(sourceCode)">{{ mode === 'EXISTING' && sources.length ? '确认更换来源箱' : '确认来源箱' }}</button>
|
||||
<view v-if="mode === 'EXISTING'" class="confirmed-sources">
|
||||
@@ -36,7 +36,8 @@
|
||||
</view>
|
||||
<view v-for="box in sources" :key="box.cartonMark" class="item-card section-gap">
|
||||
<view class="row-head"><text class="business-code">{{ box.cartonMark }}</text><button v-if="!plan" class="link-button" @tap="remove(box.cartonMark)">移除</button></view>
|
||||
<view v-for="item in box.items" :key="item.skuCode" class="source-quantity"><text class="meta-line">{{ item.skuCode }} · 可扣 {{ item.quantity }} 件</text><input class="small-input" type="number" :value="deduction(box.cartonMark,item.skuCode)" placeholder="扣除数量" @input="setDeduction(box.cartonMark,item.skuCode,$event.detail.value)" /></view>
|
||||
<template v-if="!plan"><button class="secondary" @tap="printSource(box.cartonMark)">补打包裹/箱标签</button><ReturnLabelConfirmation ref="sourceLabels" :carton-mark="box.cartonMark" /></template>
|
||||
<view v-for="item in box.items" :key="item.skuCode" class="source-quantity"><text class="meta-line">{{ item.skuCode }} · 可扣 {{ item.quantity }} 件</text><input class="small-input" type="number" :disabled="plan?.mode === 'SKU'" :value="deduction(box.cartonMark,item.skuCode)" placeholder="扣除数量" @input="setDeduction(box.cartonMark,item.skuCode,$event.detail.value)" /></view>
|
||||
</view>
|
||||
<button class="primary" :disabled="!canComplete" @tap="complete">{{ busy ? '处理中…' : '确认合箱' }}</button>
|
||||
</view>
|
||||
@@ -45,16 +46,18 @@
|
||||
|
||||
<script>
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes } from '@/api/return-packing.js'
|
||||
import ReturnLabelConfirmation from '@/components/ReturnLabelConfirmation.vue'
|
||||
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes, getReturnCartonLabel } from '@/api/return-packing.js'
|
||||
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
|
||||
export default {
|
||||
components: { ScanTargetInput },
|
||||
components: { ScanTargetInput, ReturnLabelConfirmation },
|
||||
props: { plan: { type: Object, default: null }, initialSource: { type: Object, default: null } },
|
||||
emits: ['completed', 'error', 'busy-change'],
|
||||
data() { return { mode:'', dimensions:{length:'',width:'',height:'',weight:''}, fields:[{key:'length',label:'长 cm'},{key:'width',label:'宽 cm'},{key:'height',label:'高 cm'},{key:'weight',label:'重量 kg'}], sourceCode: '', targetCode: '', sources: [], target: null, deductions: {}, active: 'source', busy: false } },
|
||||
computed: { volume() { const value = Number(this.dimensions.length) * Number(this.dimensions.width) * Number(this.dimensions.height) / 1000000; return Number.isFinite(value) && value > 0 ? value.toFixed(6) : '0.000000' }, canComplete() { return !this.busy && this.plannedItems().length > 0 && (this.mode === 'NEW' ? this.fields.every(f => Number(this.dimensions[f.key]) > 0) : !!this.target && this.target.cartonMark === this.targetCode.trim() && this.sources[0]?.cartonMark === this.sourceCode.trim()) } },
|
||||
computed: { volume() { const value = Number(this.dimensions.length) * Number(this.dimensions.width) * Number(this.dimensions.height) / 1000000; return Number.isFinite(value) && value > 0 ? value.toFixed(6) : '0.000000' }, canComplete() { return !this.busy && (!this.plan || this.plan.mode !== 'SKU' || this.plan.sourceCartonMarks.every(mark => this.sources.some(box => box.cartonMark === mark))) && this.plannedItems().length > 0 && (this.mode === 'NEW' ? this.fields.every(f => Number(this.dimensions[f.key]) > 0) : !!this.target && this.target.cartonMark === this.targetCode.trim() && this.sources[0]?.cartonMark === this.sourceCode.trim()) } },
|
||||
async mounted() {
|
||||
if (!this.plan && this.initialSource) { this.choose('EXISTING'); this.sources = [this.initialSource]; this.sourceCode = this.initialSource.cartonMark; this.active = 'target'; return }
|
||||
if (!this.plan && this.initialSource) { this.choose('NEW'); this.sources = [this.initialSource]; this.sourceCode = ''; this.active = 'source'; return }
|
||||
this.choose('NEW')
|
||||
this.targetCode = warehouseWork.returnPacking.targetCode || ''
|
||||
const marks = this.plan?.sourceCartonMarks || warehouseWork.returnPacking.sourceCartonMarks || []
|
||||
if (this.plan) {
|
||||
@@ -65,7 +68,7 @@ export default {
|
||||
} else for (const mark of marks) await this.scanSource(mark)
|
||||
},
|
||||
methods: {
|
||||
choose(mode) { if(this.busy || this.mode === mode) return; const current = !this.plan ? (this.sources[0] || this.initialSource) : null; this.mode=mode; this.sources=mode === 'NEW' && this.plan ? (this.plan.sources || []).map(box => ({...box,customerId:this.plan.customerId})) : current ? [current] : []; this.target=null; this.targetCode=''; this.sourceCode=mode === 'EXISTING' && current ? current.cartonMark : ''; this.deductions={}; this.active=mode === 'NEW' && this.plan ? '' : mode === 'EXISTING' && current ? 'target' : 'source' },
|
||||
choose(mode) { if(this.busy || this.mode === mode) return; const current = !this.plan ? (this.sources[0] || this.initialSource) : null; this.mode=mode; this.sources=current ? [current] : []; this.target=null; this.targetCode=''; this.sourceCode=mode === 'EXISTING' && current ? current.cartonMark : ''; this.deductions={}; this.active=mode === 'EXISTING' && current ? 'target' : 'source' },
|
||||
save() { updateWarehouseWork('returnPacking', { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCode: this.targetCode }) },
|
||||
remove(mark) { this.sources = this.sources.filter(box => box.cartonMark !== mark); this.save() },
|
||||
async scanSource(raw) {
|
||||
@@ -85,6 +88,7 @@ export default {
|
||||
if (this.mode === 'EXISTING') {
|
||||
this.sources = [box]; this.sourceCode = mark; this.deductions = {}; this.target = null; this.targetCode = ''
|
||||
} else { this.sources.push(box); this.sourceCode = '' }
|
||||
if (this.plan?.mode === 'SKU') this.deductions = { ...this.deductions, [mark]: Object.fromEntries((box.items || []).map(item => [item.skuCode, Number(item.quantity)])) }
|
||||
this.save(); this.active = this.mode === 'NEW' ? 'source' : 'target'
|
||||
})
|
||||
},
|
||||
@@ -111,7 +115,18 @@ export default {
|
||||
this.target = box; this.save()
|
||||
})
|
||||
},
|
||||
acceptScan(value) { if (!this.active) return; return this.active === 'source' ? this.scanSource(value) : this.scanTarget(value) },
|
||||
acceptScan(value) { if (!this.plan && this.sources.some(box => box.cartonMark === String(value).trim())) { for (const label of [].concat(this.$refs?.sourceLabels || [])) label.acceptScan(value); return } if (!this.active) return; return this.active === 'source' ? this.scanSource(value) : this.scanTarget(value) },
|
||||
async printSource(mark) {
|
||||
// #ifdef H5
|
||||
const tab = window.open('', '_blank')
|
||||
if (!tab) { this.$emit('error', new Error('请允许打开标签打印窗口')); return }
|
||||
try { const label = await getReturnCartonLabel(mark); tab.document.write(label.printableHtml); tab.document.close(); tab.focus(); tab.print() }
|
||||
catch (error) { tab.close(); this.$emit('error', error) }
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
this.$emit('error', new Error('当前设备未配置打印服务'))
|
||||
// #endif
|
||||
},
|
||||
deduction(mark, sku) { return this.deductions?.[mark]?.[sku] || '' },
|
||||
setDeduction(mark, sku, raw) {
|
||||
const box = this.sources.find(item => item.cartonMark === mark)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<view class="label-confirmation">
|
||||
<text v-if="confirmed">标签已扫码确认,可按该包裹/箱号出库</text>
|
||||
<template v-else>
|
||||
<ScanTargetInput v-model="code" label="确认已贴标签" placeholder="贴好后扫描此包裹/箱标签" @submit="confirm" />
|
||||
<button class="secondary" :disabled="busy || !code.trim()" @tap="confirm(code)">确认贴标</button>
|
||||
<text v-if="error">{{ error }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { confirmReturnLabel } from '@/api/return-packing.js'
|
||||
export default {
|
||||
components: { ScanTargetInput },
|
||||
props: { cartonMark: { type: String, required: true } },
|
||||
data() { return { code: '', confirmed: false, busy: false, error: '' } },
|
||||
methods: {
|
||||
acceptScan(value) { if (String(value).trim() === this.cartonMark) return this.confirm(value) },
|
||||
async confirm(raw) {
|
||||
if (this.busy || this.confirmed) return
|
||||
const mark = String(raw?.detail?.value ?? raw ?? this.code).trim()
|
||||
if (mark !== this.cartonMark) { this.error = '请扫描当前包裹/箱的标签'; return }
|
||||
this.busy = true; this.error = ''
|
||||
try { await confirmReturnLabel(mark); this.confirmed = true }
|
||||
catch (error) { this.error = error?.message || '贴标确认失败,请重试' }
|
||||
finally { this.busy = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<view class="section-gap">
|
||||
<text class="card-title">换标商品独立合箱</text><text class="hint">部分换标须装入独立新箱;来源余量留在原包裹/箱。所有来源登记成功后才可生成新箱。</text>
|
||||
<view v-for="row in deductions" :key="`${row.cartonMark}:${row.skuCode}`" class="meta-line">{{ row.cartonMark }} · {{ row.skuCode }} · {{ row.quantity }} 件</view>
|
||||
<template v-if="!facts.length"><view class="dimensions"><label v-for="field in fields" :key="field.key"><text>{{ field.label }}</text><input v-model="dimensions[field.key]" type="digit" placeholder="请输入" /></label></view><button class="primary" :disabled="!ready" @tap="pack">{{ busy ? '生成中…' : '生成独立新箱及标签' }}</button></template>
|
||||
<view v-for="fact in facts" :key="fact.cartonMark"><text class="business-code">新箱:{{ fact.cartonMark }} · {{ fact.quantity }} 件</text><button class="secondary" @tap="print(fact.label)">查看 / 打印新箱识别标签</button><text class="hint">此标签用于识别仓库包裹/箱;商品目标 SKU 标签请按上方换标明细核对。</text></view>
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
import { packReturnRelabelCartons } from '@/api/relabel.js'
|
||||
import { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } from '@/utils/return-relabel.js'
|
||||
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
|
||||
export default {
|
||||
props: { task: { type: Object, required: true }, locked: { type: Boolean, default: false } }, emits: ['saved', 'error', 'busy', 'refresh'],
|
||||
data() { return { busy: false, dimensions: { length: '', width: '', height: '', weight: '' }, fields: [{ key: 'length', label: '长 cm' }, { key: 'width', label: '宽 cm' }, { key: 'height', label: '高 cm' }, { key: 'weight', label: '重量 kg' }] } },
|
||||
computed: {
|
||||
deductions() { return returnDeductions(this.task) }, facts() { return returnPackFacts(this.task) },
|
||||
ready() { const items = returnSelections(this.task); return !this.locked && !this.busy && requiresReturnPack(this.task) && !this.facts.length && items.length === this.task.items.length && items.length > 0 && items.every(item => Number(item.relabelledPackages) === Number(item.expectedPackages) && !Number(item.skippedPackages) && !Number(item.exceptionPackages)) && this.deductions.every(row => row.cartonMark && row.skuCode && Number.isSafeInteger(row.quantity) && row.quantity > 0) && this.fields.every(field => Number.isFinite(Number(this.dimensions[field.key])) && Number(this.dimensions[field.key]) > 0) }
|
||||
},
|
||||
methods: {
|
||||
async pack() {
|
||||
if (!this.ready) return
|
||||
const payload = { sourceCartonMarks: [...new Set(this.deductions.map(row => row.cartonMark))], targetCartonMark: null, sourceDeductions: this.deductions, ...Object.fromEntries(this.fields.map(field => [field.key, Number(this.dimensions[field.key])])) }
|
||||
const key = warehouseIdempotencyKey('relabel', 'return-pack', { relabelNo: this.task.relabelNo, round: this.task.reviewedAt || 'INITIAL', ...payload })
|
||||
this.busy = true; this.$emit('busy', true)
|
||||
try { const result = await packReturnRelabelCartons(this.task.relabelNo, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.$emit('saved', result) } catch (error) { if (['409', 'A0409', 'B0409'].includes(String(error?.code || ''))) { releaseWarehouseKey('relabel', key.signature); this.$emit('refresh') } this.$emit('error', error) } finally { this.busy = false; this.$emit('busy', false) }
|
||||
},
|
||||
print(label) {
|
||||
// #ifdef H5
|
||||
if (!label?.printableHtml) return this.$emit('error', new Error('服务端未返回可打印的新箱标签'))
|
||||
const tab = window.open('', '_blank'); if (!tab) return this.$emit('error', new Error('请允许打开标签打印窗口'))
|
||||
tab.document.write(label.printableHtml); tab.document.close(); tab.focus(); tab.print()
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
this.$emit('error', new Error('当前设备未配置打印服务,生成标签不代表打印完成'))
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>.dimensions{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin:12px 0}.dimensions input{border:1px solid #cbd8e8;padding:8px}.hint,.meta-line{display:block;margin:8px 0}</style>
|
||||
@@ -24,17 +24,14 @@
|
||||
<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>
|
||||
<text class="card-title section-gap">{{ isReturnBulk ? '退货贴标 SKU 明细' : taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}</text>
|
||||
<view v-if="isReturnBulk" class="section-gap"><text class="meta-line">先扫描实际贴标的 SKU,核对数量后登记;无需扫描箱唛。</text><ScanTargetInput name="returnSku" v-model="returnSkuCode" label="扫描退货 SKU" placeholder="扫描或输入待贴标 SKU" :active="activeTarget === 'return-sku'" @activate="activeTarget = 'return-sku'" @submit="verifyReturnSku" /><button class="secondary" :disabled="busy || !returnSkuCode.trim()" @tap="verifyReturnSku(returnSkuCode)">识别 SKU</button><text v-if="verifiedReturnSkuKey" class="success-title">SKU 已识别,请确认实际贴标数量后登记</text></view>
|
||||
<view v-if="isReturnBulk" class="item-list section-gap"><view v-for="group in returnBulkSkuGroups" :key="group.key" class="item-card" :class="{ done: group.remaining === 0 }">
|
||||
<view class="row-head"><view><text class="business-code">{{ group.skuCode }}</text><text class="meta-line">FBA 目的仓:{{ group.destinationFbaWarehouseCode || '—' }}</text></view><text class="status-pill">剩余 {{ group.remaining }} 件</text></view>
|
||||
<view class="metric-row"><view class="metric"><strong>{{ group.expectedPackages }}</strong><text>贴标预报</text></view><view class="metric"><strong>{{ group.relabelledPackages }}</strong><text>已贴</text></view></view>
|
||||
<view class="source-allocation"><text v-if="isReturnBulk" class="source-title">来源托盘换标数量</text><text v-else class="source-title">来源托盘拆箱数量</text><view v-for="source in group.sources" :key="source.itemId" class="source-row"><text>{{ source.palletCode || '未记录托盘' }}</text><strong>{{ isReturnBulk ? '换标' : '拆箱' }} {{ source.expectedPackages }} 件</strong></view><view class="source-total"><text>合计{{ isReturnBulk ? '换标' : '拆箱' }}</text><strong>{{ group.expectedPackages }} 件</strong></view></view>
|
||||
<view v-if="group.remaining > 0"><input v-model="bulkQuantities[group.key]" class="bulk-quantity" type="number" :placeholder="`本次贴标数量(默认剩余 ${group.remaining} 件)`" /><button class="primary bulk-total-button" :disabled="busy || verifiedReturnSkuKey !== group.key || returnSkuCode.trim().toUpperCase() !== group.skuCode.toUpperCase()" @tap="completeBulkSku(group)">{{ busyAction === 'return-bulk-sku' ? '提交中…' : `登记本次贴标(默认 ${group.remaining} 件)` }}</button></view>
|
||||
</view></view>
|
||||
<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="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>
|
||||
<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 === 'sku'" class="section-gap">
|
||||
<template v-if="isReturnTask"><ScanTargetInput name="quickTarget" v-model="quickTargetLabel" label="目标商品 SKU 标签" placeholder="扫描目标商品标签" :active="activeTarget === 'quick-target'" @activate="activeTarget = 'quick-target'" /><input v-model="quickActualQuantity" type="number" placeholder="必填:本来源该 SKU 实际件数" /></template>
|
||||
<ScanTargetInput name="quickSku" v-model="quickSku" label="旧 SKU 标签" placeholder="扫描旧 SKU 自动确认" :active="activeTarget === 'quick-sku'" @activate="activeTarget = 'quick-sku'" @submit="submitSku" />
|
||||
<text v-if="skuChoices.length" class="hint">同一旧 SKU 有多条明细,请核对实际箱选择本次作业:</text>
|
||||
<text v-if="skuChoices.length" class="hint">请核对来源箱、目标商品标签和实际件数,选择明细登记:</text>
|
||||
<button v-for="row in skuChoices" :key="`${row.item.itemId}:${row.index}`" :disabled="busy" @tap="commitSku(row)">{{ row.item.cartonMark }}:{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }} · {{ row.sku.quantity }} 件</button>
|
||||
<view v-for="row in pendingSkuRows" :key="`${row.item.itemId}:${row.index}`" class="item-card sku-detail-card"><text class="business-code">{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }}</text><text class="meta-line">{{ row.item.cartonMark }} · {{ row.sku.quantity }} 件</text><button class="link-button" @tap="selectItem(row.item); changeAction('EXCEPTION')">异常 / 跳过</button></view>
|
||||
</view>
|
||||
@@ -49,9 +46,8 @@
|
||||
<button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button>
|
||||
</template>
|
||||
<view class="section-gap">
|
||||
<!-- Return SKU relabel still requires moving the received carton from the
|
||||
source pallet to a new pallet carrying the FBA destination. -->
|
||||
<RelabelPalletWork v-if="taskTab === 'carton' || isReturnBulk" ref="palletWork" :key="current.relabelNo" :task="current" :allow-disassemble="!isReturnBulk" @activate="activeTarget = 'pallet-work'" @busy="busy = $event" @saved="reloadCurrent" />
|
||||
<ReturnRelabelPackWork v-if="isReturnTask" :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" />
|
||||
<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>
|
||||
<view class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile">
|
||||
@@ -79,22 +75,37 @@
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ReturnRelabelPackWork from '@/components/ReturnRelabelPackWork.vue'
|
||||
import { requiresReturnPack, returnPackFacts } from '@/utils/return-relabel.js'
|
||||
import RelabelPalletWork from '@/components/RelabelPalletWork.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSkuTotal, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSku, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import { isRelabelFundsBlocked, relabelErrorMessage } from '@/utils/relabel-error.js'
|
||||
|
||||
const STAGES = { relabelReceive: 'receive', relabelExecute: 'execute', relabelReview: 'review' }
|
||||
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput, RelabelPalletWork },
|
||||
data() { return { user: session.currentUser, stage: 'receive', entryTaskNo: '', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, returnSkuCode: '', verifiedReturnSkuKey: '', bulkQuantities: {}, activeTarget: 'task', loading: false, busy: false, busyAction: '', error: null, successMessage: '', exceptionCode: 'OTHER', actionModes: [{ key: 'RELABEL', name: '正常换标' }, { key: 'SKIP', name: '跳过' }, { key: 'EXCEPTION', name: '异常' }], exceptionCodes: [{ key: 'DAMAGED', name: '破损' }, { key: 'MISSING_LABEL', name: '缺标签' }, { key: 'LABEL_MISMATCH', name: '标签不符' }, { key: 'UNREADABLE', name: '无法识别' }, { key: 'OTHER', name: '其他' }] } },
|
||||
components: { PortalHeader, ScanTargetInput, RelabelPalletWork, ReturnRelabelPackWork },
|
||||
data() { return { user: session.currentUser, stage: 'receive', entryTaskNo: '', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, returnCartonCode: '', verifiedReturnCarton: '', returnTargetLabels: {}, quickTargetLabel: '', quickActualQuantity: '', returnSkuCode: '', verifiedReturnSkuKey: '', bulkQuantities: {}, activeTarget: 'task', loading: false, busy: false, busyAction: '', error: null, successMessage: '', exceptionCode: 'OTHER', actionModes: [{ key: 'RELABEL', name: '正常换标' }, { key: 'SKIP', name: '跳过' }, { key: 'EXCEPTION', name: '异常' }], exceptionCodes: [{ key: 'DAMAGED', name: '破损' }, { key: 'MISSING_LABEL', name: '缺标签' }, { key: 'LABEL_MISMATCH', name: '标签不符' }, { key: 'UNREADABLE', name: '无法识别' }, { key: 'OTHER', name: '其他' }] } },
|
||||
computed: {
|
||||
palletTask() {
|
||||
if (!this.isReturnTask) return this.current
|
||||
const boxes = new Map()
|
||||
for (const item of this.current.items || []) {
|
||||
const previous = boxes.get(item.cartonMark)
|
||||
const done = Number(item.relabelledPackages) === Number(item.expectedPackages)
|
||||
boxes.set(item.cartonMark, { ...item, operationType: 'CARTON_LABEL', expectedPackages: 1, relabelledPackages: done && (!previous || previous.relabelledPackages === 1) ? 1 : 0 })
|
||||
}
|
||||
return { ...this.current, operationType: 'CARTON_LABEL', items: [...boxes.values()] }
|
||||
},
|
||||
isReturnTask() { return (this.current?.items || []).some(item => item.returnCartonId) },
|
||||
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))] },
|
||||
isReturnBulk() { return this.current?.operationType === 'RETURN_BULK_SKU_LABEL' },
|
||||
returnBulkSkuGroups() {
|
||||
@@ -109,7 +120,7 @@
|
||||
group.relabelledPackages += Number(item.relabelledPackages || 0)
|
||||
group.skippedPackages += Number(item.skippedPackages || 0)
|
||||
group.exceptionPackages += Number(item.exceptionPackages || 0)
|
||||
group.sources.push({ itemId: item.itemId, palletCode: item.cartonMark, expectedPackages: Number(item.expectedPackages || 0) })
|
||||
group.sources.push({ itemId: item.itemId, cartonMark: item.cartonMark, expectedPackages: Number(item.expectedPackages || 0) })
|
||||
}
|
||||
return [...groups.values()].map(group => ({ ...group, remaining: Math.max(0, group.expectedPackages - group.relabelledPackages - group.skippedPackages - group.exceptionPackages) }))
|
||||
},
|
||||
@@ -137,14 +148,15 @@
|
||||
completionBlockReason() {
|
||||
if (!this.current) return '请先选择换标任务'
|
||||
if (!this.allProcessed && !this.remark.trim()) return '仍有未扫描明细,请继续处理;需要异常完工时必须填写异常作业备注'
|
||||
const requiresPalletBinding = (this.current.items || []).some((item) => ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
|
||||
if (this.isReturnTask && requiresReturnPack(this.current) && !returnPackFacts(this.current).length) return '部分换标须先完成全部来源登记并生成独立新箱'
|
||||
const requiresPalletBinding = this.requiresPalletBinding && (this.current.items || []).some((item) => item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
|
||||
if (requiresPalletBinding && Number(this.current.relabelledPackages || 0) > 0) {
|
||||
const round = this.current.reviewedAt || 'INITIAL'
|
||||
const marks = new Set((this.current.history || []).filter((entry) => entry.action === 'PALLET_BIND').flatMap((entry) => {
|
||||
try { const detail = JSON.parse(entry.detail); return detail.round === round ? detail.cartonMarks || [] : [] } catch { return [] }
|
||||
}))
|
||||
const requiredMarks = new Set((this.current.items || []).filter((item) =>
|
||||
['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType)
|
||||
(item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
|
||||
&& Number(item.relabelledPackages || 0) > 0).map((item) => item.cartonMark).filter(Boolean))
|
||||
if ([...requiredMarks].some((mark) => !marks.has(mark))) return '请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定'
|
||||
}
|
||||
@@ -157,7 +169,7 @@
|
||||
onLoad(options) { this.stage = STAGES[options.op] || 'receive'; this.entryTaskNo = String(options.task || '').trim() },
|
||||
mixins: [{ data() { return { documents: [], quickCarton: '', lastCarton: null, quickSku: '', lastSku: '', skuChoices: [], photoUrls: {}, photoErrors: {} } } }],
|
||||
watch: {
|
||||
'current.reviewedAt'(value) { if (this.current?.reviewDecision === 'REWORK' && warehouseWork.relabel.reworkRound !== `${this.current.relabelNo}:${value}`) { this.unitDrafts = {}; this.selectedItem = null; this.lastCarton = null; this.quickCarton = ''; updateWarehouseWork('relabel', { reworkRound: `${this.current.relabelNo}:${value}`, photoFileIds: [], photoNames: [], remark: '' }) } },
|
||||
'current.reviewedAt'(value) { if (this.current?.reviewDecision === 'REWORK' && warehouseWork.relabel.reworkRound !== `${this.current.relabelNo}:${value}`) { this.unitDrafts = {}; this.selectedItem = null; this.lastCarton = null; this.quickCarton = ''; this.verifiedReturnCarton = ''; this.returnCartonCode = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; updateWarehouseWork('relabel', { reworkRound: `${this.current.relabelNo}:${value}`, photoFileIds: [], photoNames: [], remark: '' }) } },
|
||||
photoFileIds: { deep: true, handler(ids) { for (const id of ids) if (!this.photoUrls[id]) this.loadPhoto(id) } },
|
||||
'current.relabelNo'() { this.photoUrls = {}; this.photoErrors = {}; for (const id of this.photoFileIds) this.loadPhoto(id) }
|
||||
},
|
||||
@@ -169,45 +181,63 @@
|
||||
const oldSku = String(raw?.detail?.value ?? raw ?? this.quickSku).trim()
|
||||
if (!oldSku || this.busy || !this.current) return
|
||||
this.quickSku = oldSku
|
||||
const matches = this.pendingSkuRows.filter(row => row.sku.skuCode === oldSku)
|
||||
if (this.isReturnTask && (!this.verifiedReturnCarton || this.returnCartonCode.trim() !== this.verifiedReturnCarton)) return this.setError(new Error('请先扫描并确认来源包裹/箱'))
|
||||
const matches = this.pendingSkuRows.filter(row => row.sku.skuCode === oldSku && (!row.item.returnCartonId || row.item.cartonMark === this.verifiedReturnCarton))
|
||||
this.skuChoices = []
|
||||
if (!matches.length) {
|
||||
const exists = (this.current.items || []).some(item => this.skuDetails(item).some(sku => sku.skuCode === oldSku))
|
||||
return this.setError(new Error(exists ? '该 SKU 已处理完成,不需要重复换标' : '本单没有该 SKU,不需要换标'))
|
||||
}
|
||||
if (matches.length > 1) { this.skuChoices = matches; return }
|
||||
if (matches.length > 1 || this.isReturnTask) { this.skuChoices = matches; return }
|
||||
await this.commitSku(matches[0])
|
||||
},
|
||||
async commitSku(row) {
|
||||
if (this.busy || this.quickSku.trim() !== row.sku.skuCode) return
|
||||
const payload = { itemId: row.item.itemId, skuIndex: row.index, oldSku: this.quickSku.trim(), expectedVersion: this.current.version }
|
||||
if (row.item.returnCartonId && (row.item.cartonMark !== this.verifiedReturnCarton || this.returnCartonCode.trim() !== this.verifiedReturnCarton || this.quickTargetLabel.trim() !== row.sku.newSkuCode || !row.sku.newSkuCode || !String(this.quickActualQuantity).trim() || Number(this.quickActualQuantity) !== Number(row.sku.quantity))) return this.setError(new Error('请核对来源、目标商品标签并显式输入该 SKU 实际件数(须等于申请件数)'))
|
||||
const payload = { itemId: row.item.itemId, ...(row.item.returnCartonId ? { cartonMark: row.item.cartonMark, newSkuCode: this.quickTargetLabel.trim(), quantity: Number(this.quickActualQuantity) } : {}), skuIndex: row.index, oldSku: this.quickSku.trim(), expectedVersion: this.current.version }
|
||||
const key = warehouseIdempotencyKey('relabel', 'sku-scan', { relabelNo: this.current.relabelNo, ...payload })
|
||||
await this.run('sku-scan', async () => {
|
||||
const value = await scanRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })
|
||||
this.applyCurrent(value); releaseWarehouseKey('relabel', key.signature)
|
||||
const confirmed = value.items.find(item => item.itemId === row.item.itemId)?.skuItems?.[row.index]
|
||||
this.lastSku = confirmed ? `${confirmed.skuCode} 换标 ${confirmed.newSkuCode} · ${confirmed.quantity} 件` : '已保存,请重新读取标签绑定信息'
|
||||
this.quickSku = ''; this.skuChoices = []; this.activeTarget = 'quick-sku'
|
||||
this.quickSku = ''; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.skuChoices = []; this.activeTarget = 'quick-sku'
|
||||
await new Promise(resolve => uni.showModal({ title: '请粘贴对应 SKU 标签', content: `${row.item.cartonMark}\n${this.lastSku}`, showCancel: false, confirmText: '知道了', complete: resolve }))
|
||||
}, true, key)
|
||||
},
|
||||
relabelTypeName(type) { return ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', CARTON_AND_SKU: '混合换标(SKU + 箱唛)', RETURN_BULK_SKU_LABEL: '退货贴标(按 SKU 数量)' })[type] || '换标类型待确认' },
|
||||
remainingReturnQuantity(item) { return Math.max(0, Number(item.expectedPackages || 0) - Number(item.relabelledPackages || 0) - Number(item.skippedPackages || 0) - Number(item.exceptionPackages || 0)) },
|
||||
verifyReturnSku(raw) {
|
||||
if (this.busy) return
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.returnSkuCode).trim()
|
||||
this.returnSkuCode = code; this.verifiedReturnSkuKey = ''
|
||||
const matches = this.returnBulkSkuGroups.filter(group => group.remaining > 0 && group.skuCode.toUpperCase() === code.toUpperCase())
|
||||
if (matches.length !== 1) { this.setError(new Error(matches.length ? '相同 SKU 对应多个目的仓,请核对任务' : '扫描的 SKU 不属于本单待贴标明细,或已全部登记')); return }
|
||||
this.error = null; this.verifiedReturnSkuKey = matches[0].key; this.activeTarget = 'return-sku'
|
||||
if (!this.verifiedReturnCarton || this.returnCartonCode.trim() !== this.verifiedReturnCarton) return this.setError(new Error('请先确认来源包裹/箱'))
|
||||
const matches = this.returnBulkSources
|
||||
if (!matches.length) { this.setError(new Error('该 SKU 不属于已扫描来源的待贴标明细,或已全部登记')); return }
|
||||
this.error = null; this.verifiedReturnSkuKey = code.toUpperCase(); this.activeTarget = 'return-sku'
|
||||
},
|
||||
async completeBulkSku(group) {
|
||||
if (this.busy || this.verifiedReturnSkuKey !== group.key || this.returnSkuCode.trim().toUpperCase() !== group.skuCode.toUpperCase()) return
|
||||
const entered = String(this.bulkQuantities[group.key] ?? '').trim()
|
||||
const quantity = entered ? Number(entered) : Number(group.remaining || 0)
|
||||
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > group.remaining) { this.setError(new Error(`本次贴标数量须为 1 至 ${group.remaining} 件`)); return }
|
||||
const payload = { skuCode: group.skuCode, quantity, expectedVersion: this.current.version }
|
||||
verifyReturnCarton(raw) {
|
||||
if (this.busy) return
|
||||
const mark = String(raw?.detail?.value ?? raw ?? this.returnCartonCode).trim()
|
||||
this.returnCartonCode = mark; this.verifiedReturnCarton = ''; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickSku = ''; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.skuChoices = []
|
||||
if (!(this.current?.items || []).some(item => item.returnCartonId && item.cartonMark === mark)) return this.setError(new Error('该包裹/箱不属于本单真实退货来源'))
|
||||
this.verifiedReturnCarton = mark; this.activeTarget = this.taskTab === 'carton' ? 'quick-carton' : 'quick-sku'
|
||||
},
|
||||
async fillAllReturn(item) {
|
||||
const no = this.current.relabelNo; const version = this.current.version; const mark = this.verifiedReturnCarton
|
||||
if (this.busy || item.cartonMark !== mark || this.returnCartonCode.trim() !== mark) return
|
||||
const confirmed = await new Promise(resolve => uni.showModal({ title: '登记本来源全部', content: mark + ' · ' + item.skuCode + ':确认实际已贴标剩余 ' + this.remainingReturnQuantity(item) + ' 件?', success: result => resolve(result.confirm), fail: () => resolve(false) }))
|
||||
if (confirmed && this.current.relabelNo === no && this.current.version === version && this.verifiedReturnCarton === mark) this.bulkQuantities[item.itemId] = String(this.remainingReturnQuantity(item))
|
||||
},
|
||||
async completeBulkSku(item) {
|
||||
if (this.busy) return
|
||||
if (!this.verifiedReturnCarton || this.returnCartonCode.trim() !== this.verifiedReturnCarton || item.cartonMark !== this.verifiedReturnCarton || this.returnSkuCode.trim().toUpperCase() !== item.skuCode.toUpperCase() || !item.newSkuCode || this.returnTargetLabels[item.itemId]?.trim() !== item.newSkuCode) return this.setError(new Error('请先确认本来源包裹、商品 SKU 和目标商品标签'))
|
||||
const entered = String(this.bulkQuantities[item.itemId] ?? '').trim(); const quantity = entered ? Number(entered) : NaN
|
||||
const remaining = this.remainingReturnQuantity(item)
|
||||
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > remaining) return this.setError(new Error('本来源实际贴标数量必填,须为 1 至 ' + remaining + ' 件'))
|
||||
const payload = { itemId: item.itemId, cartonMark: item.cartonMark, skuCode: item.skuCode, quantity, expectedVersion: this.current.version }
|
||||
const key = warehouseIdempotencyKey('relabel', 'return-bulk-sku', { relabelNo: this.current.relabelNo, ...payload })
|
||||
await this.run('return-bulk-sku', async () => { this.applyCurrent(await completeReturnRelabelSkuTotal(this.current.relabelNo, { ...payload, idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; delete this.bulkQuantities[group.key]; this.activeTarget = 'return-sku'; this.successMessage = `${group.skuCode} 已登记贴标 ${quantity} 件` }, true, key)
|
||||
await this.run('return-bulk-sku', async () => { this.applyCurrent(await completeReturnRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); delete this.bulkQuantities[item.itemId]; this.successMessage = item.cartonMark + ' · ' + item.skuCode + ' 已登记 ' + quantity + ' 件' }, true, key)
|
||||
},
|
||||
async loadPhoto(fileId) {
|
||||
const no = this.current?.relabelNo
|
||||
@@ -248,6 +278,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('退货箱唛换标必须先确认同一来源包裹/箱'))
|
||||
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 () => {
|
||||
@@ -265,14 +296,14 @@
|
||||
removePhoto(index) { updateWarehouseWork('relabel', { photoFileIds: this.photoFileIds.filter((_, i) => i !== index), photoNames: this.photoNames.filter((_, i) => i !== index) }) },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.busy) return; if (this.activeTarget === 'pallet-work') { this.$refs.palletWork?.handleScan(value); return } if (this.activeTarget === 'quick-carton') { this.quickCarton = value; this.submitCarton(value) } else this.handleHardwareScan(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
handleHardwareScan(value) { if (this.activeTarget === 'return-sku') { this.verifyReturnSku(value); return } if (this.activeTarget === 'quick-sku') { this.quickSku = value; this.submitSku(value); return } if (this.activeTarget === 'old') { this.oldLabel = value; return } this.taskCode = value; this.openTask(value) },
|
||||
handleHardwareScan(value) { if (this.activeTarget === 'return-carton') { this.verifyReturnCarton(value); return } if (this.activeTarget.startsWith('target-')) { this.returnTargetLabels[this.activeTarget.slice(7)] = value; return } if (this.activeTarget === 'quick-target') { this.quickTargetLabel = value; return } if (this.activeTarget === 'return-sku') { this.verifyReturnSku(value); return } if (this.activeTarget === 'quick-sku') { this.quickSku = value; this.submitSku(value); return } if (this.activeTarget === 'old') { this.oldLabel = value; return } this.taskCode = value; this.openTask(value) },
|
||||
handleOldLabel(value) { this.oldLabel = String(value?.detail?.value ?? value ?? '').trim(); if (this.action === 'RELABEL') this.activeTarget = this.taskTab === 'sku' ? 'sku-new-0' : 'new' },
|
||||
handleNewLabel(value) { this.newLabel = String(value?.detail?.value ?? value ?? '').trim() },
|
||||
async bootstrap() { await this.loadCandidates(); if (this.error || this.stage !== 'execute' || !this.entryTaskNo) return; const target = this.candidates.find((item) => item.relabelNo === this.entryTaskNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); this.entryTaskNo = ''; if (target) await this.openTask(target.relabelNo, 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) },
|
||||
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.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.isReturnBulk ? 'return-sku' : value === 'carton' ? 'quick-carton' : 'quick-sku' },
|
||||
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' },
|
||||
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' },
|
||||
@@ -327,12 +358,12 @@
|
||||
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), skuVerifications: this.unitDraft.skuVerifications.map((sku) => ({ ...sku, skuCode: sku.skuCode.trim(), newSkuCode: sku.newSkuCode.trim() })), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), skuVerifications: payload.skuVerifications, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本箱处理结果已保存' }, true, key) },
|
||||
choosePhoto() { if (this.busy || !this.current || this.photoFileIds.length >= 20) return; const no = this.current.relabelNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { if (this.current?.relabelNo !== no) return; await this.run('photo', async () => { for (const [index, path] of (tempFilePaths || []).entries()) { const selected = tempFiles?.[index]?.file || tempFiles?.[index]; if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const uploadSource = typeof Blob !== 'undefined' && selected instanceof Blob ? selected : path; const file = await uploadRelabelPhoto(no, uploadSource); this.photoUrls[String(file.fileId)] = path; updateWarehouseWork('relabel', { photoTaskNo: no, photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }) } this.successMessage = '现场照片已上传' }, false) } }) },
|
||||
async uploadPhoto(path) { await this.run('photo', async () => { const file = await uploadRelabelPhoto(this.current.relabelNo, path); updateWarehouseWork('relabel', { photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }); this.successMessage = '现场照片已上传' }, false) },
|
||||
async completeTask() { if (this.busy || !this.photoFileIds.length || (!this.allProcessed && !this.remark.trim())) return; const payload = { photoFileIds: [...this.photoFileIds], expectedVersion: this.current.version, remark: this.remark.trim(), manualCompletion: !this.allProcessed }; const no = this.current.relabelNo; if (payload.manualCompletion) { const confirmed = await new Promise((resolve) => uni.showModal({ title: '手动完成换标', content: '未处理箱将登记异常,关联尾程订单将标为异常。确认提交?', success: (result) => resolve(result.confirm), fail: () => resolve(false) })); if (!confirmed || this.current?.relabelNo !== no || this.current.version !== payload.expectedVersion) return } const key = warehouseIdempotencyKey('relabel', 'complete', { relabelNo: no, ...payload }); let completed = false; await this.run('complete', async () => { await completeRelabelTask(no, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() },
|
||||
async completeTask() { if (this.busy || this.completionBlockReason) return; const payload = { photoFileIds: [...this.photoFileIds], expectedVersion: this.current.version, remark: this.remark.trim(), manualCompletion: !this.allProcessed }; const no = this.current.relabelNo; if (payload.manualCompletion) { const confirmed = await new Promise((resolve) => uni.showModal({ title: '手动完成换标', content: '未处理箱将登记异常,关联尾程订单将标为异常。确认提交?', success: (result) => resolve(result.confirm), fail: () => resolve(false) })); if (!confirmed || this.current?.relabelNo !== no || this.current.version !== payload.expectedVersion) return } const key = warehouseIdempotencyKey('relabel', 'complete', { relabelNo: no, ...payload }); let completed = false; await this.run('complete', async () => { await completeRelabelTask(no, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() },
|
||||
goToClaimList() { this.taskCode = ''; uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelReceive' }) },
|
||||
closeCurrent() { this.current = null; this.selectedItem = null; this.error = null; this.successMessage = ''; this.activeTarget = 'task'; this.taskCode = '' },
|
||||
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
|
||||
async retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() },
|
||||
async reloadCurrent() { if (this.current?.relabelNo) this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
|
||||
async reloadCurrent() { if (!this.current?.relabelNo) return; this.verifiedReturnCarton = ''; this.returnCartonCode = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error) && !isRelabelFundsBlocked(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
|
||||
setError(error, fallback = '换标作业失败') {
|
||||
this.error = { message: relabelErrorMessage(error, fallback), code: error?.code, requestId: error?.requestId, traceId: error?.traceId }
|
||||
@@ -347,7 +378,7 @@
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.task-card.rework-card{border:2px solid #f29b38;background:#fffaf2;box-shadow:0 5px 16px rgba(221,116,20,.13)}.rework-banner{display:flex;align-items:center;gap:8px;margin-bottom:9px}.rework-tag,.rework-priority{display:inline-flex;padding:4px 9px;border-radius:999px;font-size:11px;font-weight:800}.rework-tag{background:#d94343;color:#fff}.rework-priority{background:#fff0d7;color:#b55a0b}.rework-remark{margin-bottom:11px;padding:9px 10px;border-left:3px solid #e2761b;border-radius:6px;background:#fff3e4;color:#8e4308;font-size:12px;line-height:1.55}.detail-toolbar{display:flex;align-items:center;justify-content:flex-end;margin:0 0 10px}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.tab-row{display:flex;gap:8px;margin:12px 0 8px}.tab-button{flex:none;width:auto;margin:0;border:1px solid #d7e1ef;border-radius:7px;background:#fff;color:#52647c;font-size:12px;line-height:30px;padding:0 14px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600}
|
||||
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:140px;background:#f1f4f8}.photo-placeholder{margin:0;border:0;border-radius:0;font-size:13px;white-space:normal;line-height:1.5;display:flex;align-items:center;justify-content:center}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-actions{display:flex;justify-content:space-around;padding:4px}.photo-actions .link-button{margin:0}.bulk-total-button{margin-top:14px}.bulk-quantity{box-sizing:border-box;width:100%;padding:12px;border:1px solid #d7e1ef;border-radius:8px;background:#fff}.source-allocation{margin-top:14px;padding:12px;border-radius:10px;background:#f6f8fb}.source-title{display:block;font-weight:700;color:#24364b;margin-bottom:8px}.source-row,.source-total{display:flex;justify-content:space-between;gap:12px;padding:8px 0;color:#52647c}.source-row+ .source-row{border-top:1px solid #e4eaf2}.source-row text{min-width:0;overflow-wrap:anywhere}.source-row strong,.source-total strong{white-space:nowrap;color:#1f5fca}.source-total{border-top:1px solid #d7e1ef;font-weight:700}
|
||||
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:140px;background:#f1f4f8}.photo-placeholder{margin:0;border:0;border-radius:0;font-size:13px;white-space:normal;line-height:1.5;display:flex;align-items:center;justify-content:center}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-actions{display:flex;justify-content:space-around;padding:4px}.photo-actions .link-button{margin:0}.item-card>input{box-sizing:border-box;width:100%;padding:10px;border:1px solid #d7e1ef;border-radius:8px;margin:8px 0}.bulk-total-button{margin-top:14px}.bulk-quantity{box-sizing:border-box;width:100%;padding:12px;border:1px solid #d7e1ef;border-radius:8px;background:#fff}.source-allocation{margin-top:14px;padding:12px;border-radius:10px;background:#f6f8fb}.source-title{display:block;font-weight:700;color:#24364b;margin-bottom:8px}.source-row,.source-total{display:flex;justify-content:space-between;gap:12px;padding:8px 0;color:#52647c}.source-row+ .source-row{border-top:1px solid #e4eaf2}.source-row text{min-width:0;overflow-wrap:anywhere}.source-row strong,.source-total strong{white-space:nowrap;color:#1f5fca}.source-total{border-top:1px solid #d7e1ef;font-weight:700}
|
||||
.destination-summary{display:flex;align-items:center;flex-wrap:wrap;gap:5px;margin-top:6px}.destination-summary .meta-line{margin:0}.destination-tag{display:inline-block;padding:3px 7px;border-radius:5px;background:#edf4ff;color:#285d9f;font-size:10px;line-height:14px}
|
||||
.carton-label-list{margin-top:10px;gap:6px}.carton-label-item{display:flex;align-items:center;flex-wrap:nowrap;gap:6px;padding:7px 0;min-width:0}.carton-label{display:flex;flex:1;flex-direction:column;gap:2px;box-sizing:border-box;min-width:0;overflow:hidden;padding:5px 7px;border:1px solid #dce5ef;border-radius:6px;background:#f6f8fb}.fba-label{border-color:#cddff9;background:#eff5ff}.label-caption{color:#718095;font-size:9px;line-height:13px}.label-code{display:block;color:#253e5b;font-size:10px;font-weight:600;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.label-arrow{flex-shrink:0;color:#8090a3;font-size:12px}.carton-label-item>.destination-tag{flex-shrink:0}.sku-detail-card{padding:8px 10px}.sku-detail-card .business-code{font-size:12px;font-weight:600;line-height:18px;overflow-wrap:anywhere}.sku-detail-card .meta-line{font-size:9px;line-height:14px}.sku-detail-card .link-button{height:26px;font-size:9px;line-height:26px}
|
||||
.summary-metrics{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px;margin-left:auto;font-size:9px;line-height:18px}.summary-metric{display:inline-block;padding:1px 4px;border-radius:4px;background:#dcfce7;color:#16a34a;font-weight:700;white-space:nowrap}
|
||||
|
||||
@@ -66,7 +66,19 @@ export default {
|
||||
open(row) { this.task = JSON.parse(JSON.stringify(row)); this.requestKey = ''; this.signature = ''; this.selectedMark = ''; const draft = uni.getStorageSync(this.draftKey); if (row.status === 'COUNTING' && draft?.boxes?.length === row.boxes.length && draft.boxes.every(box => row.boxes.some(item => item.cartonMark === box.cartonMark))) { this.task.boxes = draft.boxes; this.requestKey = draft.requestKey || ''; this.signature = draft.signature || '' } },
|
||||
async scan(value) { const mark = String(value || this.scanCode).trim(); if (!mark) return; await this.run(async () => { const rows = await scanReturnCountTasks(mark); this.tasks = rows; this.total = rows.length; this.scanResult = mark; this.scanCode = ''; this.selectedMark = ''; if (rows.length === 1) await this.enterTask(rows[0]); else this.task = null }) },
|
||||
addSku(box) { box.items.push({ skuCode: '', quantity: '' }) },
|
||||
async confirm() { if (!this.ready) return; await this.run(async () => { const boxes = this.task.boxes.map(box => ({ cartonMark: box.cartonMark, items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) })); const signature = JSON.stringify(boxes); if (!this.requestKey || signature !== this.signature) { this.requestKey = `return-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.signature = signature } uni.setStorageSync(this.draftKey, { boxes: this.task.boxes, requestKey: this.requestKey, signature }); const result = await confirmReturnCount(this.task.receiptNo, { idempotencyKey: this.requestKey, boxes }); if (result?.status !== 'CONFIRMED') throw new Error('清点未完成,请刷新核实任务状态'); uni.removeStorageSync(this.draftKey); this.activeTask = null; this.resetList(); uni.showToast({ title: '箱内清点完成', icon: 'success' }); await this.fetchTasks() }) },
|
||||
async confirm() { if (!this.ready) return; await this.run(async () => { const completed = { receiptNo: this.task.receiptNo, palletCode: this.task.palletCode }; const boxes = this.task.boxes.map(box => ({ cartonMark: box.cartonMark, items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) })); const signature = JSON.stringify(boxes); if (!this.requestKey || signature !== this.signature) { this.requestKey = `return-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.signature = signature } uni.setStorageSync(this.draftKey, { boxes: this.task.boxes, requestKey: this.requestKey, signature }); const result = await confirmReturnCount(this.task.receiptNo, { idempotencyKey: this.requestKey, boxes }); if (result?.status !== 'CONFIRMED') throw new Error('清点未完成,请刷新核实任务状态'); uni.removeStorageSync(this.draftKey); this.activeTask = null; this.resetList(); uni.showToast({ title: '箱内清点完成', icon: 'success' }); await this.fetchTasks(); await this.offerNextPalletTask(completed) }) },
|
||||
async offerNextPalletTask(completed) {
|
||||
if (!completed.palletCode) return;
|
||||
let rows;
|
||||
try { rows = await scanReturnCountTasks(completed.palletCode) }
|
||||
catch (error) { error.message = `当前清点已完成,查询该托盘剩余任务失败:${error.message || '请刷新任务列表'}`; throw error }
|
||||
const remaining = rows.filter(row => row.palletCode === completed.palletCode && row.receiptNo !== completed.receiptNo && ['PENDING', 'COUNTING'].includes(row.status));
|
||||
if (!remaining.length) return;
|
||||
const answer = await this.ask({ title: '该托盘还有待清点项', content: `当前清点已完成,托盘 ${completed.palletCode} 还有 ${remaining.length} 项待清点任务,是否继续?`, confirmText: '继续清点', cancelText: '暂不清点' });
|
||||
if (!answer.confirm) return;
|
||||
this.tasks = remaining; this.total = remaining.length; this.scanResult = completed.palletCode;
|
||||
await this.enterTask(remaining[0]);
|
||||
},
|
||||
resetList() { this.task = null; this.selectedMark = ''; this.scanCode = ''; this.scanResult = ''; this.page = 1; this.tasks = []; this.total = 0 },
|
||||
back() { this.resetList(); this.load() }
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<text class="business-code">{{ label.cartonMark }}</text><text class="meta-line">来源退货单:{{ label.sourceReturnNo }}</text>
|
||||
<text v-for="item in label.items" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
|
||||
<button class="secondary" :disabled="busy" @tap="printLabel(label)">打印新箱唛</button>
|
||||
<ReturnLabelConfirmation ref="labelConfirmations" :carton-mark="label.cartonMark" />
|
||||
</view>
|
||||
<button class="primary" :disabled="busy" @tap="nextPacking">返回合箱计划列表</button>
|
||||
</view>
|
||||
@@ -41,6 +42,7 @@
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import ReturnBoxMergeWork from '@/components/ReturnBoxMergeWork.vue'
|
||||
import ReturnLabelConfirmation from '@/components/ReturnLabelConfirmation.vue'
|
||||
import { completePackingPlan, findPackingOrders, listPackingPlans, getPackingPlan, getReturnCartonLabel, scanReturnCarton, packReturnSkus } from '@/api/return-packing.js'
|
||||
import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '@/utils/return-packing-allocation.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
@@ -49,7 +51,7 @@
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput, ReturnBoxMergeWork },
|
||||
components: { PortalHeader, ScanTargetInput, ReturnBoxMergeWork, ReturnLabelConfirmation },
|
||||
data() { return { mode: 'SKU', initialSource: null, user: session.currentUser, candidates: null, customerCandidates: [], selectedCustomer: null, previews: {}, plans: [], activePlan: null, result: null, busy: false, error: null, dimensionFields: [{ key: 'length', label: '长 cm', placeholder: '长' }, { key: 'width', label: '宽 cm', placeholder: '宽' }, { key: 'height', label: '高 cm', placeholder: '高' }, { key: 'weight', label: '重量 kg', placeholder: '重量' }] } },
|
||||
computed: {
|
||||
packing() { return warehouseWork.returnPacking },
|
||||
@@ -64,7 +66,7 @@
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.mode = this.packing.mode || 'SKU'; this.activePlan = this.packing.activePlan || null; this.result = this.packing.result || null; if (this.activePlan) { const quantities = {}; for (const box of this.activePlan.boxes) for (const item of box.items || []) quantities[item.skuCode] = (quantities[item.skuCode] || 0) + Number(item.quantity); this.selectedCustomer = { skus: Object.entries(quantities).map(([skuCode, availableQuantity]) => ({ skuCode, availableQuantity })) }; } this.startScanner(); this.loadPlans(); if (!this.activePlan && !this.result && this.selectedOrders.length && !Object.keys(this.previews).length) this.refreshSelected() },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
startScanner() { this.initialSource = this.activePlan ? null : this.packing.initialSource || null; if (this.mode === 'BOX' && !this.activePlan && !this.initialSource) this.mode = 'SKU'; this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (!this.busy && !this.result) { if (this.mode === 'BOX') this.$refs.boxWork?.acceptScan(value); else { this.searchCode = value; this.search(value) } } }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
startScanner() { this.initialSource = this.activePlan ? null : this.packing.initialSource || null; if (this.mode === 'BOX' && !this.activePlan && !this.initialSource) this.mode = 'SKU'; this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.result) { for (const label of [].concat(this.$refs.labelConfirmations || [])) label.acceptScan(value); return } if (!this.busy && !this.result) { if (this.mode === 'BOX') this.$refs.boxWork?.acceptScan(value); else { this.searchCode = value; this.search(value) } } }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
async loadPlans() { try { this.plans = await listPackingPlans() } catch (error) { this.setError(error, '加载合箱计划失败') } },
|
||||
async search(raw) {
|
||||
@@ -80,12 +82,7 @@
|
||||
resetWarehouseDomain('returnPacking'); this.selectedCustomer = null; this.customerCandidates = []; this.candidates = null; this.previews = {}
|
||||
this.initialSource = carton; this.mode = 'BOX'; updateWarehouseWork('returnPacking', {mode:'BOX', initialSource:carton}); playSuccessSound(); return
|
||||
}
|
||||
const preview = await findPackingOrders(value, 'SKU')
|
||||
const customers = Array.isArray(preview.customers) ? preview.customers : []
|
||||
const orders = Array.isArray(preview.orders) ? preview.orders : []
|
||||
this.customerCandidates = customers; this.candidates = customers.length ? null : orders.filter(item => !this.selectedOrders.some(selected => selected.returnNo === item.returnNo))
|
||||
if (customers.length === 1) this.selectCustomer(customers[0])
|
||||
if (customers.length || this.candidates.length) playSuccessSound()
|
||||
throw new Error('请扫描来源包裹/箱标签,部分出库必须确认具体实物来源')
|
||||
}, '查询 SKU 或箱号失败')
|
||||
},
|
||||
selectCustomer(customer) { if (!customer?.customerId || !customer.orders?.length) return; const existingCustomer = this.selectedOrders[0]?.customerKey; if (existingCustomer && existingCustomer !== customer.customerId) { this.setError(new Error('不同客户的退货库存不能合箱'), '不能选择其他客户'); return } this.selectedCustomer = customer; const selected = customer.orders.filter((item) => !this.selectedOrders.some((current) => current.returnNo === item.returnNo)).map((item) => ({ ...item, customerKey: customer.customerId })); const all = this.selectedOrders.length ? [...this.selectedOrders, ...selected] : selected; updateWarehouseWork('returnPacking', { selectedOrders: all, searchCode: '' }); this.customerCandidates = []; this.candidates = null; this.previews = {} },
|
||||
@@ -93,10 +90,10 @@
|
||||
async startPlan(plan) { if (!plan?.planNo || this.busy || this.activePlan || this.result) return; await this.run(async () => {
|
||||
const latest = await getPackingPlan(plan.planNo)
|
||||
if (latest.status !== 'READY') throw new Error('计划已不在待作业状态,请刷新列表')
|
||||
this.activePlan = latest; this.mode = latest.mode; this.customerCandidates = []; this.candidates = null
|
||||
this.activePlan = latest; this.mode = 'BOX'; this.customerCandidates = []; this.candidates = null
|
||||
const quantities = {}; for (const box of latest.boxes) for (const item of box.items || []) quantities[item.skuCode] = (quantities[item.skuCode] || 0) + Number(item.quantity)
|
||||
this.selectedCustomer = { customerId: latest.customerId, orders: latest.returnNos.map(returnNo => ({ returnNo })), skus: Object.entries(quantities).map(([skuCode, availableQuantity]) => ({ skuCode, availableQuantity })) }
|
||||
updateWarehouseWork('returnPacking', { mode: latest.mode, activePlan: latest, selectedOrders: latest.returnNos.map(returnNo => ({ returnNo, customerKey: latest.customerId })), quantities, targetBoxes: defaultPackingBoxes(Object.entries(quantities).map(([skuCode,quantity]) => ({skuCode,quantity}))), searchCode: '' })
|
||||
updateWarehouseWork('returnPacking', { mode: 'BOX', activePlan: latest, selectedOrders: latest.returnNos.map(returnNo => ({ returnNo, customerKey: latest.customerId })), quantities, targetBoxes: defaultPackingBoxes(Object.entries(quantities).map(([skuCode,quantity]) => ({skuCode,quantity}))), searchCode: '' })
|
||||
}, '读取合箱计划失败') },
|
||||
async addOrder(item) { if (this.busy || !item?.returnNo) return; await this.run(async () => { const preview = await findPackingOrders(item.returnNo, 'RETURN_NO'); const exact = preview.orders.filter((order) => order.returnNo === item.returnNo); if (exact.length !== 1 || !preview.customerKey) throw new Error('该退货单没有可用库存'); const customerKey = this.selectedOrders[0]?.customerKey; if (customerKey && customerKey !== preview.customerKey) throw new Error('不同客户的退货单不能合箱'); const selected = [...this.selectedOrders, { ...exact[0], customerKey: preview.customerKey }]; this.selectedCustomer = null; this.previews = { ...this.previews, [item.returnNo]: preview }; updateWarehouseWork('returnPacking', { selectedOrders: selected, searchCode: '', quantities: this.validQuantities(selected, this.previews) }); this.candidates = null; this.customerCandidates = []; playSuccessSound() }, '添加退货单失败') },
|
||||
removeOrder(returnNo) { const selected = this.selectedOrders.filter((item) => item.returnNo !== returnNo); const previews = { ...this.previews }; delete previews[returnNo]; this.previews = previews; if (this.selectedCustomer) this.selectedCustomer = { ...this.selectedCustomer, orders: selected }; updateWarehouseWork('returnPacking', { selectedOrders: selected, quantities: this.validQuantities(selected, previews) }) },
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
<view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="selectCandidate(item)"><view class="row-head"><text class="business-code">{{ item.returnNo }}</text><text class="status-pill">{{ statusLabel(item.status) }}</text></view><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text><view class="metric-row"><view class="metric"><strong>{{ item.skuReceivedQuantity }}/{{ item.skuExpectedQuantity }}</strong><text>该 SKU</text></view><view class="metric"><strong>{{ item.orderReceivedQuantity }}/{{ item.orderExpectedQuantity }}</strong><text>整单进度</text></view><text class="select-link">选择 ›</text></view></button></view>
|
||||
</view>
|
||||
|
||||
<view v-if="receiptBatch && !result" class="work-card"><text class="card-title">待完成按箱签收</text><text class="business-code">{{ receiptBatch.returnNo }}</text><text class="hint">已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} 箱。请继续本批签收,已确认的箱不会重新入库;本批内容暂不可修改。</text><button class="primary" :disabled="busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '继续本批签收' }}</button></view>
|
||||
<view v-if="receiptBatch && !result" class="work-card"><text class="card-title">待完成{{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '按箱' }}签收</text><text class="business-code">{{ receiptBatch.returnNo }}</text><text class="hint">已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} {{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '箱' }}。请继续本批签收,已确认的包裹/箱不会重新入库;本批内容暂不可修改。</text><button class="primary" :disabled="busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '继续本批签收' }}</button></view>
|
||||
<template v-if="current && !result && !receiptBatch">
|
||||
<view class="work-card receipt-order-card"><view class="row-head"><text class="card-title">退货单</text><button class="link-button" @tap="clearCurrent">更换退货单</button></view><view class="receipt-order-line"><text class="business-code">{{ current.returnNo }}</text><text class="status-pill">{{ statusLabel(current.status) }}</text></view></view>
|
||||
|
||||
<view class="work-card">
|
||||
<text class="card-title">本次实物单元</text>
|
||||
<view class="unit-tabs"><button v-for="item in unitOptions" :key="item.value" :class="['unit-tab', {active: unitType === item.value}]" @tap="selectUnitType(item.value)">{{ item.label }}</button></view>
|
||||
<view v-if="unitType === 'PARCEL'"><text class="field-label">快递单号(选填)</text><ScanTargetInput name="unit" v-model="unitCode" label="快递单号(选填)" placeholder="扫描快递面单,可留空" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /></view>
|
||||
<view v-if="unitType === 'PARCEL'"><ScanTargetInput name="unit" v-model="unitCode" label="本批快递单号(选填,只填写一次)" placeholder="扫描本批快递面单,可留空" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /><text class="hint">本批共用一个快递单号;填写包裹数,再逐包裹录入 SKU 数量,各包裹单独生成仓库标签。</text></view>
|
||||
<template v-else>
|
||||
<view><text class="field-label">托盘码({{ unitType === 'BOX' ? '选填' : '必填' }})</text><ScanTargetInput name="pallet" v-model="palletCode" label="托盘码" placeholder="扫描已有托盘或新托盘标签" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="acceptPallet" /><text v-if="palletCheck" class="scan-hint">{{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}</text><button class="secondary pallet-generate" :disabled="busy" @tap="generatePallet">生成新托盘</button></view>
|
||||
<view v-if="unitType === 'PALLET'" class="dimension-grid"><label><text>托盘长(cm)</text><input class="number-input" type="digit" v-model="palletLength" placeholder="请输入长度(cm)" /></label><label><view class="dimension-label"><text>托盘宽(cm)</text><text class="volume-text">体积 {{ palletVolume }} cm³</text></view><input class="number-input" type="digit" v-model="palletWidth" placeholder="请输入宽度(cm)" /></label><label><text>托盘高(cm)</text><input class="number-input" type="digit" v-model="palletHeight" placeholder="请输入高度(cm)" /></label><label><text>托盘重量(kg)</text><input class="number-input" type="digit" v-model="palletWeight" placeholder="请输入重量(kg)" /></label></view>
|
||||
@@ -29,14 +29,14 @@
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<view class="work-card"><view class="row-head"><text class="card-title">{{ unitType === 'PARCEL' ? '本次实收数量' : (unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量') }}</text><button v-if="unitType !== 'BOX'" class="link-button" @tap="fillRemaining">全部填入</button></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>本托箱数合计</text><strong class="auto-count">{{ palletBoxCount }}</strong></view><template v-if="unitType === 'BOX'"><view class="quantity-row"><text>本次箱数</text><input class="number-input" type="number" :value="boxRows.length" placeholder="箱数" @input="resizeBoxRows($event.detail.value)" /></view><view v-for="(box,index) in boxRows" :key="index" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱</text><view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</text><input class="number-input" type="number" :value="box.items[item.skuCode] || ''" placeholder="0" @input="setBoxQuantity(index, item.skuCode, $event.detail.value)" /></view></view></template><view v-else class="item-list receipt-sku-list"><view v-for="item in selectedItems" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">剩余 {{ remaining(item) }}</text></view><view class="sku-quantity-fields"><view class="quantity-row"><text>数量</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>SKU 箱数</text><input class="number-input" type="number" :value="boxCount(item.skuCode)" placeholder="0" @input="setBoxCount(item, $event.detail.value)" /></view></view></view></view></view>
|
||||
<view class="work-card"><view class="row-head"><view><text class="card-title">{{ unitType === 'PARCEL' ? '逐包裹实收数量' : (unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量') }}</text></view><button v-if="unitType === 'PALLET'" class="link-button" @tap="fillRemaining">全部填入</button></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>本托箱数合计</text><strong class="auto-count">{{ palletBoxCount }}</strong></view><template v-if="unitType !== 'PALLET'"><view class="quantity-row"><text>{{ unitType === 'PARCEL' ? '本次包裹数' : '本次箱数' }}</text><input class="number-input" type="number" :value="boxRows.length" :placeholder="unitType === 'PARCEL' ? '包裹数' : '箱数'" @input="resizeBoxRows($event.detail.value)" /></view><view v-for="(box,index) in boxRows" :key="index" class="item-card"><text class="business-code">第 {{ index + 1 }} {{ unitType === 'PARCEL' ? '包裹' : '箱' }}</text><view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</text><text class="remaining-copy">剩余 {{ remaining(item) }}</text><input class="number-input" type="number" :value="box.items[item.skuCode] || ''" placeholder="0" @input="setBoxQuantity(index, item.skuCode, $event.detail.value)" /></view></view></template><view v-else class="item-list receipt-sku-list"><view v-for="item in selectedItems" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">剩余 {{ remaining(item) }}</text></view><view class="sku-quantity-fields"><view class="quantity-row"><text>数量</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>SKU 箱数</text><input class="number-input" type="number" :value="boxCount(item.skuCode)" placeholder="0" @input="setBoxCount(item, $event.detail.value)" /></view></view></view></view></view>
|
||||
<view class="summary-bar"><view><text class="summary-label">{{ unitType === 'PARCEL' ? '本次合计' : '本托合计' }}</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="!canConfirm || busy" @tap="confirmReceipt">{{ busy ? '提交中…' : unitType === 'PARCEL' ? '确认包裹签收' : '确认签收并入托' }}</button></view>
|
||||
<view class="work-card"><view class="row-head"><view><text class="card-title">退货打托照片</text><text class="hint">签收过程中可随时拍照</text></view><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view><button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto">拍照或从相册选择</button><view v-if="photoFileIds.length" class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile"><image class="photo-thumbnail" :src="photoUrls[fileId]" mode="aspectFill" @tap="previewPhoto(fileId)" /><text class="photo-name">{{ photoNames[index] || '退货打托照片' }}</text><button class="link-button" :disabled="busy" @tap="removePhoto(index)">移除</button></view></view></view>
|
||||
<button v-if="current.status === 'PARTIALLY_RECEIVED'" class="secondary manual-complete" :disabled="busy" @tap="manualComplete">缺货手动完结</button>
|
||||
<view v-if="current.completedPallets?.length" class="work-card"><text class="card-title">本单已完成打托</text><view class="item-list"><view v-for="pallet in current.completedPallets" :key="pallet.palletCode" class="item-card"><text class="business-code">{{ pallet.palletCode }}</text><text class="meta-line">{{ pallet.skuCode }} · {{ pallet.quantity }} 件</text><text class="meta-line">签收批次:{{ pallet.receiptBatchNo }}</text></view></view></view>
|
||||
</template>
|
||||
|
||||
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱 · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印箱唛</button></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><button v-if="result.unitType === 'PALLET' && result.labels?.length" class="secondary" :disabled="busy" @tap="goCount">去箱内清点</button><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></view>
|
||||
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} {{ result.unitType === 'PARCEL' ? '包裹' : '箱' }} · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印包裹/箱标签</button><text class="meta-line">签收已默认贴标</text></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><button v-if="result.unitType === 'PALLET' && result.labels?.length" class="secondary" :disabled="busy" @tap="goCount">去箱内清点</button><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></view>
|
||||
|
||||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current && !receiptBatch" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
|
||||
</view></scroll-view>
|
||||
@@ -65,8 +65,8 @@
|
||||
selectedItems() { return this.current?.expectedItems || [] },
|
||||
palletBoxCount() { return this.selectedItems.reduce((sum, item) => sum + this.boxCount(item.skuCode), 0) },
|
||||
activeSkuCode() { return this.selectedItems.find(item => item.skuCode === this.selectedSku)?.skuCode || this.selectedItems[0]?.skuCode || '' },
|
||||
receiptQuantity() { return this.unitType === 'BOX' ? this.boxRows.reduce((sum, box) => sum + Object.values(box.items).reduce((n, quantity) => n + (Number(quantity) || 0), 0), 0) : Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
|
||||
canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType === 'BOX') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0)) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.unitType === 'PARCEL' || (this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && (this.unitType !== 'PALLET' || this.palletBoxCount > 0))) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.unitType !== 'PALLET' || this.quantity(item.skuCode) === 0 || this.boxCount(item.skuCode) > 0)); },
|
||||
receiptQuantity() { return this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + Object.values(box.items).reduce((n, quantity) => n + (Number(quantity) || 0), 0), 0) : Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
|
||||
canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType !== 'PALLET') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0)) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.unitType === 'PARCEL' || (this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && (this.unitType !== 'PALLET' || this.palletBoxCount > 0))) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.unitType !== 'PALLET' || this.quantity(item.skuCode) === 0 || this.boxCount(item.skuCode) > 0)); },
|
||||
unitLabel() { return this.unitType === 'PALLET' ? '托盘码' : this.unitType === 'BOX' ? '箱号/箱唛' : '包裹码/快递单号' },
|
||||
unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' }
|
||||
,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' }
|
||||
@@ -78,10 +78,10 @@
|
||||
selectUnitType(type) { if (this.busy) return; this.unitType = type; this.unitCode = ''; this.boxRows = [{ items: {} }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
|
||||
resizeBoxRows(raw) { const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {} }) },
|
||||
setBoxQuantity(index, skuCode, raw) { const value = Math.max(0, Math.floor(Number(raw) || 0)); this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: value } },
|
||||
acceptUnit(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code) return; this.unitCode = code; this.palletCode = `${this.unitType}:${code}`; this.activeTarget = 'tracking'; playSuccessSound() },
|
||||
acceptUnit(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code) return; this.unitCode = code; if (this.unitType !== 'PARCEL') this.palletCode = `${this.unitType}:${code}`; this.activeTarget = this.unitType === 'PARCEL' ? 'unit' : 'tracking'; playSuccessSound() },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
handleScan(value) { if (this.busy || this.receiptBatch) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(value); else this.acceptTracking(value) },
|
||||
handleScan(value) { if (this.result) return; if (this.busy || this.receiptBatch) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(value); else this.acceptTracking(value) },
|
||||
switchMode(mode) { if (this.busy) return; this.entryMode = mode; this.lookupCode = ''; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
|
||||
async lookup(raw) { const value = String(raw?.detail?.value ?? raw ?? this.lookupCode).trim(); if (!value || this.busy) return; this.lookupCode = value; this.error = null; this.result = null; if (this.entryMode === 'SKU') await this.run(async () => { const response = await findReturnSkuCandidates(value); this.candidates = response.candidates; this.selectedSku = response.skuCode; if (this.candidates.length === 1) await this.openOrderValue(this.candidates[0].returnNo, 'SKU'); else if (this.candidates.length) playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
|
||||
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; this.selectedSku = this.lookupCode; await this.openOrder(item.returnNo, 'SKU') },
|
||||
@@ -99,16 +99,16 @@
|
||||
printPallet() { if (!this.newPalletHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(this.newPalletHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
|
||||
printReturnCarton(label) { if (!label?.printableHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(label.printableHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
|
||||
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
|
||||
async confirmReceipt() { if (this.busy) return; if (this.receiptBatch) { await this.run(() => this.resumeReceiptBatch(), '退货入库失败'); return } const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy) return; const willComplete = this.current.expectedItems.every((item) => (this.unitType === 'BOX' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
|
||||
async confirmReceipt() { if (this.busy) return; if (this.receiptBatch) { await this.run(() => this.resumeReceiptBatch(), '退货入库失败'); return } const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy) return; const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
|
||||
await this.run(async () => {
|
||||
const rows = this.unitType === 'BOX' ? this.boxRows : [null]
|
||||
const rows = this.unitType !== 'PALLET' ? this.boxRows : [null]
|
||||
const entries = rows.map((box, index) => {
|
||||
const payload = { returnNo: this.current.returnNo, trackingNo: (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.trackingNo.trim()) || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType === 'BOX' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: 1 })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.unitType === 'PALLET' ? this.boxCount(item.skuCode) : undefined }))).filter((item) => item.receivedQuantity > 0) }
|
||||
const payload = { returnNo: this.current.returnNo, trackingNo: (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.trackingNo.trim()) || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType !== 'PALLET' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: this.unitType === 'BOX' ? 1 : undefined })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.boxCount(item.skuCode) }))).filter((item) => item.receivedQuantity > 0) }
|
||||
const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
|
||||
return { request: { ...payload, idempotencyKey: key.value }, result: null }
|
||||
})
|
||||
if (this.unitType === 'BOX') {
|
||||
updateWarehouseWork('returnReceipt', { pendingBatch: JSON.parse(JSON.stringify({ returnNo: this.current.returnNo, entries })) })
|
||||
if (this.unitType !== 'PALLET') {
|
||||
updateWarehouseWork('returnReceipt', { pendingBatch: JSON.parse(JSON.stringify({ returnNo: this.current.returnNo, unitType: this.unitType, entries })) })
|
||||
await this.resumeReceiptBatch()
|
||||
} else {
|
||||
this.finishReceipt([await confirmReturnReceipt(entries[0].request)])
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
finishReceipt(results) {
|
||||
const last = results[results.length - 1]
|
||||
this.result = { ...last, continuationReturnNo: !last.fullyMatched ? this.current?.returnNo || '' : '', labels: results.filter(item => item.unitType === 'BOX' && item.printableHtml).map(item => ({ cartonMark: item.cartonMark, printableHtml: item.printableHtml })) }
|
||||
this.result = { ...last, continuationReturnNo: !last.fullyMatched ? this.current?.returnNo || '' : '', labels: results.flatMap(item => item.labels?.length ? item.labels : item.printableHtml && item.cartonMark ? [{ cartonMark: item.cartonMark, printableHtml: item.printableHtml }] : []) }
|
||||
resetWarehouseDomain('returnReceipt')
|
||||
this.current = null
|
||||
this.candidates = null
|
||||
@@ -180,7 +180,7 @@
|
||||
.dimension-grid label .dimension-label{display:flex;align-items:center;justify-content:space-between;gap:3px;margin-bottom:3px}.dimension-grid label .dimension-label>text{color:#65768b;font-size:10px;line-height:14px;font-weight:400;white-space:nowrap}.dimension-grid label .dimension-label>.volume-text{margin-left:auto;color:#135ee8;font-size:10px}
|
||||
.receipt-order-card{padding:10px 14px!important}.receipt-order-card .row-head{align-items:center;margin-bottom:5px}.receipt-order-card .card-title{margin:0;font-size:12px}.receipt-order-card .link-button{margin:0;padding:0;font-size:11px;line-height:24px}.receipt-order-line{display:flex;align-items:center;gap:8px}.receipt-order-line .business-code{flex:1;min-width:0;font-size:12px;line-height:18px;white-space:nowrap;overflow-x:auto;word-break:normal}.receipt-order-line .status-pill{flex-shrink:0;font-size:10px;padding:3px 6px}
|
||||
.receipt-sku-list .item-card{padding:10px 12px}.receipt-sku-list .row-head{align-items:flex-start;gap:8px}.receipt-sku-list .row-head>view{flex:1;min-width:0}.receipt-sku-list .business-code{font-size:12px;line-height:17px;word-break:break-all}.receipt-sku-list .meta-line{font-size:10px;line-height:15px;margin-top:3px}.receipt-sku-list .remaining-copy{flex-shrink:0;white-space:nowrap;font-size:10px}.sku-quantity-fields{display:flex;gap:14px;margin-top:8px}.sku-quantity-fields .quantity-row{flex:1;min-width:0;gap:6px;margin:0;justify-content:flex-start}.sku-quantity-fields .quantity-row>text{white-space:nowrap;font-size:10px}.sku-quantity-fields .quantity-row .number-input{box-sizing:border-box;width:64px;height:30px;min-height:30px;padding:0 5px;border-radius:6px;font-size:13px}
|
||||
.candidate-heading .card-title{margin-bottom:0}.candidate-card{width:100%;text-align:left}.select-link{color:#135ee8;font-size:12px;font-weight:800}.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#135ee8;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}.scan-hint{display:block;margin-top:9px;color:#65768b;font-size:11px;text-align:center}
|
||||
.candidate-heading .card-title{margin-bottom:0}.candidate-card{width:100%;text-align:left}.select-link{color:#135ee8;font-size:12px;font-weight:800}.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#e53935;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}.scan-hint{display:block;margin-top:9px;color:#65768b;font-size:11px;text-align:center}
|
||||
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:130px;background:#f1f4f8}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-tile .link-button{margin:0 8px 6px}
|
||||
.unit-tabs{display:flex;gap:8px;margin:10px 0}.unit-tab{flex:1!important;min-width:0!important;height:34px!important;min-height:34px!important;margin:0!important;padding:0 10px!important;border:1px solid #d6e1ef!important;border-radius:8px!important;background:#f7f9fc!important;color:#53677f!important;font-size:13px!important;line-height:34px!important}.unit-tab.active{border-color:#135ee8!important;background:#eaf2ff!important;color:#135ee8!important;font-weight:700}
|
||||
.dimension-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin-top:10px}.dimension-grid label>text{display:block;margin-bottom:3px;color:#65768b;font-size:10px}.dimension-grid .number-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #d7e1ef;border-radius:7px;color:#24364b;font-size:13px;text-align:center;background:#fff}.dimension-grid .number-input:focus{border-color:#2167e8;box-shadow:0 0 0 2px rgba(33,103,232,.12)}.volume-box{display:flex;align-items:center;justify-content:center;gap:4px;height:34px;padding:0 6px;border:1px solid #b9d3f4;border-radius:7px;color:#65768b;font-size:10px;background:#f4f8ff}.volume-box strong{color:#135ee8;font-size:13px}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export function returnSelections(task) {
|
||||
return (task?.items || []).filter(item => item.returnCartonId && ['SKU_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
|
||||
}
|
||||
export function returnDeductions(task) {
|
||||
const rows = new Map()
|
||||
for (const item of returnSelections(task)) {
|
||||
const skus = item.operationType === 'RETURN_BULK_SKU_LABEL' ? [{ skuCode: item.skuCode, quantity: item.expectedPackages }] : item.skuItems || []
|
||||
for (const sku of skus) {
|
||||
const key = `${item.cartonMark}::${sku.skuCode}`
|
||||
const row = rows.get(key) || { cartonMark: item.cartonMark, skuCode: sku.skuCode, quantity: 0 }
|
||||
row.quantity += Number(sku.quantity || 0); rows.set(key, row)
|
||||
}
|
||||
}
|
||||
return [...rows.values()]
|
||||
}
|
||||
export function requiresReturnPack(task) {
|
||||
const totals = new Map()
|
||||
for (const row of returnDeductions(task)) totals.set(row.cartonMark, (totals.get(row.cartonMark) || 0) + row.quantity)
|
||||
return returnSelections(task).some(item => !Number(item.returnQuantity) || totals.get(item.cartonMark) < Number(item.returnQuantity))
|
||||
}
|
||||
export function returnPackFacts(task) {
|
||||
return (task?.history || []).filter(row => row.action === 'RETURN_PACK').flatMap(row => {
|
||||
try { const detail = typeof row.detail === 'string' ? JSON.parse(row.detail) : row.detail; return detail?.round === (task.reviewedAt || 'INITIAL') ? [detail] : [] } catch { return [] }
|
||||
})
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*impo
|
||||
function setup(confirm) {
|
||||
const calls = []
|
||||
const uni = { showModal(options) { calls.push(options); options.success({ confirm }) }, redirectTo(options) { calls.push(options) } }
|
||||
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
|
||||
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'uni', body)({}, {}, {}, {}, uni)
|
||||
const context = { busy: false, stage: 'receive', taskCode: 'PREVIOUS', error: null, candidates: [{ relabelNo: 'RL-ACTIVE', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
|
||||
context.promptActiveTask = component.methods.promptActiveTask.bind(context)
|
||||
return { calls, context, open: () => component.methods.openExecution.call(context, { relabelNo: 'RL-OTHER', taskStatus: 'AVAILABLE' }) }
|
||||
@@ -34,7 +34,7 @@ test('cancel stays on list and preserves existing task selection', async () => {
|
||||
test('new relabel task asks whether to start before it can be claimed', async () => {
|
||||
const calls = []
|
||||
const uni = { showModal(options) { calls.push(options); options.success({ confirm: false }) } }
|
||||
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
|
||||
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'uni', body)({}, {}, {}, {}, uni)
|
||||
const context = { busy: false }
|
||||
const confirmed = await component.methods.confirmStartTask.call(context, { relabelNo: 'RL-NEW' })
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
const source = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
|
||||
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
|
||||
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', body)({}, {}, {})
|
||||
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', body)({}, {}, {}, {})
|
||||
function context() {
|
||||
const value = { current: { reviewedAt: null, history: [], items: [{ itemId: '1', operationType: 'CARTON_AND_SKU', expectedPackages: 1, skippedPackages: 0, exceptionPackages: 0, skuProcessedPackages: 0, cartonProcessedPackages: 1, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 3 }, { skuCode: 'OTHER', newSkuCode: 'NEW2', quantity: 2 }] }] }, taskTab: 'sku' }
|
||||
for (const name of ['skuDone', 'skuDetails', 'remaining']) value[name] = component.methods[name].bind(value)
|
||||
|
||||
@@ -1,49 +1,75 @@
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const api = await readFile(new URL('../src/api/relabel.js', import.meta.url), 'utf8')
|
||||
const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
|
||||
const palletWork = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('PDA exposes the approved return bulk SKU completion endpoint', () => {
|
||||
assert.match(api, /completeReturnRelabelSkuTotal/)
|
||||
assert.match(api, /\/return-skus\/complete-total/)
|
||||
const source = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
|
||||
const helperSource = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
|
||||
const helpers = await import('data:text/javascript;base64,' + Buffer.from(helperSource).toString('base64'))
|
||||
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
|
||||
function setup(confirm = false) {
|
||||
const calls = [], errors = []
|
||||
const uni = { showModal(options) { options.success({ confirm }) } }
|
||||
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', 'completeReturnRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)({}, {}, {}, {}, helpers.requiresReturnPack, helpers.returnPackFacts, async (no, payload) => { calls.push(payload); return value.current }, () => ({ value: 'key', signature: 'signature' }), () => {}, uni)
|
||||
const item = (id, mark) => ({ itemId: id, returnCartonId: id, cartonMark: mark, returnQuantity: 10, operationType: 'RETURN_BULK_SKU_LABEL', skuCode: 'SKU', newSkuCode: 'TARGET', expectedPackages: 4, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0 })
|
||||
const value = { current: { relabelNo: 'RL', version: 1, items: [item('1', 'BOX-A'), item('2', 'BOX-B')], history: [] }, busy: false, allProcessed: true, remark: '', photoFileIds: ['photo'], returnCartonCode: '', verifiedReturnCarton: '', returnSkuCode: '', bulkQuantities: {}, returnTargetLabels: {}, setError(error) { errors.push(error.message) }, applyCurrent(result) { this.current = result }, async run(action, work) { await work() } }
|
||||
for (const [name, fn] of Object.entries(component.methods)) if (!value[name]) value[name] = fn.bind(value)
|
||||
for (const name of ['isReturnTask', 'isReturnBulk', 'returnBulkSources', 'returnBulkSkuGroups', 'requiresPalletBinding', 'palletTask', 'completionBlockReason']) Object.defineProperty(value, name, { get: component.computed[name].bind(value) })
|
||||
return { value, calls, errors }
|
||||
}
|
||||
test('SKU alone cannot register or allocate unscanned sources', async () => {
|
||||
const { value, calls, errors } = setup()
|
||||
value.verifyReturnSku('SKU'); assert.match(errors.pop(), /先确认来源/)
|
||||
value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
|
||||
assert.deepEqual(value.returnBulkSources.map(item => item.cartonMark), ['BOX-A'])
|
||||
value.returnTargetLabels['2'] = 'TARGET'; value.bulkQuantities['2'] = '4'
|
||||
await value.completeBulkSku(value.current.items[1]); assert.equal(calls.length, 0)
|
||||
})
|
||||
test('quantity is explicit, integral and source bounded; wrong target cannot write', async () => {
|
||||
const { value, calls } = setup(); value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
|
||||
const item = value.current.items[0]; value.returnTargetLabels['1'] = 'TARGET'
|
||||
for (const quantity of ['', '1.5', '5', '0']) { value.bulkQuantities['1'] = quantity; await value.completeBulkSku(item) }
|
||||
assert.equal(calls.length, 0)
|
||||
value.bulkQuantities['1'] = '2'; value.returnTargetLabels['1'] = 'WRONG'; await value.completeBulkSku(item); assert.equal(calls.length, 0)
|
||||
value.returnTargetLabels['1'] = 'TARGET'; await value.completeBulkSku(item)
|
||||
assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', skuCode: 'SKU', quantity: 2, expectedVersion: 1, idempotencyKey: 'key' }])
|
||||
})
|
||||
test('all action needs confirmation and rescanning clears quantity and target', async () => {
|
||||
for (const confirm of [false, true]) { const { value } = setup(confirm); value.verifyReturnCarton('BOX-A'); await value.fillAllReturn(value.current.items[0]); assert.equal(value.bulkQuantities['1'], confirm ? '4' : undefined); value.verifyReturnCarton('BOX-B'); assert.deepEqual(value.bulkQuantities, {}); assert.deepEqual(value.returnTargetLabels, {}) }
|
||||
})
|
||||
test('normal return SKU requires source and explicit SKU pieces before server scan', async () => {
|
||||
const { value, errors } = setup(); const item = { ...value.current.items[0], operationType: 'SKU_LABEL', expectedPackages: 1, skuItems: [{ skuCode: 'SKU', newSkuCode: 'TARGET', quantity: 3 }] }
|
||||
value.current.items = [item]; value.pendingSkuRows = [{ item, sku: item.skuItems[0], index: 0 }]
|
||||
await value.submitSku('SKU'); assert.match(errors.pop(), /先扫描/)
|
||||
value.verifyReturnCarton('BOX-A'); value.quickSku = 'SKU'; await value.commitSku(value.pendingSkuRows[0]); assert.match(errors.pop(), /实际件数/)
|
||||
})
|
||||
test('packing uses SKU pieces instead of box count and preserves both sources', () => {
|
||||
const task = { items: [{ returnCartonId: '1', cartonMark: 'A', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 8, skuItems: [{ skuCode: 'SKU', quantity: 3 }] }, { returnCartonId: '2', cartonMark: 'B', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 2, skuItems: [{ skuCode: 'SKU', quantity: 2 }] }] }
|
||||
assert.deepEqual(helpers.returnDeductions(task), [{ cartonMark: 'A', skuCode: 'SKU', quantity: 3 }, { cartonMark: 'B', skuCode: 'SKU', quantity: 2 }]); assert.equal(helpers.requiresReturnPack(task), true)
|
||||
task.items[0].returnQuantity = 3; assert.equal(helpers.requiresReturnPack(task), false)
|
||||
})
|
||||
test('only truck requires return pallet binding and counts physical new boxes', () => {
|
||||
const { value } = setup(); for (const method of [null, 'EXPRESS', 'EXPRESS_PICKUP', 'TRUCK']) { value.current.deliveryMethod = method; assert.equal(value.requiresPalletBinding, method === 'TRUCK') }
|
||||
value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.relabelledPackages = item.expectedPackages })
|
||||
assert.equal(value.palletTask.items.length, 1); assert.equal(value.palletTask.items[0].expectedPackages, 1)
|
||||
})
|
||||
test('pack recovery ignores previous rework round', () => {
|
||||
const task = { reviewedAt: 'ROUND2', history: [{ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'OLD' }) }, { action: 'RETURN_PACK', detail: JSON.stringify({ round: 'ROUND2', cartonMark: 'NEW' }) }] }
|
||||
assert.deepEqual(helpers.returnPackFacts(task).map(row => row.cartonMark), ['NEW'])
|
||||
})
|
||||
|
||||
test('return relabel pallet binding validates pieces while binding physical carton marks', () => {
|
||||
assert.match(palletWork, /isReturnBulk\(\).*RETURN_BULK_SKU_LABEL/)
|
||||
assert.match(palletWork, /count === packages && this\.completedItems\(\)\.length === this\.eligibleItems\.length && this\.marks\.length === this\.eligibleItems\.length/)
|
||||
assert.match(palletWork, /this\.isReturnBulk \? this\.eligibleItems : this\.eligibleItems\.slice\(0, count\)/)
|
||||
assert.match(page, /requiredMarks[\s\S]*some\(\(mark\) => !marks\.has\(mark\)\)/)
|
||||
})
|
||||
|
||||
test('return relabel mode works by SKU remaining quantity without carton-mark scanning', () => {
|
||||
assert.match(page, /current\?\.operationType === 'RETURN_BULK_SKU_LABEL'/)
|
||||
assert.match(page, /退货贴标 SKU 明细/)
|
||||
assert.match(page, /name="returnSku"[\s\S]*@submit="verifyReturnSku"/)
|
||||
assert.match(page, /activeTarget === 'return-sku'[\s\S]*this\.verifyReturnSku\(value\)/)
|
||||
assert.match(page, /verifiedReturnSkuKey !== group\.key/)
|
||||
assert.match(page, /Number\.isSafeInteger\(quantity\)[\s\S]*quantity > group\.remaining/)
|
||||
assert.match(page, /returnBulkSkuGroups/)
|
||||
assert.match(page, /来源托盘拆箱数量/)
|
||||
assert.match(page, /登记本次贴标.*group\.remaining/)
|
||||
assert.match(page, /completeReturnRelabelSkuTotal/)
|
||||
assert.match(page, /taskTab === 'carton' && !isReturnBulk/)
|
||||
})
|
||||
|
||||
test('new relabel pallet is immediately copied into the target scan field', () => {
|
||||
assert.match(palletWork, /this\.targetCode = this\.newPallet\.palletCode \|\| ''/)
|
||||
assert.match(palletWork, /退货换标无需扫描箱唛/)
|
||||
})
|
||||
|
||||
test('return relabel distinguishes a whole pallet from a partial unpack', () => {
|
||||
assert.match(palletWork, /wholeSource\(\).*plannedPackages === Number\(this\.selectedSource\.stockPackages/)
|
||||
assert.match(palletWork, /eligibleItems\(\).*this\.isReturnBulk \? this\.sourceItems\(\) : this\.completedItems\(\)/)
|
||||
assert.match(palletWork, /this\.selectedDestination = this\.sourceItems\(\)\[0\]/)
|
||||
assert.match(palletWork, /this\.completedItems\(\)\.length !== this\.eligibleItems\.length/)
|
||||
assert.match(palletWork, /贴标数量等于本托盘全部/)
|
||||
assert.match(palletWork, /贴标数量少于本托盘库存/)
|
||||
assert.match(palletWork, /v-if="!isReturnBulk"[\s\S]*name="relabel-source"/)
|
||||
assert.match(palletWork, /await this\.run\(async \(\) => \{ const value = await resolvePallet\(row\.palletCode\)/)
|
||||
test('partial return completion requires current-round independent box then truck binding and photo', () => {
|
||||
const { value } = setup()
|
||||
value.current.items.forEach(item => { item.relabelledPackages = item.expectedPackages })
|
||||
value.current.relabelledPackages = 8
|
||||
value.current.deliveryMethod = 'EXPRESS'
|
||||
assert.match(value.completionBlockReason, /独立新箱/)
|
||||
value.current.history.push({ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'NEW' }) })
|
||||
value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.returnQuantity = 8 })
|
||||
assert.equal(value.completionBlockReason, '')
|
||||
value.current.deliveryMethod = 'TRUCK'
|
||||
assert.match(value.completionBlockReason, /组托/)
|
||||
value.current.history.push({ action: 'PALLET_BIND', detail: JSON.stringify({ round: 'INITIAL', cartonMarks: ['NEW'] }) })
|
||||
assert.equal(value.completionBlockReason, '')
|
||||
value.photoFileIds = []
|
||||
assert.match(value.completionBlockReason, /照片/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test'
|
||||
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, /退货箱唛换标必须先确认同一来源包裹\/箱/)
|
||||
assert.match(page, /this\.taskTab === 'carton' \? 'quick-carton' : 'quick-sku'/)
|
||||
})
|
||||
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}])
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
|
||||
const { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
|
||||
const source = await readFile(new URL('../src/components/ReturnRelabelPackWork.vue', import.meta.url), 'utf8')
|
||||
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
|
||||
function setup() {
|
||||
const calls = [], events = []
|
||||
const component = new Function('requiresReturnPack','returnDeductions','returnPackFacts','returnSelections','packReturnRelabelCartons','warehouseIdempotencyKey','releaseWarehouseKey',body)(requiresReturnPack,returnDeductions,returnPackFacts,returnSelections,async(no,payload)=>{calls.push(payload);return {relabelNo:no}},()=>({value:'stable',signature:'s'}),()=>{})
|
||||
const item = (mark,done) => ({returnCartonId:mark,cartonMark:mark,operationType:'SKU_LABEL',expectedPackages:1,relabelledPackages:done,skippedPackages:0,exceptionPackages:0,skuItems:[{skuCode:'SKU',quantity:3}]})
|
||||
const value = {...component.data(),task:{relabelNo:'RL',items:[item('A',1),item('B',0)],history:[]},$emit:(...args)=>events.push(args)}
|
||||
for(const[name,fn]of Object.entries(component.methods))value[name]=fn.bind(value)
|
||||
for(const[name,fn]of Object.entries(component.computed))Object.defineProperty(value,name,{get:fn.bind(value)})
|
||||
return {value,calls,events}
|
||||
}
|
||||
test('packing cannot run with an unfinished source, missing measurement, skipped or exception fact',async()=>{
|
||||
const {value,calls}=setup(); await value.pack();assert.equal(calls.length,0)
|
||||
value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
|
||||
assert.equal(value.ready,false);value.task.items[1].relabelledPackages=1;assert.equal(value.ready,true)
|
||||
value.task.items[0].skippedPackages=1;assert.equal(value.ready,false);value.task.items[0].skippedPackages=0
|
||||
value.task.items[0].exceptionPackages=1;assert.equal(value.ready,false)
|
||||
})
|
||||
test('pack sends every source with exact SKU piece deductions and generated target; saved history prevents repeat',async()=>{
|
||||
const {value,calls,events}=setup();value.task.items[1].relabelledPackages=1;value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
|
||||
await value.pack()
|
||||
assert.deepEqual(calls,[{sourceCartonMarks:['A','B'],targetCartonMark:null,sourceDeductions:[{cartonMark:'A',skuCode:'SKU',quantity:3},{cartonMark:'B',skuCode:'SKU',quantity:3}],length:20,width:30,height:40,weight:2,idempotencyKey:'stable'}])
|
||||
assert.ok(events.some(row=>row[0]==='saved'))
|
||||
value.task.history=[{action:'RETURN_PACK',detail:JSON.stringify({round:'INITIAL',cartonMark:'NEW',quantity:6,label:{printableHtml:'<html/>'}})}]
|
||||
assert.equal(value.ready,false);assert.equal(value.facts[0].cartonMark,'NEW')
|
||||
})
|
||||
@@ -12,17 +12,17 @@ test('box merge component compiles both its script and template', () => {
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'box-merge' }).errors, [])
|
||||
})
|
||||
function instance() {
|
||||
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, component: null })
|
||||
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null })
|
||||
const work = { ...component.data(), plan: {customerId:'C1',sources:[{cartonMark:'A',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',items:[{skuCode:'SKU1',quantity:2}]}]}, $emit(){} }
|
||||
for (const [name, method] of Object.entries(component.methods)) work[name] = method.bind(work)
|
||||
work.save = () => {}
|
||||
return {work,component}
|
||||
}
|
||||
test('new plan box displays all sources without scanning and keeps independent SKU deductions', async () => {
|
||||
test('new plan requires scanning each source and keeps independent SKU deductions', async () => {
|
||||
const {work,component}=instance(); work.choose('NEW')
|
||||
assert.equal(work.sources.length,2)
|
||||
assert.equal(work.active,'')
|
||||
await work.acceptScan('A')
|
||||
assert.equal(work.sources.length,0)
|
||||
assert.equal(work.active,'source')
|
||||
await work.acceptScan('A'); await work.acceptScan('B')
|
||||
work.setDeduction('A','SKU1',1); work.setDeduction('A','SKU2',2); work.setDeduction('B','SKU1',2)
|
||||
work.dimensions={length:10,width:20,height:30,weight:1}
|
||||
assert.equal(work.sources.length,2)
|
||||
@@ -36,7 +36,7 @@ test('existing box uses another box as target and never removes target inventory
|
||||
assert.equal(work.plannedItems().length,1)
|
||||
assert.equal(work.target.items[0].quantity,2)
|
||||
assert.equal(component.computed.canComplete.call(work),true)
|
||||
work.choose('NEW'); assert.equal(work.target,null); assert.equal(work.sources.length,2)
|
||||
work.choose('NEW'); assert.equal(work.target,null); assert.equal(work.sources.length,0)
|
||||
assert.equal(work.plannedItems().length,0)
|
||||
})
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ function scanState() {
|
||||
open(row) { this.task = row }, $nextTick(callback) { callback() } }
|
||||
value.run = scope.page.methods.run.bind(value)
|
||||
value.enterTask = scope.page.methods.enterTask.bind(value)
|
||||
value.offerNextPalletTask = scope.page.methods.offerNextPalletTask.bind(value)
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -63,6 +64,35 @@ test('box and single-batch pallet scans claim and open counting directly without
|
||||
}
|
||||
})
|
||||
|
||||
test('completed pallet offers remaining batches and starts only after confirmation', async () => {
|
||||
for (const confirm of [false, true]) {
|
||||
const value = scanState(); value.task = null
|
||||
const next = { receiptNo: 'RR-NEXT', palletCode: 'PLT-1', status: 'PENDING' }
|
||||
scope.scanReturnCountTasks = async code => { assert.equal(code, 'PLT-1'); return [next, { ...next, receiptNo: 'RR-DONE', status: 'CONFIRMED' }, { ...next, palletCode: 'PLT-2' }] }
|
||||
scope.getCurrentReturnCountTask = async () => null
|
||||
let claimed = 0
|
||||
scope.beginReturnCountTask = async receiptNo => { claimed++; assert.equal(receiptNo, 'RR-NEXT'); return { ...next, status: 'COUNTING' } }
|
||||
value.ask = async options => { assert.match(options.content, /还有 1 项/); assert.equal(options.cancelText, '暂不清点'); return { confirm } }
|
||||
await value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' })
|
||||
assert.equal(claimed, confirm ? 1 : 0)
|
||||
assert.equal(value.task?.status || null, confirm ? 'COUNTING' : null)
|
||||
}
|
||||
})
|
||||
|
||||
test('no remaining pallet task skips prompt and claim conflicts remain visible', async () => {
|
||||
const value = scanState(); value.task = null
|
||||
value.ask = async () => assert.fail('no remaining task should not prompt')
|
||||
scope.scanReturnCountTasks = async () => []
|
||||
await value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' })
|
||||
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-NEXT', palletCode: 'PLT-1', status: 'COUNTING' }]
|
||||
value.ask = async () => ({ confirm: true })
|
||||
scope.getCurrentReturnCountTask = async () => null
|
||||
scope.beginReturnCountTask = async () => { throw Object.assign(new Error('该任务正在由其他人员清点'), { code: 'A0409' }) }
|
||||
await value.run(() => value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' }))
|
||||
assert.equal(value.error.code, 'A0409')
|
||||
assert.equal(value.task, null)
|
||||
})
|
||||
|
||||
test('direct scan cannot claim a different task while the operator has unfinished work', async () => {
|
||||
const value = scanState()
|
||||
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-NEW', returnNo: 'RT-NEW' }]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc'
|
||||
const filename = 'src/components/ReturnLabelConfirmation.vue'
|
||||
const { descriptor } = parse(readFileSync(new URL('../' + filename, import.meta.url), 'utf8'))
|
||||
test('physical label confirmation compiles and requires its own barcode before calling the service', async () => {
|
||||
compileScript(descriptor, { id: 'return-label' })
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'return-label' }).errors, [])
|
||||
const calls = []
|
||||
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), {
|
||||
component: null, ScanTargetInput: {}, confirmReturnLabel: async mark => calls.push(mark)
|
||||
})
|
||||
const label = { ...component.data(), cartonMark: 'RP-1' }
|
||||
for (const [name, method] of Object.entries(component.methods)) label[name] = method.bind(label)
|
||||
await label.confirm('RP-OTHER'); assert.equal(calls.length, 0); assert.equal(label.confirmed, false)
|
||||
await label.acceptScan('RP-1'); assert.deepEqual(calls, ['RP-1']); assert.equal(label.confirmed, true)
|
||||
await label.confirm('RP-1'); assert.equal(calls.length, 1)
|
||||
})
|
||||
@@ -9,7 +9,7 @@ const { descriptor } = parse(readFileSync(new URL('../' + filename, import.meta.
|
||||
function instance(api = {}) {
|
||||
let saved = {}, reset = false
|
||||
const component = vm.runInNewContext(descriptor.script.content.replace(/^\s*import .*$/gm, '').replace('export default', 'component ='), {
|
||||
component: null, PortalHeader: {}, ScanTargetInput: {}, ReturnBoxMergeWork: {}, session: {currentUser:{}}, playSuccessSound() {},
|
||||
component: null, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ReturnBoxMergeWork: {}, session: {currentUser:{}}, playSuccessSound() {},
|
||||
updateWarehouseWork(domain, patch) { saved = {...saved,...patch} }, resetWarehouseDomain() { reset = true }, ...api
|
||||
})
|
||||
const page = {...component.data(), activePlan:{planNo:'P1'}, busy:true}
|
||||
@@ -32,17 +32,19 @@ test('unified scan resolves an actual carton and passes its SKU contents into bo
|
||||
assert.equal(page.mode,'BOX');assert.equal(page.initialSource,carton)
|
||||
assert.equal(saved().initialSource.items[0].quantity,2);assert.equal(skuCalls,0)
|
||||
})
|
||||
test('only carton not-found falls back to customer SKU stock; conflicts do not fall back', async () => {
|
||||
test('unknown SKU cannot silently choose parcel sources; occupied sources retain the conflict', async () => {
|
||||
let skuCalls=0, queryError={code:'A0404'}
|
||||
const {page}=instance({scanReturnCarton:async()=>{throw queryError},findPackingOrders:async()=>{skuCalls++;return {customers:[{customerId:'C1',orders:[{returnNo:'R1'}],skus:[{skuCode:'SKU1',availableQuantity:3}]}]}}})
|
||||
const {page}=instance({scanReturnCarton:async()=>{throw queryError},findPackingOrders:async()=>{skuCalls++;return {}}})
|
||||
page.activePlan=null;page.busy=false;page.selectedOrders=[];page.searchCode=''
|
||||
page.setError=error=>{page.error=error}
|
||||
await page.search('SKU1')
|
||||
assert.equal(page.mode,'SKU');assert.equal(page.selectedCustomer.skus[0].availableQuantity,3)
|
||||
assert.match(page.error.message,/扫描来源包裹/)
|
||||
assert.equal(skuCalls,0)
|
||||
queryError={code:'A0409',message:'箱库存被占用'}
|
||||
await page.search('LOCKEDBOX')
|
||||
assert.equal(skuCalls,1);assert.equal(page.error.code,'A0409')
|
||||
await page.search('BOX1')
|
||||
assert.equal(page.error.message,'箱库存被占用');assert.equal(skuCalls,0)
|
||||
})
|
||||
|
||||
test('existing target immediately clears work and returns to refreshed plans even during completion', () => {
|
||||
const {page,reset}=instance()
|
||||
page.packingCompleted({targetMode:'EXISTING',labels:[{cartonMark:'B'}]})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
|
||||
function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
|
||||
const context = vm.createContext({ reactive: value => value, PortalHeader: {}, ScanTargetInput: {},
|
||||
const context = vm.createContext({ reactive: value => value, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {},
|
||||
playSuccessSound() {}, playErrorSound() {}, confirmReturnReceipt: confirm,
|
||||
uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key) } })
|
||||
const store = readFileSync(new URL('../src/stores/warehouse-work.js', import.meta.url), 'utf8')
|
||||
@@ -20,13 +20,14 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
|
||||
palletCode: ' PLT-TEST ', palletLength: 10, palletWidth: 10, palletHeight: 10, palletWeight: 1,
|
||||
quantity: () => 2, boxCount: () => 1
|
||||
})
|
||||
Object.defineProperty(instance, 'receiptBatch', { get: () => vm.runInContext('warehouseWork.returnReceipt.pendingBatch', context) })
|
||||
return instance
|
||||
}
|
||||
|
||||
test('SKU lookup displays all pending order lines while retaining scanned SKU for pallet generation', () => {
|
||||
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
|
||||
const component = vm.runInNewContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { PortalHeader: {}, ScanTargetInput: {} })
|
||||
const component = vm.runInNewContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
|
||||
const lines = [{ skuCode: 'SKU-A', expectedQuantity: 6 }, { skuCode: 'SKU-B', expectedQuantity: 4 }]
|
||||
const instance = { current: { expectedItems: lines }, selectedSku: 'SKU-A' }
|
||||
instance.selectedItems = component.computed.selectedItems.call(instance)
|
||||
@@ -59,14 +60,14 @@ test('PALLET page submits the required palletCode through the actual confirmatio
|
||||
|
||||
test('parcel signing allows an empty code but still rejects quantities above the remaining amount', async () => {
|
||||
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
|
||||
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { PortalHeader: {}, ScanTargetInput: {} })
|
||||
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 2, unitType: 'PARCEL', unitCode: '', quantity: () => 2, remaining: () => 3 }
|
||||
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
|
||||
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 2, unitType: 'PARCEL', boxRows: [{ items: { A: 2 } }], remaining: () => 3 }
|
||||
assert.equal(component.computed.canConfirm.call(state), true)
|
||||
state.quantity = () => 4
|
||||
state.boxRows[0].items.A = 4
|
||||
assert.equal(component.computed.canConfirm.call(state), false)
|
||||
const calls = []
|
||||
const page = setup(async payload => { calls.push(payload); return { unitType: 'PARCEL' } })
|
||||
page.unitType = 'PARCEL'; page.unitCode = ''; page.trackingNo = ''
|
||||
page.unitType = 'PARCEL'; page.unitCode = ''; page.trackingNo = ''; page.boxRows = [{ items: { 'SKU-TEST': 2 } }]
|
||||
await page.confirmReceipt()
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].trackingNo, undefined)
|
||||
@@ -77,13 +78,72 @@ test('parcel signing allows an empty code but still rejects quantities above the
|
||||
test('parcel waybill is submitted as trackingNo and leaves unitCode for the server', async () => {
|
||||
const calls = []
|
||||
const page = setup(async payload => { calls.push(payload); return { unitType: 'PARCEL' } })
|
||||
page.unitType = 'PARCEL'; page.unitCode = ' sf561626 '; page.trackingNo = 'stale-from-pallet'
|
||||
page.unitType = 'PARCEL'; page.unitCode = ' sf561626 '; page.trackingNo = 'stale-from-pallet'; page.boxRows = [{ items: { 'SKU-TEST': 2 } }]
|
||||
await page.confirmReceipt()
|
||||
assert.equal(calls[0].trackingNo, 'sf561626')
|
||||
assert.equal(calls[0].unitCode, undefined)
|
||||
assert.equal(calls[0].palletCode, undefined)
|
||||
})
|
||||
|
||||
test('batch waybill scans stay on the single parcel waybill input', () => {
|
||||
const page = setup(async () => ({}))
|
||||
page.unitType = 'PARCEL'; page.activeTarget = 'unit'; page.palletCode = ''
|
||||
page.handleScan('WAYBILL-ONE')
|
||||
assert.equal(page.unitCode, 'WAYBILL-ONE'); assert.equal(page.palletCode, '')
|
||||
assert.equal(page.activeTarget, 'unit')
|
||||
page.handleScan('WAYBILL-CORRECTED')
|
||||
assert.equal(page.unitCode, 'WAYBILL-CORRECTED')
|
||||
})
|
||||
|
||||
test('parcel rows must be nonempty and summed quantities cannot exceed the order remainder', () => {
|
||||
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
|
||||
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
|
||||
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 4, unitType: 'PARCEL', boxRows: [{ items: { A: 2 } }, { items: { A: 2 } }], remaining: () => 3 }
|
||||
assert.equal(component.computed.canConfirm.call(state), false)
|
||||
state.remaining = () => 5
|
||||
assert.equal(component.computed.canConfirm.call(state), true)
|
||||
state.boxRows[1].items = {}
|
||||
assert.equal(component.computed.canConfirm.call(state), false)
|
||||
})
|
||||
|
||||
test('one submission signs multiple mixed-SKU parcels sharing one waybill and independent labels', async () => {
|
||||
const calls = []
|
||||
const page = setup(async request => {
|
||||
calls.push(JSON.parse(JSON.stringify(request)))
|
||||
return { unitType: 'PARCEL', labels: [{ cartonMark: `RP-${calls.length}`, printableHtml: 'label' }], fullyMatched: false }
|
||||
})
|
||||
page.unitType = 'PARCEL'; page.unitCode = ' WAYBILL-BATCH '; page.trackingNo = 'stale'; page.selectedItems = [{ skuCode: 'SKU-TEST' }, { skuCode: 'SKU-B' }]
|
||||
page.boxRows = [{ items: { 'SKU-TEST': 1, 'SKU-B': 2 } }, { items: { 'SKU-TEST': 3 } }]
|
||||
await page.confirmReceipt()
|
||||
assert.equal(calls.length, 2)
|
||||
assert.deepEqual(calls[0].items, [{ skuCode: 'SKU-TEST', receivedQuantity: 1 }, { skuCode: 'SKU-B', receivedQuantity: 2 }])
|
||||
assert.deepEqual(calls[1].items, [{ skuCode: 'SKU-TEST', receivedQuantity: 3 }])
|
||||
assert.equal(calls[0].trackingNo, 'WAYBILL-BATCH'); assert.equal(calls[1].trackingNo, 'WAYBILL-BATCH')
|
||||
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
|
||||
assert.deepEqual(Array.from(page.result.labels, row => row.cartonMark), ['RP-1', 'RP-2'])
|
||||
})
|
||||
|
||||
test('failed multi-parcel batch resumes immutable requests without re-signing a successful parcel', async () => {
|
||||
const saved = new Map(), calls = []
|
||||
let failSecond = true
|
||||
const confirm = async request => {
|
||||
calls.push(JSON.parse(JSON.stringify(request)))
|
||||
if (request.items[0].receivedQuantity === 2 && failSecond) throw new Error('response lost')
|
||||
return { unitType: 'PARCEL', labels: [{ cartonMark: `RP-${request.items[0].receivedQuantity}`, printableHtml: 'label' }], fullyMatched: false }
|
||||
}
|
||||
const page = setup(confirm, saved)
|
||||
page.unitType = 'PARCEL'; page.unitCode = 'WAYBILL-BATCH'; page.boxRows = [{ items: { 'SKU-TEST': 1 } }, { items: { 'SKU-TEST': 2 } }]
|
||||
await page.confirmReceipt()
|
||||
assert.equal(page.receiptBatch.entries[0].result.labels[0].cartonMark, 'RP-1')
|
||||
failSecond = false
|
||||
const restored = setup(confirm, saved)
|
||||
await restored.confirmReceipt()
|
||||
assert.equal(calls.length, 3)
|
||||
assert.equal(calls[1].idempotencyKey, calls[2].idempotencyKey)
|
||||
assert.deepEqual(calls[1], calls[2])
|
||||
assert.equal(restored.result.labels.length, 2)
|
||||
})
|
||||
|
||||
test('partial receipt continues the same order with fresh pallet measurements', async () => {
|
||||
const page = setup(async () => ({}))
|
||||
page.finishReceipt([{ fullyMatched: false }])
|
||||
|
||||
@@ -91,7 +91,7 @@ test('carton relabel uses one scan input and shows the confirmed FBA mapping aft
|
||||
assert.match(scan, /confirmedItem\?\.fbaCartonMark/)
|
||||
})
|
||||
|
||||
test('mixed relabel keeps SKU work open and return bulk also mounts pallet work', async () => {
|
||||
test('mixed relabel keeps SKU work open and return transport gates pallet work', async () => {
|
||||
const page = await read('../src/pages/relabel/relabel.vue')
|
||||
const body = page.match(/remaining\(item\) \{([^\n]+)\},/)[1]
|
||||
const remaining = new Function('item', body)
|
||||
@@ -99,7 +99,7 @@ test('mixed relabel keeps SKU work open and return bulk also mounts pallet work'
|
||||
cartonProcessedPackages: 1, skuProcessedPackages: 0, skippedPackages: 0, exceptionPackages: 0 }
|
||||
assert.equal(remaining.call({ taskTab: 'sku' }, mixed), 1)
|
||||
assert.equal(remaining.call({ taskTab: 'carton' }, mixed), 0)
|
||||
assert.match(page, /<RelabelPalletWork v-if="taskTab === 'carton' \|\| isReturnBulk"/)
|
||||
assert.match(page, /<RelabelPalletWork v-if="requiresPalletBinding && \(taskTab === 'carton' \|\| isReturnTask\)"/)
|
||||
})
|
||||
|
||||
test('relabel list confirms before starting a new task and completion returns to the list', async () => {
|
||||
|
||||
Reference in New Issue
Block a user