This commit is contained in:
2026-08-24 19:29:41 +08:00
parent 1b129c4cdb
commit bc986f1eef
10 changed files with 107 additions and 32 deletions
+11 -7
View File
@@ -88,7 +88,7 @@
</view>
<InboundList :items="current?.inbounds || []" />
<view v-if="lastReceipt" class="success-card">本次已收 {{ lastReceipt.acceptedPackages }} 箱,{{ lastReceipt.inboundCode }} 剩余 {{ lastReceipt.pendingPackages }} 箱</view>
<view class="sticky-action one"><button class="primary-button complete-button" :disabled="busy || progress.pending !== 0" @tap="completeContainer">{{ progress.pending ? `尚有 ${progress.pending} 箱未收` : '数量已核平,完成入库' }}</button></view>
<view class="sticky-action one"><button class="primary-button complete-button" :disabled="busy || !allInboundsReconciled" @tap="completeContainer">{{ allInboundsReconciled ? '每个入仓码已核平,完成整柜入库' : `尚有 ${progress.pending} 箱未收` }}</button></view>
</template>
<template v-else-if="phase === 'completed'">
@@ -113,6 +113,7 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES } from '@/api/receiving-adapter.js'
import { getOperationPrototype } from '@/data/overseas-workbench.js'
import { session, requireSession } from '@/stores/session.js'
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, selectedInbound, setCurrentReceiving } from '@/stores/receiving.js'
@@ -133,7 +134,7 @@
export default {
components: { PortalHeader, ScanTargetInput, SessionSummary, InboundList },
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', loading: false, busy: false, detailOpen: false, containerInput: '', cartonInput: '', skuInput: '', palletInput: '', packageCount: 1, prototypeInput: '', error: null, toast: { visible: false, message: '', type: '' }, photoSteps: [{ role: 'LICENSE_PLATE', label: '车牌与柜号' }, { role: 'CONTAINER_SEAL', label: '集装箱封签' }, { role: 'DOOR_OPEN', label: '开柜现场' }] } },
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', loading: false, busy: false, detailOpen: false, containerInput: '', cartonInput: '', skuInput: '', palletInput: '', packageCount: 1, prototypeInput: '', error: null, toast: { visible: false, message: '', type: '' }, photoSteps: RECEIVING_PHOTO_ROLES.map((role) => ({ role, label: ({ LICENSE_PLATE: '车牌照片', CONTAINER_SEAL: '集装箱封签', DOOR_OPEN: '开柜现场' })[role] })) } },
computed: {
prototype() { return getOperationPrototype(this.operationName) }, inboundMode() { return this.moduleKey === 'inbound' },
candidates() { return receiving.candidates }, selected() { return receiving.selected }, current() { return receiving.current }, photoSlots() { return receiving.photoSlots },
@@ -142,7 +143,8 @@
activeTarget: { get() { return receiving.activeScanTarget }, set(value) { receiving.activeScanTarget = value } },
phase() { return this.detailOpen && !this.current ? 'detail' : receivingPhase(this.current) }, progress() { return receivingProgress(this.current) }, activeInbound() { return selectedInbound(this.current) },
uploadedPhotoCount() { return this.photoSlots.filter((item) => item?.fileId).length }, photosReady() { return this.uploadedPhotoCount === 3 },
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; return this.activeInbound.destinationType === 'FBA' ? Boolean(this.resolvedSku?.skuCode) && Number(this.packageCount) > 0 && Number(this.packageCount) <= Number(this.activeInbound.pendingPackages) : Boolean(this.resolvedCarton?.cartonMark) },
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; if (this.activeInbound.destinationType === 'FBA') { const count = Number(this.packageCount); return this.resolvedSku?.inboundCode === this.activeInbound.inboundCode && Boolean(this.resolvedSku?.skuCode) && Number.isInteger(count) && count > 0 && count <= Number(this.activeInbound.pendingPackages) } return this.resolvedCarton?.inboundCode === this.activeInbound.inboundCode && Boolean(this.resolvedCarton?.cartonMark) },
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && values.every((item) => Number(item.pendingPackages) === 0) },
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对全部入仓码、目的仓与箱数', sign: '完成现场取证后进入收货', receive: '扫码箱唛与托盘,按目的类型收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
},
@@ -156,11 +158,13 @@
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { if (!this.inboundMode) { this.prototypeInput = value; return this.prototypeScan() } if (this.phase === 'candidates') { this.containerInput = value; return this.openContainer(value) } if (this.phase !== 'receive') return; if (this.activeTarget === 'carton' && this.activeInbound?.destinationType === 'OVERSEAS') { this.cartonInput = value; return this.resolveCarton(value) } if (this.activeTarget === 'sku' && this.activeInbound?.destinationType === 'FBA') { this.skuInput = value; return this.resolveSku(value) } this.palletInput = value; return this.resolvePallet(value) },
async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { this.error = error; playErrorSound(); this.showToast(error.message || `${action}失败`, 'error'); throw error } finally { this.busy = false } },
async bootstrap() { this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); setCurrentReceiving(current); receiving.activeScanTarget = current?.status === 'WAIT_SIGN' ? 'container' : (selectedInbound(current)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku') } catch (error) { if (String(error.code) === '404' || String(error.code) === 'A0404' || String(error.code) === 'B0404') { resetReceivingDraft(); await this.loadCandidates(false) } else this.error = error } finally { this.loading = false } },
setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : (selectedInbound(value)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku') },
async bootstrap() { this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); setCurrentReceiving(current); this.setScanTargetForCurrent(current) } catch (error) { if (isNoCurrentReceiving(error)) { resetReceivingDraft(); await this.loadCandidates(false) } else this.error = error } finally { this.loading = false } },
async recoverCurrentAfterConflict(error) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); setCurrentReceiving(current); this.setScanTargetForCurrent(current); this.detailOpen = false; this.error = null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { this.error = recoveryError; return false } },
async loadCandidates(feedback = true) { try { const work = () => listReceivingCandidates(); receiving.candidates = feedback ? await this.run('刷新待卸柜', work) : await work() } catch (_) {} },
async openContainer(raw) { const containerNo = String(raw?.detail?.value ?? raw ?? this.containerInput).trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('查询柜号', () => getReceivingContainer(containerNo)); this.containerInput = containerNo; this.detailOpen = true; playSuccessSound() } catch (_) {} },
async openContainer(raw) { const containerNo = String(raw?.detail?.value ?? raw ?? this.containerInput).trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('查询柜号', () => getReceivingContainer(containerNo)); this.containerInput = containerNo; this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } },
cancelDetail() { this.detailOpen = false; receiving.selected = null },
async prepareContainer() { if (!this.selected?.containerNo) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { const value = await this.run('选择当前柜', () => prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.detailOpen = false } catch (_) {} },
async prepareContainer() { if (!this.selected?.containerNo) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { const value = await this.run('选择当前柜', () => prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.setScanTargetForCurrent(value); this.detailOpen = false } catch (error) { await this.recoverCurrentAfterConflict(error) } },
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera', 'album'], success: async ({ tempFilePaths }) => { const path = tempFilePaths?.[0]; if (!path) return; try { const file = await this.run('上传签收照片', () => uploadCurrentUnloadPhoto(path)); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true }) } catch (_) {} } }) },
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: Number(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', () => signCurrentReceiving({ photos, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); receiving.activeScanTarget = selectedInbound(value)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku' } catch (_) {} },
chooseInbound(item) { receiving.selectedInboundCode = item.inboundCode; receiving.resolvedCarton = null; receiving.resolvedSku = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.skuInput = ''; this.palletInput = ''; this.packageCount = Math.max(1, Number(item.pendingPackages || 1)); receiving.activeScanTarget = item.destinationType === 'OVERSEAS' ? 'carton' : 'sku' },
@@ -168,7 +172,7 @@
async resolveSku(raw) { const skuCode = String(raw?.detail?.value ?? raw ?? this.skuInput).trim(); if (!skuCode || this.busy) return; try { const value = await this.run('解析 SKU', () => resolveCurrentSku(skuCode)); receiving.resolvedSku = value; receiving.selectedInboundCode = value.inboundCode; receiving.resolvedPallet = null; this.skuInput = value.skuCode; this.palletInput = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (_) {} },
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; if (!value.compatible) throw Object.assign(new Error('托盘与当前入仓码目的仓不匹配'), { code: 'PALLET_INCOMPATIBLE' }); playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, skuCode: this.resolvedSku.skuCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount) } : { cartonMark: this.resolvedCarton.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', payload); try { const result = await this.run('提交实收', () => isFba ? receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : receiveCurrentCarton({ ...payload, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result; setCurrentReceiving(await getCurrentReceiving()); receiving.resolvedCarton = null; receiving.resolvedSku = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.skuInput = ''; this.palletInput = ''; const inbound = selectedInbound(); this.packageCount = Math.max(1, Number(inbound?.pendingPackages || 1)); receiving.activeScanTarget = inbound?.destinationType === 'OVERSEAS' ? 'carton' : 'sku'; playSuccessSound() } catch (_) {} },
async completeContainer() { if (this.progress.pending !== 0) return; const key = idempotencyKey('complete', { containerNo: this.current.containerNo }); try { const value = await this.run('完成入库', () => completeCurrentReceiving({ idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value) } catch (_) {} },
async completeContainer() { if (!this.allInboundsReconciled) return; const key = idempotencyKey('complete', { containerNo: this.current.containerNo }); try { const value = await this.run('完成入库', () => completeCurrentReceiving({ idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value) } catch (_) {} },
async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) },
prototypeScan() { if (!String(this.prototypeInput || '').trim()) return this.showToast('请先扫描或输入编码', 'warning'); this.showToast('该模块尚未接入真实业务接口', 'warning') },
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) }