Files
pda-overseas-uniapp/src/pages/return-packing/return-packing.vue
T

153 lines
22 KiB
Vue
Raw Normal View History

2026-09-10 22:43:11 +08:00
<template>
<view v-if="user" class="page-shell portal-page work-page">
2026-10-22 14:38:15 +08:00
<PortalHeader title="退货合箱" :user="user" />
2026-09-10 22:43:11 +08:00
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
2026-10-08 15:34:47 +08:00
<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, '箱合并失败')" />
2026-10-20 14:52:33 +08:00
<view v-if="!result && !activePlan && mode !== 'BOX'" class="work-card direct-merge-card"><text class="card-title">仓库现场直接合箱</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>
<view class="task-list">
<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>
2026-09-10 22:43:11 +08:00
2026-10-08 15:34:47 +08:00
<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>
2026-09-10 22:43:11 +08:00
2026-10-08 15:34:47 +08:00
<template v-if="selectedOrders.length && !result && mode === 'SKU'">
2026-10-20 14:52:33 +08:00
<view class="work-card"><text class="card-title">{{ activePlan ? `合箱计划 ${activePlan.planNo}` : '客户散件库存' }}</text></view>
2026-10-08 15:34:47 +08:00
<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>
2026-09-10 22:43:11 +08:00
<button class="primary" :disabled="!canMerge || busy" @tap="mergeCartons">{{ busy ? '合箱中…' : `确认合箱(${mergeQuantity} 件)` }}</button>
</template>
2026-10-08 15:34:47 +08:00
<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>
2026-10-12 13:43:37 +08:00
<ReturnLabelConfirmation ref="labelConfirmations" :carton-mark="label.cartonMark" />
2026-10-08 15:34:47 +08:00
</view>
2026-10-14 11:43:44 +08:00
<button class="primary" @tap="nextPacking">返回合箱计划列表</button>
2026-10-08 15:34:47 +08:00
</view>
2026-09-10 22:43:11 +08:00
<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>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
2026-10-08 15:34:47 +08:00
import ReturnBoxMergeWork from '@/components/ReturnBoxMergeWork.vue'
2026-10-12 13:43:37 +08:00
import ReturnLabelConfirmation from '@/components/ReturnLabelConfirmation.vue'
2026-10-08 15:34:47 +08:00
import { completePackingPlan, findPackingOrders, listPackingPlans, getPackingPlan, getReturnCartonLabel, scanReturnCarton, packReturnSkus } from '@/api/return-packing.js'
import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '@/utils/return-packing-allocation.js'
2026-09-10 22:43:11 +08:00
import { session, requireSession } from '@/stores/session.js'
import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
2026-09-10 22:43:11 +08:00
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
export default {
2026-10-12 13:43:37 +08:00
components: { PortalHeader, ScanTargetInput, ReturnBoxMergeWork, ReturnLabelConfirmation },
2026-10-08 15:34:47 +08:00
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: '重量' }] } },
2026-09-10 22:43:11 +08:00
computed: {
packing() { return warehouseWork.returnPacking },
2026-10-08 15:34:47 +08:00
targetBoxes() { return this.packing.targetBoxes || [] },
2026-09-10 22:43:11 +08:00
searchCode: { get() { return this.packing.searchCode }, set(value) { updateWarehouseWork('returnPacking', { searchCode: value }) } },
selectedOrders() { return Array.isArray(this.packing.selectedOrders) ? this.packing.selectedOrders : [] },
2026-10-08 15:34:47 +08:00
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()] },
2026-09-10 22:43:11 +08:00
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) },
2026-10-08 15:34:47 +08:00
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) }
2026-09-10 22:43:11 +08:00
},
2026-10-08 15:34:47 +08:00
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() },
2026-09-10 22:43:11 +08:00
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
2026-10-14 11:43:44 +08:00
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 confirmation of this.$refs.labelConfirmations || []) confirmation.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, '扫码广播注册失败') }) },
2026-09-10 22:43:11 +08:00
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
2026-10-08 15:34:47 +08:00
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
}
2026-10-12 13:43:37 +08:00
throw new Error('请扫描来源包裹/箱标签,部分出库必须确认具体实物来源')
2026-10-08 15:34:47 +08:00
}, '查询 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('计划已不在待作业状态,请刷新列表')
2026-10-12 13:43:37 +08:00
this.activePlan = latest; this.mode = 'BOX'; this.customerCandidates = []; this.candidates = null
2026-10-08 15:34:47 +08:00
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 })) }
2026-10-12 13:43:37 +08:00
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: '' })
2026-10-08 15:34:47 +08:00
}, '读取合箱计划失败') },
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) }) },
2026-09-10 22:43:11 +08:00
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() }) },
2026-10-08 15:34:47 +08:00
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); await this.loadPlans()
2026-10-08 15:34:47 +08:00
}, '退货合箱失败')
},
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() },
2026-10-14 11:43:44 +08:00
packingCompleted(value) { this.busy = false; playSuccessSound(); if (value.targetMode === 'EXISTING') { this.returnToPlans(); return } this.result = { labels: value.labels || (value.label ? [value.label] : []) }; updateWarehouseWork('returnPacking', { result: this.result }) },
2026-10-08 15:34:47 +08:00
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
},
2026-09-10 22:43:11 +08:00
copyCartonMark() { const value = this.result?.label?.cartonMark || this.result?.cartonMark; if (value) uni.setClipboardData({ data: value }) },
2026-10-14 11:43:44 +08:00
nextPacking() { this.returnToPlans() },
2026-10-08 15:34:47 +08:00
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() },
2026-09-10 22:43:11 +08:00
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` }
}
}
</script>
<style scoped lang="scss">
@import '../warehouse/operations.scss';
.candidate-card{width:100%;text-align:left}.select-link,.remaining-copy{color:#135ee8;font-size:11px;font-weight:800}.selected-tags{display:flex;flex-wrap:wrap;gap:7px}.selected-tag{width:auto;height:32px;margin:0;padding:0 10px;border-radius:16px;color:#135ee8;font-size:10px;line-height:32px;background:#eaf2ff}.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}.dimension-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.dimension-grid label>text{display:block;margin-bottom:5px;color:#65768b;font-size:10px}.dimension-grid .number-input{text-align:center}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}
</style>