189 lines
36 KiB
Vue
189 lines
36 KiB
Vue
<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 && !receiptBatch" 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" />
|
||
<text class="scan-hint">扫描完成后自动匹配对应退货单</text>
|
||
</view>
|
||
|
||
<view v-if="!current && candidates !== null && !result && !receiptBatch" 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>
|
||
|
||
<view v-if="receiptBatch && !result" class="work-card"><text class="card-title">待完成{{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '按箱' }}签收</text><text class="business-code">{{ receiptBatch.returnNo }}</text><text class="hint">已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} {{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '箱' }}。请继续本批签收,已确认的包裹/箱不会重新入库;本批内容暂不可修改。</text><button class="primary" :disabled="busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '继续本批签收' }}</button></view>
|
||
<template v-if="current && !result && !receiptBatch">
|
||
<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"><view><text class="card-title">{{ unitType === 'PARCEL' ? '逐包裹实收数量' : (unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量') }}</text></view><button v-if="unitType === 'PALLET'" class="link-button" @tap="fillRemaining">全部填入</button></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>本托箱数合计</text><strong class="auto-count">{{ palletBoxCount }}</strong></view><template v-if="unitType !== 'PALLET'"><view class="quantity-row"><text>{{ unitType === 'PARCEL' ? '本次包裹数' : '本次箱数' }}</text><input class="number-input" type="number" :value="boxRows.length" :placeholder="unitType === 'PARCEL' ? '包裹数' : '箱数'" @input="resizeBoxRows($event.detail.value)" /></view><view v-for="(box,index) in boxRows" :key="index" class="item-card"><text class="business-code">第 {{ index + 1 }} {{ unitType === 'PARCEL' ? '包裹' : '箱' }}</text><view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</text><text class="remaining-copy">剩余 {{ remaining(item) }}</text><input class="number-input" type="number" :value="box.items[item.skuCode] || ''" placeholder="0" @input="setBoxQuantity(index, item.skuCode, $event.detail.value)" /></view></view></template><view v-else 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 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><text class="hint">签收过程中可随时拍照</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>
|
||
<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 && !receiptBatch" 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, selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {} }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
|
||
computed: {
|
||
receiptBatch() { return warehouseWork.returnReceipt.pendingBatch },
|
||
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 || [] },
|
||
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)) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.unitType === 'PARCEL' || (this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && (this.unitType !== 'PALLET' || this.palletBoxCount > 0))) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.unitType !== 'PALLET' || 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 (!this.receiptBatch && 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: {} }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
|
||
resizeBoxRows(raw) { const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {} }) },
|
||
setBoxQuantity(index, skuCode, raw) { const value = Math.max(0, Math.floor(Number(raw) || 0)); this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: value } },
|
||
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 || this.receiptBatch) 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 value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {}, boxCounts: {} }); this.activeTarget = 'lookup'; return } 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; if (this.receiptBatch) { await this.run(() => this.resumeReceiptBatch(), '退货入库失败'); return } const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy) 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]
|
||
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: undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, 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) }
|
||
const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
|
||
return { request: { ...payload, idempotencyKey: key.value }, result: null }
|
||
})
|
||
if (this.unitType !== 'PALLET') {
|
||
updateWarehouseWork('returnReceipt', { pendingBatch: JSON.parse(JSON.stringify({ returnNo: this.current.returnNo, unitType: this.unitType, entries })) })
|
||
await this.resumeReceiptBatch()
|
||
} else {
|
||
this.finishReceipt([await confirmReturnReceipt(entries[0].request)])
|
||
}
|
||
}, '退货入库失败')
|
||
},
|
||
async resumeReceiptBatch() {
|
||
const batch = this.receiptBatch
|
||
const scopeKey = warehouseWork.scopeKey
|
||
if (!batch) return
|
||
for (let index = 0; index < batch.entries.length; index++) {
|
||
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
|
||
if (batch.entries[index].result) continue
|
||
// Persist the immutable request before every attempt; storage failures must stop submission.
|
||
updateWarehouseWork('returnReceipt', { pendingBatch: batch })
|
||
const result = await confirmReturnReceipt(batch.entries[index].request)
|
||
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
|
||
if (!result) throw new Error('服务端未返回签收结果,请重试本批签收')
|
||
batch.entries[index].result = result
|
||
updateWarehouseWork('returnReceipt', { pendingBatch: batch })
|
||
}
|
||
this.finishReceipt(batch.entries.map(entry => entry.result))
|
||
},
|
||
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() { if (!this.current || this.busy || this.photoFileIds.length >= 20) return; const no = this.current.returnNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { 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] },
|
||
async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
|
||
clearCurrent() { if (this.busy || this.receiptBatch) return; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
|
||
continueScanning() {
|
||
if (this.busy || this.receiptBatch) 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: {} }]
|
||
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: {} }]; 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}
|
||
</style>
|