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

153 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader title="退货合箱" :user="user" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<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 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>
<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 && mode === 'SKU'">
<view class="work-card"><text class="card-title">{{ activePlan ? `合箱计划 ${activePlan.planNo}` : '客户散件库存' }}</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">合箱完成 · {{ 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>
<ReturnLabelConfirmation ref="labelConfirmations" :carton-mark="label.cartonMark" />
</view>
<button class="primary" @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>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import ReturnBoxMergeWork from '@/components/ReturnBoxMergeWork.vue'
import ReturnLabelConfirmation from '@/components/ReturnLabelConfirmation.vue'
import { completePackingPlan, findPackingOrders, listPackingPlans, getPackingPlan, getReturnCartonLabel, scanReturnCarton, packReturnSkus } from '@/api/return-packing.js'
import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '@/utils/return-packing-allocation.js'
import { session, requireSession } from '@/stores/session.js'
import { 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, ReturnBoxMergeWork, ReturnLabelConfirmation },
data() { return { mode: 'SKU', initialSource: null, user: session.currentUser, candidates: null, customerCandidates: [], selectedCustomer: null, previews: {}, plans: [], activePlan: null, result: null, busy: false, error: null, dimensionFields: [{ key: 'length', label: '长 cm', placeholder: '长' }, { key: 'width', label: '宽 cm', placeholder: '宽' }, { key: 'height', label: '高 cm', placeholder: '高' }, { key: 'weight', label: '重量 kg', placeholder: '重量' }] } },
computed: {
packing() { return warehouseWork.returnPacking },
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() { 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 && 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.mode = this.packing.mode || 'SKU'; this.activePlan = this.packing.activePlan || null; this.result = this.packing.result || null; if (this.activePlan) { const quantities = {}; for (const box of this.activePlan.boxes) for (const item of box.items || []) quantities[item.skuCode] = (quantities[item.skuCode] || 0) + Number(item.quantity); this.selectedCustomer = { skus: Object.entries(quantities).map(([skuCode, availableQuantity]) => ({ skuCode, availableQuantity })) }; } this.startScanner(); this.loadPlans(); if (!this.activePlan && !this.result && this.selectedOrders.length && !Object.keys(this.previews).length) this.refreshSelected() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
startScanner() { this.initialSource = this.activePlan ? null : this.packing.initialSource || null; if (this.mode === 'BOX' && !this.activePlan && !this.initialSource) this.mode = 'SKU'; this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.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, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
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
}
throw new Error('请扫描来源包裹/箱标签,部分出库必须确认具体实物来源')
}, '查询 SKU 或箱号失败')
},
selectCustomer(customer) { if (!customer?.customerId || !customer.orders?.length) return; const existingCustomer = this.selectedOrders[0]?.customerKey; if (existingCustomer && existingCustomer !== customer.customerId) { this.setError(new Error('不同客户的退货库存不能合箱'), '不能选择其他客户'); return } this.selectedCustomer = customer; const selected = customer.orders.filter((item) => !this.selectedOrders.some((current) => current.returnNo === item.returnNo)).map((item) => ({ ...item, customerKey: customer.customerId })); const all = this.selectedOrders.length ? [...this.selectedOrders, ...selected] : selected; updateWarehouseWork('returnPacking', { selectedOrders: all, searchCode: '' }); this.customerCandidates = []; this.candidates = null; this.previews = {} },
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 = 'BOX'; this.customerCandidates = []; this.candidates = null
const quantities = {}; for (const box of latest.boxes) for (const item of box.items || []) quantities[item.skuCode] = (quantities[item.skuCode] || 0) + Number(item.quantity)
this.selectedCustomer = { customerId: latest.customerId, orders: latest.returnNos.map(returnNo => ({ returnNo })), skus: Object.entries(quantities).map(([skuCode, availableQuantity]) => ({ skuCode, availableQuantity })) }
updateWarehouseWork('returnPacking', { mode: 'BOX', activePlan: latest, selectedOrders: latest.returnNos.map(returnNo => ({ returnNo, customerKey: latest.customerId })), quantities, targetBoxes: defaultPackingBoxes(Object.entries(quantities).map(([skuCode,quantity]) => ({skuCode,quantity}))), searchCode: '' })
}, '读取合箱计划失败') },
async addOrder(item) { if (this.busy || !item?.returnNo) return; await this.run(async () => { const preview = await findPackingOrders(item.returnNo, 'RETURN_NO'); const exact = preview.orders.filter((order) => order.returnNo === item.returnNo); if (exact.length !== 1 || !preview.customerKey) throw new Error('该退货单没有可用库存'); const customerKey = this.selectedOrders[0]?.customerKey; if (customerKey && customerKey !== preview.customerKey) throw new Error('不同客户的退货单不能合箱'); const selected = [...this.selectedOrders, { ...exact[0], customerKey: preview.customerKey }]; this.selectedCustomer = null; this.previews = { ...this.previews, [item.returnNo]: preview }; updateWarehouseWork('returnPacking', { selectedOrders: selected, searchCode: '', quantities: this.validQuantities(selected, this.previews) }); this.candidates = null; this.customerCandidates = []; playSuccessSound() }, '添加退货单失败') },
removeOrder(returnNo) { const selected = this.selectedOrders.filter((item) => item.returnNo !== returnNo); const previews = { ...this.previews }; delete previews[returnNo]; this.previews = previews; if (this.selectedCustomer) this.selectedCustomer = { ...this.selectedCustomer, orders: selected }; updateWarehouseWork('returnPacking', { selectedOrders: selected, quantities: this.validQuantities(selected, previews) }) },
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 || 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()
}, '退货合箱失败')
},
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) { 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 }) },
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.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` }
}
}
</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>