发布已通过 TEST 回归的 2026-10-08 批次至灰度 #6

Merged
admin merged 3 commits from release/test into main 2026-10-08 22:22:46 +08:00
14 changed files with 544 additions and 39 deletions
+1
View File
@@ -43,6 +43,7 @@ export function normalizeDispatchTask(value) {
warehouseRouteLabel: String(item?.warehouseRouteLabel || ''),
scheduledPackages: count(item?.scheduledPackages),
pickedPackages: count(item?.pickedPackages),
returnCartons: asList(item?.returnCartons).map((carton) => ({ ...carton, id: String(carton.id || ''), cartonMark: String(carton.cartonMark || ''), productQuantity: count(carton.productQuantity) })),
positions: asList(item?.positions).map((position) => ({
...position,
id: String(position?.id || ''),
+56 -3
View File
@@ -17,7 +17,17 @@ function normalizeItems(items) {
}
export function normalizePackingPreview(value = {}) {
return {
value = value && typeof value === 'object' ? value : {}
const normalizeCustomer = (customer = {}) => ({
customerId: customer.customerId == null ? '' : String(customer.customerId),
orders: Array.isArray(customer.orders) ? customer.orders.map((item) => ({
returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '')
})) : [],
skus: Array.isArray(customer.skus) ? customer.skus.map((item) => ({
skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
})) : []
})
const result = {
customerKey: value.customerId == null ? '' : String(value.customerId),
orders: Array.isArray(value.orders) ? value.orders.map((item) => ({
returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '')
@@ -26,6 +36,13 @@ export function normalizePackingPreview(value = {}) {
skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
})) : []
}
if (Array.isArray(value.customers)) result.customers = value.customers.map(normalizeCustomer).filter((item) => item.customerId)
else if (result.customerKey && result.orders.length) {
// Older service versions returned the single-customer shape. Keep the
// PDA behavior consistent by exposing it through the grouped contract.
result.customers = [{ customerId: result.customerKey, orders: result.orders, skus: result.skus }]
}
return result
}
export function normalizeReturnLabel(value = {}) {
@@ -37,16 +54,52 @@ export function normalizeReturnLabel(value = {}) {
}
}
export async function findPackingOrders(returnNo = '') {
export async function findPackingOrders(returnNo = '', mode = 'RETURN_NO') {
const query = String(returnNo || '').trim()
try {
return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?returnNo=${encoded(query)}` : ''}`))
return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?${mode === 'RETURN_NO' ? 'returnNo' : 'skuCode'}=${encoded(query)}` : ''}`))
} catch (error) {
if (isNotFound(error)) return normalizePackingPreview()
throw error
}
}
export async function listPackingPlans() {
const value = await overseasRequest('GET', `${BASE}/packing/plans`)
return Array.isArray(value) ? value.map(normalizePackingPlan) : []
}
export async function completePackingPlan(planNo, request) {
const value = await overseasRequest('POST', `${BASE}/packing/plans/${encoded(planNo)}/complete`, request)
return { planNo: String(value?.planNo || planNo), labels: Array.isArray(value?.labels) ? value.labels.map(normalizeReturnLabel) : [] }
}
export function normalizePackingPlan(value = {}) {
return { planNo: String(value.planNo || ''), customerId: String(value.customerId || ''), returnNos: Array.isArray(value.returnNos) ? value.returnNos.map(String) : [], sourceCartonMarks: Array.isArray(value.sourceCartonMarks) ? value.sourceCartonMarks.map(String) : [], sources: Array.isArray(value.sources) ? value.sources : [], mode: String(value.mode || ''), status: String(value.status || ''), boxes: Array.isArray(value.boxes) ? value.boxes : [], remark: String(value.remark || '') }
}
export async function getPackingPlan(planNo) {
return normalizePackingPlan(await overseasRequest('GET', `${BASE}/packing/plans/${encoded(planNo)}`))
}
export async function scanReturnCarton(mark) {
return overseasRequest('GET', `${BASE}/cartons/${encoded(mark)}`)
}
export async function mergeReturnBoxes(request) {
const value = await overseasRequest('POST', `${BASE}/cartons/merge-boxes`, request)
return { labels: [normalizeReturnLabel(value?.label)] }
}
export async function packReturnSkus(request) {
const value = await overseasRequest('POST', `${BASE}/cartons/pack`, request)
return { labels: Array.isArray(value?.labels) ? value.labels.map(normalizeReturnLabel) : [] }
}
export async function putawayReturnCarton(mark, request) {
return overseasRequest('POST', `${BASE}/cartons/${encoded(mark)}/putaway`, request)
}
export async function mergeReturnCartons(request) {
const value = await overseasRequest('POST', `${BASE}/cartons/merge`, request)
return { cartonMark: String(value?.cartonMark || ''), skuCode: String(value?.skuCode || ''), totalQuantity: count(value?.totalQuantity), label: normalizeReturnLabel(value?.label) }
+145
View File
@@ -0,0 +1,145 @@
<template>
<view>
<view class="work-card">
<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>
<text class="hint">{{ mode === 'NEW' ? '从下方来源箱选择 SKU 和扣除数量,完成后生成新箱唛。' : '扫描来源箱,再扫描另一只箱作为目标;目标箱原有库存不扣除。' }}</text>
<template v-if="mode === 'EXISTING' || (mode === 'NEW' && !plan)">
<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">
<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 class="link-button" :disabled="busy" @tap="active = 'source'">更换来源箱</button></view>
<text v-for="item in box.items || []" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
<text class="hint">来源箱已确认</text>
</view>
</view>
</template>
</view>
<view v-if="mode" class="work-card">
<text class="card-title">{{ mode === 'NEW' ? '新增目标箱' : '已有目标箱' }}</text>
<template v-if="mode === 'EXISTING'">
<ScanTargetInput name="target" v-model="targetCode" label="目标箱唛" placeholder="扫描另一只箱作为目标箱" :active="active === 'target'" @activate="active = 'target'" @submit="scanTarget" />
<button class="secondary" :disabled="busy || !sources.length || !targetCode.trim()" @tap="scanTarget(targetCode)">确认目标箱</button>
<view v-if="target" class="item-card section-gap"><text class="business-code">{{ target.cartonMark }}</text><text v-for="item in target.items" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · 原有 {{ item.quantity }} 件(不扣除)</text></view>
</template>
<template v-else><view class="dimensions"><label v-for="field in fields" :key="field.key"><text>{{ field.label }}</text><input class="small-input" type="digit" v-model="dimensions[field.key]" placeholder="请输入" /></label></view><text class="hint">体积:{{ volume }} m³</text></template>
<view v-if="mode === 'EXISTING' && plan" class="plan-boxes section-gap">
<text class="card-title">计划箱子明细</text>
<view v-for="box in (plan.sources || []).filter(box => !sources.some(source => source.cartonMark === box.cartonMark))" :key="box.cartonMark" class="plan-box">
<text class="business-code">{{ box.cartonMark }}</text>
<text v-for="item in box.items || []" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
<text class="hint">{{ target?.cartonMark === box.cartonMark ? '目标箱 · 保留原有库存' : sources.some(source => source.cartonMark === box.cartonMark) ? '来源箱已确认 · 下方填写扣除数量' : '待扫描确认' }}</text>
</view>
</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>
</view>
<button class="primary" :disabled="!canComplete" @tap="complete">{{ busy ? '处理中…' : '确认合箱' }}</button>
</view>
</view>
</template>
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes } from '@/api/return-packing.js'
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default {
components: { ScanTargetInput },
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()) } },
async mounted() {
if (!this.plan && this.initialSource) { this.choose('EXISTING'); this.sources = [this.initialSource]; this.sourceCode = this.initialSource.cartonMark; this.active = 'target'; return }
this.targetCode = warehouseWork.returnPacking.targetCode || ''
const marks = this.plan?.sourceCartonMarks || warehouseWork.returnPacking.sourceCartonMarks || []
if (this.plan) {
// Reserved plan sources are read from the reviewed plan, never from the free-stock scan endpoint.
this.sources = []
this.sourceCode = ''
this.active = 'source'
} 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' },
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) {
if (!this.mode || this.busy) return
const mark = String(raw || this.sourceCode).trim(); if (!mark) return
await this.run(async () => {
if (this.sources.some(box => box.cartonMark === mark)) {
if (this.mode === 'EXISTING') { this.sourceCode = mark; this.active = 'target'; return }
throw new Error('该来源箱已添加')
}
if (this.mode !== 'EXISTING' && this.target?.cartonMark === mark) throw new Error('来源箱不能与目标箱相同')
const planned = this.plan?.sources?.find(box => box.cartonMark === mark)
if(this.plan && !planned) throw new Error('该来源箱不在合箱计划中')
const box = planned ? {...planned,customerId:this.plan.customerId} : await scanReturnCarton(mark)
const customer = this.sources[0]?.customerId || this.target?.customerId
if (customer && customer !== box.customerId) throw new Error('不同客户的箱不能合并')
if (this.mode === 'EXISTING') {
this.sources = [box]; this.sourceCode = mark; this.deductions = {}; this.target = null; this.targetCode = ''
} else { this.sources.push(box); this.sourceCode = '' }
this.save(); this.active = this.mode === 'NEW' ? 'source' : 'target'
})
},
async scanTarget(raw) {
if (this.busy || !this.sources.length || this.mode !== 'EXISTING') return
const mark = String(raw || this.targetCode).trim(); if (!mark) return
this.targetCode = mark; this.target = null
await this.run(async () => {
if(this.sources.some(box => box.cartonMark === mark)) throw new Error('来源箱和目标箱不能相同,请扫描另一只箱')
const plannedSource = this.plan?.sources?.find(box => box.cartonMark === mark)
if (plannedSource) {
// A reviewed BOX plan may nominate one of its original boxes as the
// target. The remaining planned boxes are the source boxes.
this.target = {...plannedSource,customerId:this.plan.customerId}
this.targetCode = mark
this.deductions = Object.fromEntries(this.sources.map(box => [box.cartonMark, Object.fromEntries((box.items || []).map(item => [item.skuCode, '']))]))
this.save()
return
}
if (this.sources.some(box => box.cartonMark === mark)) throw new Error('目标箱不能与来源箱相同')
const box = await scanReturnCarton(mark)
const customer = this.plan?.customerId || this.sources[0]?.customerId
if (customer && customer !== box.customerId) throw new Error('目标箱与来源箱必须属于同一客户')
this.target = box; this.save()
})
},
acceptScan(value) { if (!this.active) return; return this.active === 'source' ? this.scanSource(value) : this.scanTarget(value) },
deduction(mark, sku) { return this.deductions?.[mark]?.[sku] || '' },
setDeduction(mark, sku, raw) {
const box = this.sources.find(item => item.cartonMark === mark)
const max = Number(box?.items?.find(item => item.skuCode === sku)?.quantity || 0)
const value = Math.min(max, Math.max(0, Math.floor(Number(raw) || 0)))
this.deductions = { ...this.deductions, [mark]: { ...(this.deductions[mark] || {}), [sku]: value } }
},
plannedItems() { return this.sources.flatMap(box => (box.items || []).map(item => ({ skuCode: item.skuCode, quantity: Number(this.deduction(box.cartonMark, item.skuCode)) || 0 }))).filter(item => item.quantity > 0) },
async complete() {
if (!this.canComplete) return
const sourceDeductions=this.sources.flatMap(box => (box.items || []).map(item => ({cartonMark:box.cartonMark,skuCode:item.skuCode,quantity:Number(this.deduction(box.cartonMark,item.skuCode))||0}))).filter(item=>item.quantity>0)
const totals={}; for(const item of sourceDeductions) totals[item.skuCode]=(totals[item.skuCode]||0)+item.quantity
const items=Object.entries(totals).map(([skuCode,quantity])=>({skuCode,quantity}))
const box={items,...(this.mode==='NEW'?Object.fromEntries(this.fields.map(f=>[f.key,Number(this.dimensions[f.key])])):{targetCartonMark:this.target.cartonMark})}
const payload = { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCartonMark: this.target?.cartonMark,sourceDeductions,...box }
const key = warehouseIdempotencyKey('returnPacking', 'box-merge', { ...payload, planNo: this.plan?.planNo })
await this.run(async () => {
const result = this.plan ? await completePackingPlan(this.plan.planNo, { boxes: [box], sourceDeductions, idempotencyKey: key.value }) : await mergeReturnBoxes({ ...payload, idempotencyKey: key.value })
releaseWarehouseKey('returnPacking', key.signature); this.$emit('completed', { ...result, targetMode: this.mode })
})
},
async run(work) { this.busy = true; this.$emit('busy-change', true); try { await work() } catch (error) { this.$emit('error', error) } finally { this.busy = false; this.$emit('busy-change', false) } }
}
}
</script>
<style scoped lang="scss">
@import '../pages/warehouse/operations.scss';
.business-code{font-size:13px}.item-card{padding:8px}.secondary,.primary{min-height:36px}.source-quantity{display:flex;align-items:center;justify-content:space-between;gap:8px}.small-input{width:90px;height:30px;padding:0 6px;border:1px solid #cbd8e8;border-radius:6px;text-align:center;font-size:11px}.plan-remark{display:block;color:#315985;font-weight:600}.choice-row{display:flex;gap:8px}.choice-row button{flex:1}.dimensions{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.dimensions{gap:8px 12px;padding:8px 0}.dimensions label{display:flex;align-items:center;gap:6px;min-width:0}.dimensions label text{flex-shrink:0;width:44px;font-size:11px;color:#617287}.dimensions .small-input{box-sizing:border-box;width:100%;min-width:0;height:30px;background:#f8fbff;font-size:12px}.choice-row button{height:34px;min-height:34px;line-height:32px;margin:4px 0;padding:0 8px;font-size:11px}.plan-box{padding:7px 0;border-bottom:1px solid #e6edf5}.source-quantity .meta-line{flex:1;min-width:0;overflow-wrap:anywhere}.source-quantity .small-input{flex-shrink:0;width:72px}.primary{height:36px;line-height:36px;padding:0 10px}.business-code{overflow-wrap:anywhere}
</style>
+21 -12
View File
@@ -10,7 +10,7 @@
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">排车拣货详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
<view class="fulfillment-card">
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号或托盘码" placeholder="扫描排车单号 / 托盘码" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号 / 托盘码 / 来源箱唛" placeholder="扫描排车单号、托盘码或退货箱唛" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ current ? '匹配托盘' : '打开排车任务' }}</button>
<text class="scan-help">网络失败不会清空草稿,再次提交会沿用同一幂等键。</text>
</view>
@@ -47,9 +47,10 @@
</view>
<view v-if="current.items.length" class="item-list">
<view v-for="item in current.items" :key="item.palletNo" class="fulfillment-card item-card" :class="{ selected: selectedItem?.palletNo === item.palletNo }" @tap="selectPallet(item)">
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)">
<view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text>{{ item.pickedPackages }}/{{ item.scheduledPackages }} 箱</text></view>
<text class="task-meta">{{ routeLabel(item) }}</text>
<text v-for="carton in item.returnCartons" :key="carton.cartonMark" class="task-meta">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text>
<view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view>
</view>
</view>
@@ -57,7 +58,8 @@
<view v-if="selectedItem && selectedRemaining > 0" class="fulfillment-card">
<text class="card-title">本次拣货:{{ selectedItem.palletNo }}</text>
<view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view>
<text v-if="selectedItem.returnCartons.length" class="task-meta">{{ selectedReturnCarton ? `已扫描 ${selectedReturnCarton.cartonMark}` : '请扫描此托盘上的来源箱唛,每次拣货一箱' }}</text>
<view v-else class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view>
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认拣货' }}</button>
</view>
@@ -105,16 +107,17 @@
computed: {
recentTaskNo() { return fulfillment.dispatch.currentNo || '' },
packages() { return fulfillment.dispatch.packages },
selectedItem() { return this.current?.items?.find((item) => item.palletNo === fulfillment.dispatch.palletCode) || null },
selectedItem() { return this.current?.items?.find((item) => fulfillment.dispatch.dispatchItemId ? item.dispatchItemId === fulfillment.dispatch.dispatchItemId : item.palletNo === fulfillment.dispatch.palletCode) || null },
selectedReturnCarton() { return this.selectedItem?.returnCartons?.find((carton) => carton.cartonMark === fulfillment.dispatch.cartonCode && carton.status === 'OUTBOUND_PENDING') || null },
selectedPosition() { return this.selectedItem?.positions?.find((item) => item.availablePackages > 0) || this.selectedItem?.positions?.[0] || null },
selectedRemaining() { return Math.max(0, Math.min(this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0, this.selectedPosition?.availablePackages || 0)) },
selectedRemaining() { const remaining = this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0; return Math.max(0, this.selectedItem?.returnCartons?.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0)) },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.scheduledPackages, 0) },
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
allPicked() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
pickingCompleted() { return finishedStatuses.includes(this.current?.status) },
departed() { return this.current?.status === 'DEPARTED' },
canPick() { return this.current?.canPick === true && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining && !this.pickingCompleted },
canPick() { return this.current?.canPick === true && (this.selectedItem?.returnCartons?.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) && !this.pickingCompleted },
canCompleteStage() { return Boolean(this.current) && !this.pickingCompleted },
canComplete() { return this.current?.canPick === true && this.plannedPackages > 0 && this.remainingPackages === 0 },
canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed }
@@ -144,26 +147,32 @@
async loadTasks() { await this.run('加载排车任务', async () => { this.tasks = await listDispatchTasks(); this.current = null }, true) },
async openTask(dispatchNo) {
const code = String(dispatchNo || '').trim(); if (!code) return
await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: previousNo === this.current.dispatchNo ? fulfillment.dispatch.palletCode : '' }); this.scanCode = '' }, true)
await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); const same = previousNo === this.current.dispatchNo; updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '' }); this.scanCode = '' }, true)
},
closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', packages: 1 }); this.loadTasks() },
closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.loadTasks() },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
console.info('[Overseas PDA dispatch] scan', { code, task: this.current?.dispatchNo || '' })
this.scanCode = code
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
const cartonItem = this.current.items.find((item) => item.returnCartons?.some((carton) => carton.cartonMark === code))
if (cartonItem) {
if (cartonItem.returnCartons.find((carton) => carton.cartonMark === code)?.status !== 'OUTBOUND_PENDING') return this.setError(new Error('该箱已拣货或不可出库'), '扫码未匹配')
this.selectPallet(cartonItem); updateFulfillmentDraft('dispatch', { cartonCode: code, packages: 1 }); this.scanCode = ''; playSuccessSound(); return
}
const item = this.current.items.find((value) => [value.palletNo, value.fbaWarehouseCode].includes(code))
if (!item) return this.setError(new Error('扫码未匹配当前排车任务的托盘或稳定业务码'), '扫码未匹配')
this.selectPallet(item); this.scanCode = ''; playSuccessSound()
},
selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, cartonCode: '', packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
async submitPick() {
if (!this.canPick) return
const payload = { dispatchNo: this.current.dispatchNo, palletNo: this.selectedItem.palletNo, positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
const pick = this.selectedReturnCarton ? { positionId: null, cartonMark: this.selectedReturnCarton.cartonMark, packages: 1, productQuantity: this.selectedReturnCarton.productQuantity } : { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
const payload = { dispatchNo: this.current.dispatchNo, dispatchItemId: this.selectedItem.dispatchItemId, ...pick }
const key = fulfillmentIdempotencyKey('dispatch', 'pick', payload)
await this.run('提交排车拣货', async () => {
await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0, idempotencyKey: key.value })
this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { ...pick, idempotencyKey: key.value })
this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
})
},
async completePicking() {
+101 -20
View File
@@ -2,18 +2,36 @@
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader title="退货合箱" :user="user" fallback-url="/pages/module/module?key=inbound" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="!result" class="work-card"><text class="card-title">添加退货单</text><ScanTargetInput name="returnNo" v-model="searchCode" label="退货单号" placeholder="扫描或输入完整退货单号" :active="true" @submit="search" /><button class="primary" :disabled="!searchCode.trim() || busy" @tap="search(searchCode)">{{ busy ? '查询中…' : '查询可合箱库存' }}</button></view>
<button v-if="!result && (activePlan || mode === 'BOX')" class="link-button" :disabled="busy" @tap="leavePlan">返回扫码 / 计划列表</button>
<ReturnBoxMergeWork v-if="mode === 'BOX' && !result" ref="boxWork" :key="activePlan?.planNo || initialSource?.cartonMark || 'direct'" :plan="activePlan" :initial-source="initialSource" @busy-change="busy = $event" @completed="packingCompleted" @error="setError($event, '箱合并失败')" />
<view v-if="!result && !activePlan && mode !== 'BOX'" class="work-card"><text class="card-title">扫描 SKU 或箱号</text><ScanTargetInput name="stock" v-model="searchCode" label="SKU 或箱号" placeholder="扫描 SKU 或箱号,查询可合箱库存" :active="true" @submit="search" /><button class="primary" :disabled="!searchCode.trim() || busy" @tap="search(searchCode)">{{ busy ? '查询中…' : '查询可合箱库存' }}</button></view>
<view v-if="!result && !activePlan && plans.length" class="work-card"><view class="row-head"><text class="card-title">待作业合箱计划</text><text class="meta-line">审核通过后推送</text></view><button v-for="plan in plans" :key="plan.planNo" class="task-card candidate-card" @tap="startPlan(plan)"><view class="row-head"><text class="business-code">{{ plan.planNo }}</text><text class="status-pill">{{ plan.status === 'READY' ? '待合箱' : plan.status }}</text></view><text class="meta-line">退货单:{{ plan.returnNos.join('、') }}</text><text class="select-link">开始作业 ›</text></button></view>
<view v-if="candidates !== null && !result" class="section-gap"><view v-if="!candidates.length" class="empty-box"><text class="empty-copy">没有匹配的已完成退货单或可用库存</text></view><view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="addOrder(item)"><view class="row-head"><view><text class="business-code">{{ item.returnNo }}</text><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text></view><text class="select-link">添加 ›</text></view></button></view></view>
<view v-if="customerCandidates.length && !selectedCustomer && !result" class="section-gap"><view class="work-card"><text class="card-title">选择客户</text><text class="hint">该 SKU 在多个客户有可用退货库存,请先选择客户</text><view class="task-list"><button v-for="(customer, index) in customerCandidates" :key="index" class="task-card candidate-card" @tap="selectCustomer(customer)"><view class="row-head"><view><text class="business-code">{{ customerLabel(customer, index) }}</text><text class="meta-line">可用 SKU {{ customer.skus.length }} 个 · 退货单 {{ customer.orders.length }} 个</text></view><text class="select-link">选择 ›</text></view></button></view></view></view>
<view v-if="candidates !== null && !customerCandidates.length && !result" class="section-gap"><view v-if="!candidates.length" class="empty-box"><text class="empty-copy">没有匹配的已完成退货单或可用库存</text></view><view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="addOrder(item)"><view class="row-head"><view><text class="business-code">{{ item.returnNo }}</text><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text></view><text class="select-link">添加 ›</text></view></button></view></view>
<template v-if="selectedOrders.length && !result">
<view class="work-card"><text class="card-title">已选退货单</text><view class="selected-tags"><button v-for="item in selectedOrders" :key="item.returnNo" class="selected-tag" @tap="removeOrder(item.returnNo)">{{ item.returnNo }} ×</button></view><text class="hint">不同客户的退货单不能合箱;选择时会自动校验。</text></view>
<view class="work-card"><text class="card-title">新箱 SKU 数量</text><view class="item-list"><view v-for="item in availableSkus" :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">可用 {{ item.availableQuantity }}</text></view><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></view></view>
<view class="work-card"><text class="card-title">新箱箱规</text><view class="dimension-grid"><label v-for="field in dimensionFields" :key="field.key"><text>{{ field.label }}</text><input class="number-input" type="digit" :value="packing[field.key]" :placeholder="field.placeholder" @input="setDimension(field.key, $event.detail.value)" /></label></view></view>
<template v-if="selectedOrders.length && !result && mode === 'SKU'">
<view class="work-card"><text class="card-title">{{ activePlan ? `合箱计划 ${activePlan.planNo}` : '客户散件库存' }}</text><text class="hint">{{ activePlan ? '数量已由计划锁定,请分配到目标箱。' : '可用数量来自该客户同仓全部散件库存,后台自动追溯来源退货单。' }}</text></view>
<view class="work-card"><text class="card-title">新箱 SKU 数量</text><view class="item-list"><view v-for="item in availableSkus" :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">可用 {{ item.availableQuantity }}</text></view><view class="quantity-row"><text>装入数量</text><input class="number-input" type="number" :disabled="!!activePlan" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view></view></view></view>
<button class="secondary" :disabled="busy || !mergeQuantity" @tap="distributeBoxes">按单 SKU 一箱分配</button>
<view v-for="(box,index) in targetBoxes" :key="index" class="work-card">
<view class="row-head"><text class="card-title">目标箱 {{ index + 1 }}</text><button class="link-button" @tap="removeBox(index)">删除</button></view>
<view v-for="item in availableSkus" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }}</text><input class="number-input" type="number" :value="box.quantities?.[item.skuCode] || ''" placeholder="装入数量" @input="setBoxQuantity(index,item.skuCode,$event.detail.value)" /></view>
<view class="dimension-grid section-gap"><label v-for="field in dimensionFields" :key="field.key"><text>{{ field.label }}</text><input class="number-input" type="digit" :value="box[field.key]" :placeholder="field.placeholder" @input="setBoxField(index,field.key,$event.detail.value)" /></label></view>
<text class="hint">体积 {{ boxVolume(box) }} m³</text>
</view>
<button class="secondary" :disabled="busy" @tap="addBox">新增目标箱(可混 SKU)</button>
<button class="primary" :disabled="!canMerge || busy" @tap="mergeCartons">{{ busy ? '合箱中…' : `确认合箱(${mergeQuantity} 件)` }}</button>
</template>
<view v-if="result" class="success-box result-card"><text class="success-title">退货合箱成功</text><text class="result-carton">{{ result.label.cartonMark || result.cartonMark }}</text><text class="meta-line">来源退货单:{{ result.label.sourceReturnNo || '—' }}</text><text class="meta-line">箱规:{{ sizeText(result.label) }}</text><view class="item-list section-gap"><view v-for="item in result.label.items" :key="item.skuCode" class="item-card"><view class="row-head"><text class="business-code">{{ item.skuCode }}</text><text class="remaining-copy">{{ item.quantity }} 件</text></view><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view></view><button class="secondary" @tap="copyCartonMark">复制新箱号</button><button class="primary" @tap="nextPacking">继续合箱</button></view>
<view v-if="result" class="success-box result-card"><text class="success-title">合箱完成 · {{ result.labels.length }} 箱</text>
<view v-for="label in result.labels" :key="label.cartonMark" class="item-card section-gap">
<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>
</view>
<button class="primary" :disabled="busy" @tap="nextPacking">返回合箱计划列表</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="selectedOrders.length" class="secondary" :disabled="busy" @tap="refreshSelected">重新读取可用库存</button></view>
</view></scroll-view>
</view>
@@ -22,40 +40,103 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { findPackingOrders, mergeReturnCartons } from '@/api/return-packing.js'
import ReturnBoxMergeWork from '@/components/ReturnBoxMergeWork.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'
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, candidates: null, previews: {}, 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: '重量' }] } },
components: { PortalHeader, ScanTargetInput, ReturnBoxMergeWork },
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 },
targetBoxes() { return this.packing.targetBoxes || [] },
searchCode: { get() { return this.packing.searchCode }, set(value) { updateWarehouseWork('returnPacking', { searchCode: value }) } },
selectedOrders() { return Array.isArray(this.packing.selectedOrders) ? this.packing.selectedOrders : [] },
availableSkus() { const result = new Map(); for (const order of this.selectedOrders) for (const item of this.previews[order.returnNo]?.skus || []) { const current = result.get(item.skuCode) || { ...item, availableQuantity: 0 }; current.availableQuantity += Number(item.availableQuantity || 0); result.set(item.skuCode, current) } return [...result.values()] },
availableSkus() { if (this.selectedCustomer) return this.selectedCustomer.skus || []; const result = new Map(); for (const order of this.selectedOrders) for (const item of this.previews[order.returnNo]?.skus || []) { const current = result.get(item.skuCode) || { ...item, availableQuantity: 0 }; current.availableQuantity += Number(item.availableQuantity || 0); result.set(item.skuCode, current) } return [...result.values()] },
mergeQuantity() { return Object.values(this.packing.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
validDimensions() { return this.dimensionFields.every((field) => Number(this.packing[field.key]) > 0) },
canMerge() { return this.selectedOrders.length > 0 && this.mergeQuantity > 0 && this.validDimensions && this.availableSkus.every((item) => this.quantity(item.skuCode) <= item.availableQuantity) }
canMerge() { return this.selectedOrders.length > 0 && this.mergeQuantity > 0 && validPackingAllocation(this.targetBoxes, this.packing.quantities || {}) && this.availableSkus.every((item) => this.quantity(item.skuCode) <= item.availableQuantity) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); if (this.selectedOrders.length && !Object.keys(this.previews).length) this.refreshSelected() },
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.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (!this.busy) { 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.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 search(raw) { const value = String(raw?.detail?.value ?? raw ?? this.searchCode).trim(); if (!value || this.busy) return; this.searchCode = value; await this.run(async () => { const preview = await findPackingOrders(value); this.candidates = preview.orders.filter((item) => !this.selectedOrders.some((selected) => selected.returnNo === item.returnNo)); if (this.candidates.length) playSuccessSound() }, '查询可合箱退货单失败') },
async addOrder(item) { if (this.busy || !item?.returnNo) return; await this.run(async () => { const preview = await findPackingOrders(item.returnNo); 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.previews = { ...this.previews, [item.returnNo]: preview }; updateWarehouseWork('returnPacking', { selectedOrders: selected, searchCode: '', quantities: this.validQuantities(selected, this.previews) }); this.candidates = null; playSuccessSound() }, '添加退货单失败') },
removeOrder(returnNo) { const selected = this.selectedOrders.filter((item) => item.returnNo !== returnNo); const previews = { ...this.previews }; delete previews[returnNo]; this.previews = previews; updateWarehouseWork('returnPacking', { selectedOrders: selected, quantities: this.validQuantities(selected, previews) }) },
async loadPlans() { try { this.plans = await listPackingPlans() } catch (error) { this.setError(error, '加载合箱计划失败') } },
async search(raw) {
const value = String(raw?.detail?.value ?? raw ?? this.searchCode).trim()
if (!value || this.busy || this.activePlan || this.result) return
this.searchCode = value
await this.run(async () => {
let carton
try { carton = await scanReturnCarton(value) } catch (error) {
if (!['A0404', '404'].includes(String(error?.code || '').toUpperCase())) throw error
}
if (carton) {
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()
}, '查询 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 = {} },
customerLabel(customer, index) { return `客户 ${index + 1}` },
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
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: '' })
}, '读取合箱计划失败') },
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) }) },
validQuantities(selected, previews) { const available = new Map(); for (const order of selected) for (const item of previews[order.returnNo]?.skus || []) available.set(item.skuCode, (available.get(item.skuCode) || 0) + Number(item.availableQuantity || 0)); return Object.fromEntries([...available].map(([sku, maximum]) => [sku, Math.min(maximum, this.quantity(sku))])) },
quantity(skuCode) { return Math.max(0, Number(this.packing.quantities?.[skuCode]) || 0) },
setQuantity(item, raw) { const value = Math.min(item.availableQuantity, Math.max(0, Math.floor(Number(raw) || 0))); updateWarehouseWork('returnPacking', { quantities: { ...(this.packing.quantities || {}), [item.skuCode]: value } }) },
setDimension(key, raw) { updateWarehouseWork('returnPacking', { [key]: String(raw || '').trim() }) },
async refreshSelected() { if (this.busy || !this.selectedOrders.length) return; await this.run(async () => { const previews = {}; let customerKey = ''; for (const selected of this.selectedOrders) { const preview = await findPackingOrders(selected.returnNo); if (!preview.customerKey || (customerKey && preview.customerKey !== customerKey)) throw new Error('所选退货单已不可合箱,请重新选择'); customerKey = preview.customerKey; previews[selected.returnNo] = preview } this.previews = previews; updateWarehouseWork('returnPacking', { quantities: this.validQuantities(this.selectedOrders, previews) }) }, '刷新可用库存失败') },
async mergeCartons() { if (!this.canMerge || this.busy) return; const payload = { returnNos: this.selectedOrders.map((item) => item.returnNo), items: this.availableSkus.map((item) => ({ skuCode: item.skuCode, quantity: this.quantity(item.skuCode) })).filter((item) => item.quantity > 0), length: Number(this.packing.length), width: Number(this.packing.width), height: Number(this.packing.height), weight: Number(this.packing.weight) }; const key = warehouseIdempotencyKey('returnPacking', 'merge', payload); await this.run(async () => { this.result = await mergeReturnCartons({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('returnPacking', key.signature); resetWarehouseDomain('returnPacking'); this.previews = {}; this.candidates = null; playSuccessSound() }, '退货合箱失败') },
async refreshSelected() { if (this.busy || !this.selectedOrders.length || this.selectedCustomer) return; await this.run(async () => { const previews = {}; let customerKey = ''; for (const selected of this.selectedOrders) { const preview = await findPackingOrders(selected.returnNo); if (!preview || !preview.customerKey || (customerKey && preview.customerKey !== customerKey)) throw new Error('所选退货单已不可合箱,请重新选择'); customerKey = preview.customerKey; previews[selected.returnNo] = preview } this.previews = previews; updateWarehouseWork('returnPacking', { quantities: this.validQuantities(this.selectedOrders, previews) }) }, '刷新可用库存失败') },
async mergeCartons() {
if (!this.canMerge || this.busy) return
const payload = { returnNos: this.selectedOrders.map(item => item.returnNo), boxes: this.targetBoxes.map(packingBoxPayload) }
const key = warehouseIdempotencyKey('returnPacking', 'pack', { ...payload, planNo: this.activePlan?.planNo })
await this.run(async () => {
const value = this.activePlan ? await completePackingPlan(this.activePlan.planNo, { boxes: payload.boxes, idempotencyKey: key.value }) : await packReturnSkus({ ...payload, idempotencyKey: key.value })
this.packingCompleted(value); releaseWarehouseKey('returnPacking', key.signature); await this.loadPlans()
}, '退货合箱失败')
},
distributeBoxes() { updateWarehouseWork('returnPacking', { targetBoxes: defaultPackingBoxes(Object.entries(this.packing.quantities || {}).map(([skuCode,quantity]) => ({skuCode,quantity}))) }) },
addBox() { updateWarehouseWork('returnPacking', { targetBoxes: [...this.targetBoxes, { quantities: {}, length:'', width:'', height:'', weight:'' }] }) },
removeBox(index) { updateWarehouseWork('returnPacking', { targetBoxes: this.targetBoxes.filter((_,i) => i !== index) }) },
setBoxField(index,key,value) { updateWarehouseWork('returnPacking', { targetBoxes: this.targetBoxes.map((box,i) => i === index ? { ...box, [key]: String(value || '') } : box) }) },
setBoxQuantity(index,sku,value) { updateWarehouseWork('returnPacking', { targetBoxes: this.targetBoxes.map((box,i) => i === index ? { ...box, quantities: { ...box.quantities, [sku]: Math.max(0,Math.floor(Number(value) || 0)) } } : box) }) },
boxVolume(box) { return ((Number(box.length)||0)*(Number(box.width)||0)*(Number(box.height)||0)/1000000).toFixed(6) },
changeMode(mode) { if (this.activePlan || this.busy) return; this.mode = mode; resetWarehouseDomain('returnPacking'); updateWarehouseWork('returnPacking', { mode }); this.selectedCustomer = null; this.customerCandidates = []; this.candidates = null },
leavePlan() { if (this.busy) return; this.returnToPlans() },
packingCompleted(value) { playSuccessSound(); if (value.targetMode === 'EXISTING') { this.returnToPlans(); return } this.result = { labels: value.labels || (value.label ? [value.label] : []) }; updateWarehouseWork('returnPacking', { result: this.result }) },
async printLabel(label) {
// #ifdef H5
const tab = window.open('', '_blank')
if (!tab) { this.setError(new Error('请允许打开箱唛打印窗口'), '打印失败'); return }
try { const latest = await getReturnCartonLabel(label.cartonMark); if (!latest.printableHtml) throw new Error('箱唛打印内容为空,请重试'); tab.document.write(latest.printableHtml); tab.document.close(); tab.focus(); tab.print() } catch (error) { tab.close(); this.setError(error, '打印失败') }
// #endif
// #ifndef H5
this.setError(new Error('当前设备未配置打印服务'), '打印失败')
// #endif
},
copyCartonMark() { const value = this.result?.label?.cartonMark || this.result?.cartonMark; if (value) uni.setClipboardData({ data: value }) },
nextPacking() { this.result = null; this.error = null; this.searchCode = '' },
nextPacking() { if (this.busy) return; this.returnToPlans() },
returnToPlans() { resetWarehouseDomain('returnPacking'); this.initialSource = null; this.activePlan = null; this.result = null; this.mode = 'SKU'; this.error = null; this.customerCandidates = []; this.selectedCustomer = null; this.candidates = null; this.previews = {}; this.loadPlans() },
async run(work, fallback) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.setError(error, fallback) } finally { this.busy = false } },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
sizeText(label) { return `${label.length || '—'} × ${label.width || '—'} × ${label.height || '—'} cm · ${label.weight || '—'} kg` }
+1 -1
View File
@@ -4,7 +4,7 @@ const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v1'
const blankState = () => ({
scopeKey: '',
dispatch: { currentNo: '', palletCode: '', packages: 1, pendingKeys: {} },
dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1, pendingKeys: {} },
outbound: { currentTaskNo: '', scanCode: '', packages: 1, pendingKeys: {} }
})
+1 -1
View File
@@ -7,7 +7,7 @@ const blankState = () => ({
move: { sourcePalletCode: '', targetLocationCode: '', reason: '', pendingKeys: {} },
relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} },
returnReceipt: { pendingBatch: null, entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, boxCounts: {}, pendingKeys: {} },
returnPacking: { searchCode: '', selectedOrders: [], quantities: {}, length: '', width: '', height: '', weight: '', pendingKeys: {} },
returnPacking: { mode: 'SKU', searchCode: '', selectedOrders: [], quantities: {}, targetBoxes: [], sourceCartonMarks: [], targetCode: '', activePlan: null, result: null, putawayResults: {}, pendingKeys: {} },
returnPackingPlan: { pendingKeys: {} },
warehouseTransfer: { currentNo: '', pendingKeys: {} },
query: { inventoryCode: '', locationCode: '', palletCode: '' }
+26
View File
@@ -0,0 +1,26 @@
export function defaultPackingBoxes(items) {
return items.filter(item => Number(item.quantity) > 0).map(item => ({
quantities: { [item.skuCode]: Number(item.quantity) }, length: '', width: '', height: '', weight: ''
}))
}
export function packingBoxPayload(box) {
return { items: Object.entries(box.quantities || {}).filter(([, quantity]) => Number(quantity) > 0)
.map(([skuCode, quantity]) => ({ skuCode, quantity: Number(quantity) })),
length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight) }
}
export function validPackingAllocation(boxes, expected) {
if (!boxes.length) return false
const totals = {}
for (const box of boxes) {
const payload = packingBoxPayload(box)
if (!payload.items.length || !['length', 'width', 'height', 'weight'].every(key => Number.isFinite(payload[key]) && payload[key] > 0)) return false
for (const item of payload.items) {
if (!Number.isSafeInteger(item.quantity) || item.quantity <= 0 || !Object.hasOwn(expected, item.skuCode)) return false
totals[item.skuCode] = (totals[item.skuCode] || 0) + item.quantity
}
}
return Object.entries(expected).filter(([, quantity]) => Number(quantity) > 0).every(([sku, quantity]) => totals[sku] === Number(quantity))
&& Object.keys(totals).length === Object.values(expected).filter(quantity => Number(quantity) > 0).length
}
+29
View File
@@ -13,6 +13,35 @@ import { toDispatchError } from '../src/utils/dispatch-error.js'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('dispatch merge retains return carton picking and review gates together', async () => {
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
const definition = new Function('PortalHeader', 'ScanTargetInput', 'DispatchHandoverStatus',
source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))({}, {}, {})
const state = { current: { canPick: true }, selectedItem: { returnCartons: [{}] },
selectedReturnCarton: { cartonMark: 'RETURN-BOX' }, selectedPosition: null,
packages: 1, selectedRemaining: 1, pickingCompleted: false, plannedPackages: 1, remainingPackages: 0 }
assert.equal(definition.computed.canPick.call(state), true)
state.selectedReturnCarton = null
assert.equal(definition.computed.canPick.call(state), false)
state.selectedItem.returnCartons = []
state.selectedPosition = { id: 'position' }
assert.equal(definition.computed.canPick.call(state), true)
for (const canPick of [false, undefined, 'true']) {
state.current.canPick = canPick
assert.equal(definition.computed.canPick.call(state), false)
assert.equal(definition.computed.canComplete.call(state), false)
}
const calls = []
const deps = { pickDispatchItem: async (...args) => calls.push(args), getDispatchTask: async () => state.current,
fulfillmentIdempotencyKey: () => ({ value: 'retry-key', signature: 'signature' }), releaseFulfillmentKey() {},
updateFulfillmentDraft() {}, playSuccessSound() {}, PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {} }
const page = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
await page.methods.submitPick.call({ canPick: true, current: { dispatchNo: 'DS-RETURN' },
selectedItem: { dispatchItemId: 'item' }, selectedReturnCarton: { cartonMark: 'RETURN-BOX', productQuantity: 5 },
run: async (_, work) => work() })
assert.deepEqual(calls, [['DS-RETURN', 'item', { positionId: null, cartonMark: 'RETURN-BOX', packages: 1, productQuantity: 5, idempotencyKey: 'retry-key' }]])
})
test('trip departure preserves version zero and rejects incomplete trip identity', () => {
const task = normalizeDispatchTask({ tripNo: 'TR-1', tripRecordVersion: 0, tripDispatchCount: 2, items: [] })
assert.equal(task.tripRecordVersion, '0')
+2 -2
View File
@@ -176,8 +176,8 @@ test('return packing is a separate same-customer inventory operation', async ()
])
assert.match(pages, /pages\/return-packing\/return-packing/)
assert.match(workbench, /key: 'returnPacking'[^\n]*available: true[^\n]*route: '\/pages\/return-packing\/return-packing'/)
for (const action of ['findPackingOrders', 'mergeReturnCartons']) assert.match(page, new RegExp(action))
for (const path of ['/packing/orders', '/cartons/merge', '/cartons/']) assert.match(api, new RegExp(path.replaceAll('/', '\\/')))
for (const action of ['findPackingOrders', 'packReturnSkus']) assert.match(page, new RegExp(action))
for (const path of ['/packing/orders', '/cartons/pack', '/cartons/']) assert.match(api, new RegExp(path.replaceAll('/', '\\/')))
assert.match(page, /startScanBroadcast/)
assert.match(page, /customerKey[\s\S]*不同客户的退货单不能合箱/)
assert.match(page, /idempotencyKey: key\.value/)
+70
View File
@@ -0,0 +1,70 @@
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/ReturnBoxMergeWork.vue'
const source = readFileSync(new URL('../' + filename, import.meta.url), 'utf8')
const { descriptor } = parse(source)
test('box merge component compiles both its script and template', () => {
compileScript(descriptor, { id: 'box-merge' })
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 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 () => {
const {work,component}=instance(); work.choose('NEW')
assert.equal(work.sources.length,2)
assert.equal(work.active,'')
await work.acceptScan('A')
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)
assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),5)
assert.equal(component.computed.canComplete.call(work),true)
})
test('existing box uses another box as target and never removes target inventory', async () => {
const {work,component}=instance(); work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
assert.equal(work.target.cartonMark,'B'); assert.equal(work.sources.length,1)
work.setDeduction('A','SKU1',1)
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)
assert.equal(work.plannedItems().length,0)
})
test('existing source can be replaced before merging, echoing the mark and clearing old deductions and target', async () => {
const {work,component}=instance()
work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
work.setDeduction('A','SKU1',1)
assert.equal(work.sourceCode,'A')
work.sourceCode='B'
assert.equal(component.computed.canComplete.call(work),false)
await work.scanSource('B')
assert.equal(work.sources.length,1)
assert.equal(work.sources[0].cartonMark,'B')
assert.equal(work.sourceCode,'B')
assert.equal(work.target,null)
assert.equal(work.targetCode,'')
assert.equal(work.plannedItems().length,0)
await work.scanTarget('A'); work.setDeduction('B','SKU1',1)
assert.equal(component.computed.canComplete.call(work),true)
})
test('a directly scanned box survives switching between existing and new targets', async () => {
const {work}=instance()
const box=work.plan.sources[0];work.plan=null;work.initialSource=box
work.choose('EXISTING')
assert.equal(work.sources[0].cartonMark,'A');assert.equal(work.sourceCode,'A')
work.choose('NEW')
assert.equal(work.sources[0].items.length,2)
work.choose('EXISTING')
assert.equal(work.sourceCode,'A');assert.equal(work.active,'target')
})
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '../src/utils/return-packing-allocation.js'
const measured = box => ({ ...box, length: '20', width: '30', height: '40', weight: '5' })
test('default one box per SKU preserves all planned quantities', () => {
const boxes = defaultPackingBoxes([{ skuCode: 'A', quantity: 7 }, { skuCode: 'B', quantity: 3 }, { skuCode: 'C', quantity: 0 }]).map(measured)
assert.equal(boxes.length, 2)
assert.equal(validPackingAllocation(boxes, { A: 7, B: 3 }), true)
assert.deepEqual(packingBoxPayload(boxes[0]).items, [{ skuCode: 'A', quantity: 7 }])
})
test('split and mixed boxes must equal the complete locked plan', () => {
const boxes = [measured({ quantities: { A: 2, B: 3 } }), measured({ quantities: { A: 5 } })]
assert.equal(validPackingAllocation(boxes, { A: 7, B: 3 }), true)
assert.equal(validPackingAllocation(boxes, { A: 8, B: 3 }), false)
assert.equal(validPackingAllocation(boxes, { A: 6, B: 3 }), false)
assert.equal(validPackingAllocation(boxes, { A: 7 }), false)
})
test('empty boxes, decimal quantities and invalid measurements block completion', () => {
assert.equal(validPackingAllocation([], { A: 1 }), false)
assert.equal(validPackingAllocation([measured({ quantities: {} })], {}), false)
assert.equal(validPackingAllocation([measured({ quantities: { A: 1.5 } })], { A: 1.5 }), false)
assert.equal(validPackingAllocation([{ ...measured({ quantities: { A: 1 } }), weight: '0' }], { A: 1 }), false)
})
+5
View File
@@ -11,6 +11,11 @@ test('packing preview keeps customer scope internal and exposes stable order ref
assert.equal(value.skus[0].availableQuantity, 8)
})
test('legacy single-customer preview is normalized to all customer orders', () => {
const value = normalizePackingPreview({ customerId: '100', orders: [{ returnNo: 'RT-1' }, { returnNo: 'RT-2' }], skus: [{ skuCode: 'SKU-1', availableQuantity: 8 }] })
assert.deepEqual(value.customers[0].orders.map((item) => item.returnNo), ['RT-1', 'RT-2'])
})
test('return packing API uses exact PDA inventory routes without submitting customer or warehouse scope', async () => {
const calls = []
session.apiBase = '/api'; session.token = 'packing-token'
+62
View File
@@ -0,0 +1,62 @@
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/pages/return-packing/return-packing.vue'
const { descriptor } = parse(readFileSync(new URL('../' + filename, import.meta.url), 'utf8'))
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() {},
updateWarehouseWork(domain, patch) { saved = {...saved,...patch} }, resetWarehouseDomain() { reset = true }, ...api
})
const page = {...component.data(), activePlan:{planNo:'P1'}, busy:true}
for (const [key, method] of Object.entries(component.methods)) page[key] = method.bind(page)
page.loadPlans = () => { page.plans = [{planNo:'P2'}] }
return {page, saved:()=>saved, reset:()=>reset}
}
test('packing completion template compiles without a putaway prerequisite', () => {
compileScript(descriptor, {id:'packing'})
assert.deepEqual(compileTemplate({source:descriptor.template.content,filename,id:'packing'}).errors,[])
assert.equal(descriptor.template.content.includes('确认上架'),false)
})
test('unified scan resolves an actual carton and passes its SKU contents into box work', async () => {
let skuCalls=0
const carton={cartonMark:'BOX1',customerId:'C1',items:[{skuCode:'SKU1',quantity:2}]}
const {page,saved}=instance({scanReturnCarton:async()=>carton,findPackingOrders:async()=>{skuCalls++;return {}}})
page.activePlan=null;page.busy=false;page.selectedOrders=[]
await page.search('BOX1')
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 () => {
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}]}]}}})
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)
queryError={code:'A0409',message:'箱库存被占用'}
await page.search('LOCKEDBOX')
assert.equal(skuCalls,1);assert.equal(page.error.code,'A0409')
})
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'}]})
assert.equal(page.result,null)
assert.equal(page.activePlan,null)
assert.equal(reset(),true)
assert.equal(page.plans[0].planNo,'P2')
})
test('new target retains labels for printing and can return without putaway', () => {
const {page,saved,reset}=instance()
page.packingCompleted({targetMode:'NEW',labels:[{cartonMark:'NEW#1-1'}]})
assert.equal(saved().result.labels[0].cartonMark,'NEW#1-1')
assert.equal(reset(),false)
page.busy=false; page.nextPacking()
assert.equal(page.result,null)
assert.equal(reset(),true)
})