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

239 lines
43 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">
<view v-if="!current && !result" class="work-card">
<text class="card-title">扫描 SKU 69 码</text>
<ScanTargetInput name="lookup" v-model="lookupCode" label="SKU 69 码" placeholder="扫描商品 69 码,自动带出退货单号" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
</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 receipt-order-card"><view class="row-head"><text class="card-title">退货单</text><button class="link-button" @tap="clearCurrent">更换退货单</button></view><view class="receipt-order-line"><text class="business-code">{{ current.returnNo }}</text><text class="status-pill">{{ statusLabel(current.status) }}</text></view></view>
<view class="work-card">
<text class="card-title">本次实物单元</text>
<view class="unit-tabs"><button v-for="item in unitOptions" :key="item.value" :class="['unit-tab', {active: unitType === item.value}]" @tap="selectUnitType(item.value)">{{ item.label }}</button></view>
<view v-if="unitType === 'PARCEL'"><ScanTargetInput name="unit" v-model="unitCode" label="本批快递单号(选填,只填写一次)" placeholder="扫描本批快递面单,可留空" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /><text class="hint">本批共用一个快递单号;填写包裹数,再逐包裹录入 SKU 数量,各包裹单独生成仓库标签。</text></view>
<template v-else>
<view><text class="field-label">托盘码({{ unitType === 'BOX' ? '选填' : '必填' }})</text><ScanTargetInput name="pallet" v-model="palletCode" label="托盘码" placeholder="扫描已有托盘或新托盘标签" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="acceptPallet" /><text v-if="palletCheck" class="scan-hint">{{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}</text><button class="secondary pallet-generate" :disabled="busy" @tap="generatePallet">生成新托盘</button></view>
<view v-if="unitType === 'PALLET'" class="dimension-grid"><label><text>托盘长(cm)</text><input class="number-input" type="digit" v-model="palletLength" placeholder="请输入长度(cm)" /></label><label><view class="dimension-label"><text>托盘宽(cm)</text><text class="volume-text">体积 {{ palletVolume }} cm³</text></view><input class="number-input" type="digit" v-model="palletWidth" placeholder="请输入宽度(cm)" /></label><label><text>托盘高(cm)</text><input class="number-input" type="digit" v-model="palletHeight" placeholder="请输入高度(cm)" /></label><label><text>托盘重量(kg)</text><input class="number-input" type="digit" v-model="palletWeight" placeholder="请输入重量(kg)" /></label></view>
<view><text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" /></view>
</template>
</view>
<view class="work-card">
<view class="row-head receipt-quantity-heading"><text class="card-title">{{ unitType === 'PARCEL' ? '逐包裹实收数量' : unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量' }}</text><button v-if="unitType === 'PALLET'" class="link-button" @tap="fillRemaining">全部填入</button><view v-else class="unit-count-row"><text>{{ unitType === 'PARCEL' ? '本次包裹数' : '本次箱数' }}</text><input class="number-input" type="number" placeholder="数量" :value="boxRows.length" :disabled="busy" @input="resizeBoxRows($event.detail.value)" /></view></view>
<view v-if="unitType === 'PALLET'" class="quantity-row"><text>本托箱数合计</text><strong class="auto-count">{{ palletBoxCount }}</strong></view>
<template v-if="unitType !== 'PALLET'">
<view v-for="(box,index) in boxRows" :key="index" class="item-card physical-unit-card">
<view class="unit-card-head"><text class="business-code">第 {{ index + 1 }} {{ unitType === 'PARCEL' ? '包裹' : '箱' }}</text><button v-if="box.confirmed" class="link-button" :disabled="busy" @tap="editBoxRow(index)">已确认 · 展开修改</button><text v-else class="unit-card-tag">实测信息</text></view>
<template v-if="box.confirmed"><text class="meta-line">{{ box.length }} × {{ box.width }} × {{ box.height }} cm · {{ box.weight }} kg · {{ box.originalPackaging ? '原包装' : '新包装' }}</text><text v-for="item in selectedItems.filter(item => Number(box.items[item.skuCode]) > 0)" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ box.items[item.skuCode] }} 件</text></template>
<template v-else>
<view class="dimension-grid unit-dimension-grid"><label><text>长(cm)</text><input class="number-input" type="digit" v-model="box.length" placeholder="长" :disabled="busy" /></label><label><text>宽(cm)</text><input class="number-input" type="digit" v-model="box.width" placeholder="宽" :disabled="busy" /></label><label><text>高(cm)</text><input class="number-input" type="digit" v-model="box.height" placeholder="高" :disabled="busy" /></label><label><text>重量(kg)</text><input class="number-input" type="digit" v-model="box.weight" placeholder="重量" :disabled="busy" /></label></view>
<view class="unit-volume"><text>体积</text><strong>{{ rowVolume(box) }}</strong><text>cm³</text></view>
<view class="packaging-choice"><text>是否使用原{{ unitType === 'PARCEL' ? '包装' : '箱' }}</text><button :class="['choice-button', {selected: box.originalPackaging === true}]" :disabled="busy" @tap="setOriginalPackaging(index, true)">是</button><button :class="['choice-button', {selected: box.originalPackaging === false}]" :disabled="busy" @tap="setOriginalPackaging(index, false)">否</button></view>
<view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</text><input class="number-input unit-quantity-input" type="number" :value="box.items[item.skuCode] || ''" :placeholder="'剩余 ' + boxRemaining(index, item)" :disabled="busy" @input="setBoxQuantity(index, item.skuCode, $event.detail.value)" /></view>
<button v-if="boxRowValid(index)" class="primary" :disabled="busy" @tap="confirmBoxRow(index)">确认第 {{ index + 1 }} {{ unitType === 'PARCEL' ? '包裹' : '箱' }}</button>
</template>
</view>
</template>
<view v-if="unitType === 'PALLET'" class="item-list receipt-sku-list"><view v-for="item in selectedItems" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">剩余 {{ remaining(item) }}</text></view><view class="sku-quantity-fields"><view class="quantity-row"><text>数量</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>SKU 箱数</text><input class="number-input" type="number" :value="boxCount(item.skuCode)" placeholder="0" @input="setBoxCount(item, $event.detail.value)" /></view></view></view></view></view>
<view v-if="unitType === 'PALLET'" class="summary-bar"><view><text class="summary-label">{{ unitType === 'PARCEL' ? '本次合计' : '本托合计' }}</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="!canConfirm || busy" @tap="confirmReceipt">{{ busy ? '提交中…' : unitType === 'PARCEL' ? '确认包裹签收' : '确认签收并入托' }}</button></view>
<view class="work-card"><view class="row-head"><view><text class="card-title">退货打托照片</text></view><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view><button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto()">拍照或从相册选择</button><view v-if="photoFileIds.length" class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile"><image class="photo-thumbnail" :src="photoUrls[fileId]" mode="aspectFill" @tap="previewPhoto(fileId)" /><text class="photo-name">{{ photoNames[index] || '退货打托照片' }}</text><button class="link-button" :disabled="busy" @tap="removePhoto(index)">移除</button></view></view></view>
<view v-if="unitType !== 'PALLET'" class="summary-bar"><view><text class="summary-label">本次合计</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="busy || !canConfirm || !allBoxRowsConfirmed || !photoFileIds.length" @tap="confirmReceipt">{{ busy ? '提交中…' : '确认签收入库' }}</button></view>
<button v-if="current.status === 'PARTIALLY_RECEIVED'" class="secondary manual-complete" :disabled="busy" @tap="manualComplete">缺货手动完结</button>
<view v-if="current.completedPallets?.length" class="work-card"><text class="card-title">本单已完成打托</text><view class="item-list"><view v-for="pallet in current.completedPallets" :key="pallet.palletCode" class="item-card"><text class="business-code">{{ pallet.palletCode }}</text><text class="meta-line">{{ pallet.skuCode }} · {{ pallet.quantity }} 件</text><text class="meta-line">签收批次:{{ pallet.receiptBatchNo }}</text></view></view></view>
</template>
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} {{ result.unitType === 'PARCEL' ? '包裹' : '箱' }} · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印包裹/箱标签</button><text class="meta-line">签收已默认贴标</text></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><button v-if="result.unitType === 'PALLET' && result.labels?.length" class="secondary" :disabled="busy" @tap="goCount">去箱内清点</button><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></view>
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current" 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 { checkReturnPallet, confirmReturnReceipt, createReturnPallet, downloadReturnPalletPhoto, findReturnSkuCandidates, manuallyCompleteReturnReceipt, startReturnReceipt, uploadReturnPalletPhoto } from '@/api/return-receipt.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 },
data() { return { user: session.currentUser, current: null, candidates: null, result: null, receiptResults: [], selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, 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 }) } },
palletCode: { get() { return warehouseWork.returnReceipt.palletCode }, set(value) { updateWarehouseWork('returnReceipt', { palletCode: value }) } },
selectedItems() { return this.current?.expectedItems || [] },
allBoxRowsConfirmed() { return this.unitType !== 'PALLET' && this.boxRows.length > 0 && this.boxRows.every((box, index) => box.confirmed && this.boxRowValid(index)) },
palletBoxCount() { return this.selectedItems.reduce((sum, item) => sum + this.boxCount(item.skuCode), 0) },
activeSkuCode() { return this.selectedItems.find(item => item.skuCode === this.selectedSku)?.skuCode || this.selectedItems[0]?.skuCode || '' },
receiptQuantity() { return this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + Object.values(box.items).reduce((n, quantity) => n + (Number(quantity) || 0), 0), 0) : Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType !== 'PALLET') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0) && (!('length' in box) || (Number(box.length) > 0 && Number(box.width) > 0 && Number(box.height) > 0 && Number(box.weight) > 0 && typeof box.originalPackaging === 'boolean'))) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && this.palletBoxCount > 0) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.quantity(item.skuCode) === 0 || this.boxCount(item.skuCode) > 0)); },
unitLabel() { return this.unitType === 'PALLET' ? '托盘码' : this.unitType === 'BOX' ? '箱号/箱唛' : '包裹码/快递单号' },
unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' }
,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
goCount() { const mark = this.result?.labels?.[0]?.cartonMark; if (mark) uni.navigateTo({ url: '/pages/return-counting/return-counting?cartonMark=' + encodeURIComponent(mark) }) },
selectUnitType(type) { if (this.busy) return; this.unitType = type; this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
resizeBoxRows(raw) { if (this.busy) return; const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }) },
setOriginalPackaging(index, value) { if (this.busy) return; this.boxRows[index].originalPackaging = value; this.boxRows[index].confirmed = false },
rowVolume(box) { const v = Number(box?.length) * Number(box?.width) * Number(box?.height); return Number.isFinite(v) && v > 0 ? v.toFixed(2) : '—' },
setBoxQuantity(index, skuCode, raw) { if (this.busy) return; this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: raw }; this.boxRows[index].confirmed = false },
boxRemaining(index, item) { const allocated = this.boxRows.reduce((sum, box, rowIndex) => sum + (rowIndex === index ? 0 : Math.max(0, Number(box.items[item.skuCode]) || 0)), 0); return Math.max(0, this.remaining(item) - allocated) },
boxRowValid(index) {
const box = this.boxRows[index]
if (!box || !['length', 'width', 'height', 'weight'].every(field => Number.isFinite(Number(box[field])) && Number(box[field]) > 0) || typeof box.originalPackaging !== 'boolean') return false
return this.selectedItems.some(item => Number(box.items[item.skuCode]) > 0) && this.selectedItems.every(item => { const quantity = Number(box.items[item.skuCode] || 0); return Number.isSafeInteger(quantity) && quantity >= 0 && quantity <= this.boxRemaining(index, item) })
},
editBoxRow(index) { if (this.busy) return; this.boxRows[index].confirmed = false; this.error = null },
confirmBoxRow(index) {
if (this.busy) return
if (!this.boxRowValid(index)) return this.setError(new Error('请填写有效箱规、重量和正整数实收数量,数量不能超过扣除其他箱后的剩余量'), '本箱确认失败')
this.boxRows[index].confirmed = true; this.error = null
if (this.allBoxRowsConfirmed) this.choosePhoto(true)
},
acceptUnit(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code) return; this.unitCode = code; if (this.unitType !== 'PARCEL') this.palletCode = `${this.unitType}:${code}`; this.activeTarget = this.unitType === 'PARCEL' ? 'unit' : 'tracking'; playSuccessSound() },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleScan(value) { if (this.result) return; if (this.busy) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(value); else this.acceptTracking(value) },
switchMode(mode) { if (this.busy) return; this.entryMode = mode; this.lookupCode = ''; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
async lookup(raw) { const value = String(raw?.detail?.value ?? raw ?? this.lookupCode).trim(); if (!value || this.busy) return; this.lookupCode = value; this.error = null; this.result = null; if (this.entryMode === 'SKU') await this.run(async () => { const response = await findReturnSkuCandidates(value); this.candidates = response.candidates; this.selectedSku = response.skuCode; if (this.candidates.length === 1) await this.openOrderValue(this.candidates[0].returnNo, 'SKU'); else if (this.candidates.length) playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; this.selectedSku = this.lookupCode; await this.openOrder(item.returnNo, 'SKU') },
async openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { await this.openOrderValue(returnNo, startedBy, feedback) }, '读取退货单失败') },
async openOrderValue(returnNo, startedBy, feedback = true) { const previous = this.current; const value = await startReturnReceipt(returnNo); if (!value) { this.resetReceiptInput(); this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {}, boxCounts: {} }); this.activeTarget = 'lookup'; return } if (!previous || previous.returnNo !== value.returnNo || JSON.stringify(previous.expectedItems) !== JSON.stringify(value.expectedItems)) this.resetReceiptInput(); this.current = value; this.palletCheck = null; this.candidates = null; this.startedBy = startedBy; this.photoFileIds = (value.photos || []).map((item) => String(item.fileId)); this.photoNames = (value.photos || []).map((item) => item.fileName || '退货打托照片'); this.photoUrls = {}; await Promise.all(this.photoFileIds.map(async (fileId) => { try { this.photoUrls[fileId] = await downloadReturnPalletPhoto(value.returnNo, fileId) } catch (_) { /* 单张照片失败不阻断作业 */ } })); updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, palletCode: '', quantities: this.validQuantities(value), boxCounts: {} }); this.activeTarget = 'pallet'; 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) },
boxCount(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.boxCounts?.[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 } }) },
setBoxCount(item, raw) { const value = Math.max(0, Math.floor(Number(raw) || 0)); updateWarehouseWork('returnReceipt', { boxCounts: { ...(warehouseWork.returnReceipt.boxCounts || {}), [item.skuCode]: value } }) },
fillRemaining() { updateWarehouseWork('returnReceipt', { quantities: Object.fromEntries(this.selectedItems.map((item) => [item.skuCode, this.remaining(item)])) }) },
async acceptPallet(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code || this.busy) return; if (!this.activeSkuCode) { this.setError(new Error('请先通过 SKU 69 码选择一个待签收 SKU'), '托盘校验失败'); return } await this.run(async () => { const value = await checkReturnPallet(this.current.returnNo, code); this.palletCode = value.palletCode; this.newPalletHtml = ''; this.palletCheck = value; this.activeTarget = 'tracking'; playSuccessSound() }, '托盘校验失败') },
async generatePallet() { if (!this.current || this.busy) return; const skuCode = this.activeSkuCode; if (!skuCode) { this.setError(new Error('请先通过 SKU 69 码选择一个待签收 SKU'), '无法生成退货托盘'); return } await this.run(async () => { const value = await createReturnPallet(this.current.returnNo, skuCode); if (!value.palletCode) throw new Error('服务端未返回新托盘码'); this.palletCode = value.palletCode; this.newPalletHtml = value.printableHtml; this.palletCheck = { palletCode: value.palletCode, empty: true, skuCodes: [], quantity: 0 }; this.activeTarget = 'tracking'; playSuccessSound() }, '生成退货托盘失败') },
printPallet() { if (!this.newPalletHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(this.newPalletHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
printReturnCarton(label) { if (!label?.printableHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(label.printableHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
async confirmReceipt() { if (this.busy) return; const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy || (this.unitType !== 'PALLET' && !this.allBoxRowsConfirmed)) return; if (this.unitType !== 'PALLET' && !this.photoFileIds.length) { this.setError(new Error('请先拍照或上传签收照片,再确认签收入库'), '签收校验失败'); return } const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
await this.run(async () => {
const rows = this.unitType !== 'PALLET' ? this.boxRows : [null]
let expectedReceivedQuantity = this.current.expectedItems.reduce((sum, item) => sum + Number(item.receivedQuantity || 0), 0)
const entries = rows.map((box, index) => {
const payload = { returnNo: this.current.returnNo, trackingNo: (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.trackingNo.trim()) || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PARCEL' || this.unitType === 'BOX' ? undefined : undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : Number(box.length), width: this.unitType === 'PALLET' ? Number(this.palletWidth) : Number(box.width), height: this.unitType === 'PALLET' ? Number(this.palletHeight) : Number(box.height), weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : Number(box.weight), originalPackaging: this.unitType === 'PALLET' ? undefined : box.originalPackaging, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType !== 'PALLET' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: this.unitType === 'BOX' ? 1 : undefined })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.boxCount(item.skuCode) }))).filter((item) => item.receivedQuantity > 0) }
payload.expectedReceivedQuantity = expectedReceivedQuantity
expectedReceivedQuantity += payload.items.reduce((sum, item) => sum + item.receivedQuantity, 0)
const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
return { request: { ...payload, idempotencyKey: key.value }, result: null }
})
const scopeKey = warehouseWork.scopeKey
for (const entry of entries) {
const result = await confirmReturnReceipt(entry.request)
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
if (!result) throw new Error('服务端未返回签收结果')
this.receiptResults.push(result)
if (this.unitType !== 'PALLET') {
this.boxRows.shift()
for (const received of entry.request.items) {
const item = this.current.expectedItems.find(item => item.skuCode === received.skuCode)
if (item) item.receivedQuantity = Number(item.receivedQuantity || 0) + received.receivedQuantity
}
}
}
this.finishReceipt(this.receiptResults)
this.receiptResults = []
}, '退货入库失败')
},
finishReceipt(results) {
const last = results[results.length - 1]
this.result = { ...last, continuationReturnNo: !last.fullyMatched ? this.current?.returnNo || '' : '', labels: results.flatMap(item => item.labels?.length ? item.labels : item.printableHtml && item.cartonMark ? [{ cartonMark: item.cartonMark, printableHtml: item.printableHtml }] : []) }
resetWarehouseDomain('returnReceipt')
this.current = null
this.candidates = null
this.activeTarget = 'lookup'
playSuccessSound()
},
manualComplete() { if (!this.current || this.busy) return; uni.showModal({ title: '缺货手动完结', editable: true, placeholderText: '请填写实收少于预期数量的原因', success: async (modal) => { if (!modal.confirm) return; const reason = String(modal.content || '').trim(); if (!reason) { this.setError(new Error('必须填写差异原因'), '手动完结失败'); return } await this.run(async () => { await manuallyCompleteReturnReceipt(this.current.returnNo, reason); this.result = { palletCode: '', receiptNo: '', receiptBatchNo: '', expectedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.expectedQuantity, 0), receivedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.receivedQuantity, 0), fullyMatched: false }; this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '手动完结失败') } }) },
choosePhoto(cameraOnly = false) {
if (!this.current || this.busy) return
if (this.photoFileIds.length >= 20) return
const no = this.current.returnNo; const draft = JSON.stringify(this.boxRows); const type = this.unitType
uni.chooseImage({ count: cameraOnly ? 1 : Math.min(9, 20 - this.photoFileIds.length), sourceType: cameraOnly ? ['camera'] : ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => {
if (this.current?.returnNo !== no || this.unitType !== type || JSON.stringify(this.boxRows) !== draft) return
await this.run(async () => {
for (const [index,path] of (tempFilePaths || []).entries()) {
if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB')
const file = await uploadReturnPalletPhoto(no, path); const id = String(file.fileId)
this.photoFileIds.push(id); this.photoNames.push(file.fileName || '退货打托照片'); this.photoUrls[id] = path
}
playSuccessSound()
}, '退货打托照片上传失败')
} })
},
previewPhoto(fileId) { const urls = this.photoFileIds.map(id => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
removePhoto(index) { const [id] = this.photoFileIds.splice(index, 1); this.photoNames.splice(index, 1); if (id) delete this.photoUrls[id] },
// 服务端实收变化后重新录入下一份实物,不能给旧草稿换上新库存快照。
resetReceiptInput() {
this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]
this.unitCode = ''; this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
this.palletCheck = null; this.newPalletHtml = ''
updateWarehouseWork('returnReceipt', { quantities: {}, boxCounts: {}, palletCode: '', trackingNo: '' })
},
async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
clearCurrent() { if (this.busy) return; this.receiptResults = []; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
continueScanning() {
if (this.busy) return
this.clearCurrent()
this.result = null
this.entryMode = 'SKU'; this.lookupCode = ''; this.selectedSku = ''
this.palletCheck = null; this.newPalletHtml = ''
this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]
this.photoFileIds = []; this.photoNames = []; this.photoUrls = {}
},
async nextReceipt() {
if (this.busy) return
const returnNo = this.result?.continuationReturnNo
if (!returnNo) { this.result = null; this.clearCurrent(); return }
await this.run(async () => {
await this.openOrderValue(returnNo, 'SKU')
this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]; this.newPalletHtml = ''
this.result = null
}, '读取剩余待签收货物失败')
},
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';
.dimension-grid label .dimension-label{display:flex;align-items:center;justify-content:space-between;gap:3px;margin-bottom:3px}.dimension-grid label .dimension-label>text{color:#65768b;font-size:10px;line-height:14px;font-weight:400;white-space:nowrap}.dimension-grid label .dimension-label>.volume-text{margin-left:auto;color:#135ee8;font-size:10px}
.receipt-order-card{padding:10px 14px!important}.receipt-order-card .row-head{align-items:center;margin-bottom:5px}.receipt-order-card .card-title{margin:0;font-size:12px}.receipt-order-card .link-button{margin:0;padding:0;font-size:11px;line-height:24px}.receipt-order-line{display:flex;align-items:center;gap:8px}.receipt-order-line .business-code{flex:1;min-width:0;font-size:12px;line-height:18px;white-space:nowrap;overflow-x:auto;word-break:normal}.receipt-order-line .status-pill{flex-shrink:0;font-size:10px;padding:3px 6px}
.receipt-sku-list .item-card{padding:10px 12px}.receipt-sku-list .row-head{align-items:flex-start;gap:8px}.receipt-sku-list .row-head>view{flex:1;min-width:0}.receipt-sku-list .business-code{font-size:12px;line-height:17px;word-break:break-all}.receipt-sku-list .meta-line{font-size:10px;line-height:15px;margin-top:3px}.receipt-sku-list .remaining-copy{flex-shrink:0;white-space:nowrap;font-size:10px}.sku-quantity-fields{display:flex;gap:14px;margin-top:8px}.sku-quantity-fields .quantity-row{flex:1;min-width:0;gap:6px;margin:0;justify-content:flex-start}.sku-quantity-fields .quantity-row>text{white-space:nowrap;font-size:10px}.sku-quantity-fields .quantity-row .number-input{box-sizing:border-box;width:64px;height:30px;min-height:30px;padding:0 5px;border-radius:6px;font-size:13px}
.candidate-heading .card-title{margin-bottom:0}.candidate-card{width:100%;text-align:left}.select-link{color:#135ee8;font-size:12px;font-weight:800}.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#e53935;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}.scan-hint{display:block;margin-top:9px;color:#65768b;font-size:11px;text-align:center}
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:130px;background:#f1f4f8}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-tile .link-button{margin:0 8px 6px}
.unit-tabs{display:flex;gap:8px;margin:10px 0}.unit-tab{flex:1!important;min-width:0!important;height:34px!important;min-height:34px!important;margin:0!important;padding:0 10px!important;border:1px solid #d6e1ef!important;border-radius:8px!important;background:#f7f9fc!important;color:#53677f!important;font-size:13px!important;line-height:34px!important}.unit-tab.active{border-color:#135ee8!important;background:#eaf2ff!important;color:#135ee8!important;font-weight:700}
.dimension-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin-top:10px}.dimension-grid label>text{display:block;margin-bottom:3px;color:#65768b;font-size:10px}.dimension-grid .number-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #d7e1ef;border-radius:7px;color:#24364b;font-size:13px;text-align:center;background:#fff}.dimension-grid .number-input:focus{border-color:#2167e8;box-shadow:0 0 0 2px rgba(33,103,232,.12)}.volume-box{display:flex;align-items:center;justify-content:center;gap:4px;height:34px;padding:0 6px;border:1px solid #b9d3f4;border-radius:7px;color:#65768b;font-size:10px;background:#f4f8ff}.volume-box strong{color:#135ee8;font-size:13px}
.work-card>.row-head .business-code{font-size:16px;line-height:20px}.work-card>.row-head .meta-line{font-size:10px;line-height:15px}.work-card>.row-head .status-pill{padding:4px 7px;font-size:10px}
.physical-unit-card{padding:12px!important;border:1px solid #d8e5f5!important;background:linear-gradient(145deg,#fff 0%,#f7faff 100%);box-shadow:0 4px 12px rgba(30,78,130,.08)}.unit-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.unit-card-tag{padding:3px 7px;border-radius:10px;background:#eaf2ff;color:#135ee8;font-size:10px;font-weight:700}.unit-dimension-grid{margin-top:0}.unit-volume{display:flex;align-items:baseline;justify-content:flex-end;gap:4px;margin-top:7px;color:#728197;font-size:10px}.unit-volume strong{color:#135ee8;font-size:15px}.packaging-choice{display:flex;align-items:center;gap:7px;margin-top:10px;padding-top:9px;border-top:1px solid #e6edf6;color:#52667e;font-size:11px}.packaging-choice>text{flex:1}.choice-button{width:42px!important;min-width:42px!important;height:28px!important;min-height:28px!important;margin:0!important;padding:0!important;border:1px solid #d1ddeb!important;border-radius:7px!important;background:#fff!important;color:#65768b!important;font-size:11px!important;line-height:28px!important}.choice-button.selected{border-color:#135ee8!important;background:#eaf2ff!important;color:#135ee8!important;font-weight:700}
.receipt-quantity-heading{display:flex;align-items:center;flex-wrap:nowrap;gap:8px;margin-bottom:10px}.receipt-quantity-heading .card-title{margin-bottom:0;white-space:nowrap}.unit-count-row{display:flex;align-items:center;gap:6px;margin-left:auto;flex-shrink:0;white-space:nowrap;color:#65768b;font-size:10px}.unit-count-row .number-input{box-sizing:border-box;width:48px;min-width:48px;height:30px;min-height:30px;margin:0;padding:0 4px;border-radius:6px;text-align:center;font-size:13px}.quantity-row .unit-quantity-input{width:88px;flex-shrink:0;font-size:12px}.physical-unit-card .quantity-row>text{min-width:0;overflow-wrap:anywhere}.unit-card-head .link-button{margin:0;padding:0;font-size:10px}
</style>