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

93 lines
14 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" fallback-url="/pages/module/module?key=inbound" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="!current && !result" class="work-card">
<text class="card-title">选择查询方式</text>
<view class="mode-row"><button class="mode-button" :class="{ active: entryMode === 'RETURN_NO' }" @tap="switchMode('RETURN_NO')">退货单号</button><button class="mode-button" :class="{ active: entryMode === 'SKU' }" @tap="switchMode('SKU')">SKU</button></view>
<ScanTargetInput name="lookup" v-model="lookupCode" :label="entryMode === 'SKU' ? 'SKU' : '退货单号'" :placeholder="entryMode === 'SKU' ? '扫描或输入 SKU' : '扫描包裹上的退货单号'" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
<button class="primary" :disabled="!lookupCode.trim() || busy" @tap="lookup(lookupCode)">{{ busy ? '查询中…' : '查询' }}</button>
</view>
<view v-if="!current && candidates !== null && !result" class="section-gap">
<view class="row-head candidate-heading"><text class="card-title">匹配退货单</text><text class="meta-line">{{ candidates.length }} 个候选</text></view>
<view v-if="!candidates.length" class="empty-box"><text class="empty-copy">{{ entryMode === 'SKU' ? '当前绑定仓没有包含该 SKU 的待签收退货单' : '当前绑定仓没有匹配的待签收或签收中退货单' }}</text></view>
<view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="selectCandidate(item)"><view class="row-head"><text class="business-code">{{ item.returnNo }}</text><text class="status-pill">{{ statusLabel(item.status) }}</text></view><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text><view class="metric-row"><view class="metric"><strong>{{ item.skuReceivedQuantity }}/{{ item.skuExpectedQuantity }}</strong><text>该 SKU</text></view><view class="metric"><strong>{{ item.orderReceivedQuantity }}/{{ item.orderExpectedQuantity }}</strong><text>整单进度</text></view><text class="select-link">选择 ›</text></view></button></view>
</view>
<template v-if="current && !result">
<view class="work-card"><view class="row-head"><view><text class="business-code">{{ current.returnNo }}</text><text class="meta-line">{{ startedBy === 'SKU' ? '通过 SKU 候选进入' : '通过退货单号进入' }}</text></view><text class="status-pill">{{ statusLabel(current.status) }}</text></view><button class="link-button" @tap="clearCurrent">更换退货单</button></view>
<view class="work-card">
<text class="card-title">本包信息</text>
<text class="hint">快递单号和箱号均可不填;箱号留空时由系统自动生成。</text>
<text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" />
<text class="field-label">箱号(选填)</text><ScanTargetInput name="carton" v-model="cartonMark" label="退货箱号" placeholder="扫描已有箱号,留空自动生成" :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="acceptCarton" />
</view>
<view class="work-card"><view class="row-head"><text class="card-title">本包实收数量</text><button class="link-button" @tap="fillRemaining">全部填入</button></view><view class="item-list"><view v-for="item in current.expectedItems" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">剩余 {{ remaining(item) }}</text></view><view class="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="summary-bar"><view><text class="summary-label">本包合计</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="!canConfirm || busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '确认本包入库' }}</button></view>
</template>
<view v-if="result" class="success-box result-card"><text class="success-title">退货入库成功</text><text class="result-carton">{{ result.cartonMark }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" @tap="nextReceipt">继续签收</button></view>
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
</view></scroll-view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { confirmReturnReceipt, findReturnSkuCandidates, startReturnReceipt } from '@/api/return-receipt.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, current: null, candidates: null, result: null, activeTarget: 'lookup', busy: false, error: null } },
computed: {
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
lookupCode: { get() { return warehouseWork.returnReceipt.lookupCode }, set(value) { updateWarehouseWork('returnReceipt', { lookupCode: value }) } },
startedBy: { get() { return warehouseWork.returnReceipt.startedBy }, set(value) { updateWarehouseWork('returnReceipt', { startedBy: value }) } },
trackingNo: { get() { return warehouseWork.returnReceipt.trackingNo }, set(value) { updateWarehouseWork('returnReceipt', { trackingNo: value }) } },
cartonMark: { get() { return warehouseWork.returnReceipt.cartonMark }, set(value) { updateWarehouseWork('returnReceipt', { cartonMark: value }) } },
receiptQuantity() { return Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
canConfirm() { return this.current && this.receiptQuantity > 0 && this.current.expectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item)) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, warehouseWork.returnReceipt.startedBy, false) },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleScan(value) { if (this.busy) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'carton') this.acceptCarton(value); else this.acceptTracking(value) },
switchMode(mode) { if (this.busy) return; this.entryMode = mode; this.lookupCode = ''; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
async lookup(raw) { const value = String(raw?.detail?.value ?? raw ?? this.lookupCode).trim(); if (!value || this.busy) return; this.lookupCode = value; this.error = null; this.result = null; if (this.entryMode === 'SKU') await this.run(async () => { const response = await findReturnSkuCandidates(value); this.candidates = response.candidates; if (this.candidates.length) playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; await this.openOrder(item.returnNo, 'SKU') },
async openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { const value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {} }); this.activeTarget = 'lookup'; return } this.current = value; this.candidates = null; this.startedBy = startedBy; updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, quantities: this.validQuantities(value) }); this.activeTarget = 'tracking'; if (feedback) playSuccessSound() }, '读取退货单失败') },
validQuantities(order) { const source = warehouseWork.returnReceipt.quantities || {}; return Object.fromEntries(order.expectedItems.map((item) => [item.skuCode, Math.min(Math.max(0, Number(source[item.skuCode]) || 0), this.remaining(item))])) },
quantity(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.quantities?.[skuCode]) || 0) },
remaining(item) { return Math.max(0, Number(item.expectedQuantity || 0) - Number(item.receivedQuantity || 0)) },
setQuantity(item, raw) { const maximum = this.remaining(item); const value = Math.min(maximum, Math.max(0, Math.floor(Number(raw) || 0))); updateWarehouseWork('returnReceipt', { quantities: { ...(warehouseWork.returnReceipt.quantities || {}), [item.skuCode]: value } }) },
fillRemaining() { updateWarehouseWork('returnReceipt', { quantities: Object.fromEntries(this.current.expectedItems.map((item) => [item.skuCode, this.remaining(item)])) }) },
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); this.activeTarget = 'carton'; playSuccessSound() },
acceptCarton(raw) { this.cartonMark = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
async confirmReceipt() { if (!this.canConfirm || this.busy) return; const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, cartonMark: this.cartonMark.trim() || undefined, items: this.current.expectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode) })).filter((item) => item.receivedQuantity > 0) }; const key = warehouseIdempotencyKey('returnReceipt', 'confirm', payload); await this.run(async () => { this.result = await confirmReturnReceipt({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('returnReceipt', key.signature); resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '退货入库失败') },
async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
clearCurrent() { resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
nextReceipt() { this.result = null; this.clearCurrent() },
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() },
statusLabel(value) { return ({ PENDING_RECEIPT: '待签收', PARTIALLY_RECEIVED: '签收中', COMPLETED: '已完成' })[value] || value || '状态未知' }
}
}
</script>
<style scoped lang="scss">
@import '../warehouse/operations.scss';
.candidate-heading .card-title{margin-bottom:0}.candidate-card{width:100%;text-align:left}.select-link{color:#135ee8;font-size:12px;font-weight:800}.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#135ee8;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}
</style>