@@ -2,38 +2,45 @@
< 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" >
< 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" class="section-gap" >
< 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 >
< template v-if = "current && !result" >
< view v-if = "receiptBatch && !result" class="work-card"><text class="card-title">待完成按箱签收</text><text class="business-code">{{ receiptBatch.returnNo }}</text><text class="hint">已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} 箱。请继续本批签收,已确认的箱不会重新入库;本批内容暂不可修改。</text><button class="primary" :disabled="busy" @tap="confirmReceipt" > {{ busy ? ' 提交中 … ' : ' 继续本批签收 ' }} < / button > < / view >
< template v-if = "current && !result && !receiptBatch" >
< 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" > 本次签收按一个 SKU 打一个托盘 , 托盘码 必填 。 < / text >
< text class = "field-label" > 托盘码 < / 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><button v-if="newPalletHtml" class="secondary" @tap="printPallet" > 打印托盘标签 < / button >
< text class = "field-label" > 快递单号 ( 选填 ) < / text > < ScanTargetInput name = "tracking" v-model = "trackingNo " label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking '" @activate="activeTarget = 'tracking '" @submit="acceptTracking" / >
< text class = "card-title" > 本次实物单元 < / text >
< text class = "hint" > 按现场收到的实物选择维度 , 托盘不是 必填项 。 < / 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'"><text class="field-label">包裹码/快递单号</text><ScanTargetInput name="unit" v-model="unitCode " label="包裹码/ 快递单号" placeholder="扫描快递面单" :active="activeTarget === 'unit '" @activate="activeTarget = 'unit '" @submit="acceptUnit" / > < / view >
< template v-else >
< text v-if = "unitType === 'PALLET'" class="scan-hint">托盘入库:扫描已有托盘或生成新托盘,沿用原托盘测量流程。</text><text v-else class="scan-hint" > 箱子可直接散装入库 , 也可扫描托盘直接打托 。 < / text >
< 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="长" /></label><label><text>宽 cm</text><input class="number-input" type="digit" v-model="palletWidth" placeholder="宽" /></label><label><text>高 cm</text><input class="number-input" type="digit" v-model="palletHeight" placeholder="高" /></label><label><text>重量 kg</text><input class="number-input" type="digit" v-model="palletWeight" placeholder="重量" /></label><view class="volume-box" > < text > 体积 < / text > < strong > { { palletVolume } } < / strong > < text > cm³ < / text > < / view > < / 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" > < text class = "card-title" > 本托实收数量 < / text > < button class = "link-button" @tap ="fillRemaining" > 全部填入 < / button > < / view > < view class = "item-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="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 = "work-card" > < view class = "row-head" > < text class = "card-title" > { { unitType === 'PARCEL' ? '本次实收数量' : ( unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量' ) } } < / text > < button v-if = "unitType !== 'BOX'" 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 === 'BOX'"><view class="quantity-row"><text>本次箱数</text><input class="number-input" type="number" :value="boxRows.length" placeholder="箱数" @input="resizeBoxRows($event.detail.value)" /></view><view v-for="(box,index) in boxRows" :key="index" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱</text><view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</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"><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="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 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 >
< 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><text 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" @tap="nextReceipt" > 继续签收 < / button > < / view >
< 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 }} 箱 · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印箱唛</button></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><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" @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 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 >
@@ -43,51 +50,104 @@
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 { releaseWarehouseKey , resetWarehouseDomain , restoreWarehouseWork , updateWarehouseWork , warehouseIdempotencyKey , warehouseWork } from '@/stores/warehouse-work.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 : '' , photoFileIds : [ ] , photoNames : [ ] , photoUrls : { } , activeTarget : 'lookup' , busy : false , error : null } } ,
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 ? . filter ( item => ! this . selectedSku || item . skuCode === this . selectedSku ) || [ ] } ,
palletBoxCount ( ) { return this . selectedItems . reduce ( ( sum , item ) => sum + this . boxCount ( item . skuCode ) , 0 ) } ,
activeSkuCode ( ) { return this . selectedItems . length === 1 ? this . selectedItems [ 0 ] . skuCode : '' } ,
receiptQuantity ( ) { return Object . values ( warehouseWork . returnReceipt . quantities || { } ) . reduce ( ( total , value ) => total + ( Number ( value ) || 0 ) , 0 ) } ,
canConfirm ( ) { return this . current && this . palletCode . trim ( ) && this . palletCheck && this . selectedItems . length = == 1 && this . receiptQuantity > 0 && this . selectedItems . every ( ( item ) => this . quantity ( item . skuCode ) <= this . remaining ( item ) ) }
receiptQuantity ( ) { return this . unitType === 'BOX' ? 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 === 'BOX' ) 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 . unitCode . trim ( ) : 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 ( warehouseWork . returnReceipt . currentReturnNo && ! this . current && ! this . busy ) this . openOrder ( warehouseWork . returnReceipt . currentReturnNo , 'SKU' , false ) } ,
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 : {
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 ; this . palletCode = ` ${ this . unitType } : ${ code } ` ; this . activeTarget = '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 . busy ) return ; if ( ! this . current ) { this . lookupCode = value ; this . lookup ( value ) ; return } if ( this . activeTarget === 'palle t' ) this . acceptPallet ( value ) ; else this . acceptTracking ( value ) } ,
handleScan ( value ) { if ( this . busy || this . receiptBatch ) return ; if ( ! this . current ) { this . lookupCode = value ; this . lookup ( value ) ; return } if ( this . activeTarget === 'uni t' ) 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 : { } } ) ; 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 ) } ) ; this . activeTarget = 'pallet' ; if ( feedback ) playSuccessSound ( ) } ,
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 . canConfirm || this . busy ) return ; const willComplete = this . current . expectedItems . every ( ( item ) => 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 } const payload = { returnNo : this . current . returnNo , trackingNo : this . trackingNo . trim ( ) || undefined , startedBy : this . startedBy , palletCode : this . palletCode . trim ( ) , items : this . selectedItems . 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 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 === 'BOX' ? 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 === 'BOX' ? this . boxRows : [ null ]
const entries = rows . map ( ( box , index ) => {
const payload = { returnNo : this . current . returnNo , trackingNo : this . trackingNo . trim ( ) || undefined , startedBy : this . startedBy , unitType : this . unitType , unitCode : this . unitType === 'PARCEL' ? this . unitCode . trim ( ) : 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 === 'BOX' ? this . selectedItems . map ( ( item ) => ( { skuCode : item . skuCode , receivedQuantity : Number ( box . items [ item . skuCode ] ) || 0 , boxCount : 1 } ) ) : this . selectedItems . map ( ( item ) => ( { skuCode : item . skuCode , receivedQuantity : this . quantity ( item . skuCode ) , boxCount : this . unitType === 'PALLET' ? this . boxCount ( item . skuCode ) : undefined } ) ) ) . filter ( ( item ) => item . receivedQuantity > 0 ) }
const key = warehouseIdempotencyKey ( 'returnReceipt' , ` confirm- ${ index } ` , payload )
return { request : { ... payload , idempotencyKey : key . value } , result : null }
} )
if ( this . unitType === 'BOX' ) {
updateWarehouseWork ( 'returnReceipt' , { pendingBatch : JSON . parse ( JSON . stringify ( { returnNo : this . current . returnNo , 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 , labels : results . filter ( item => item . unitType === 'BOX' && item . printableHtml ) . map ( item => ( { 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 ( ) { resetWarehouseDomain ( 'returnReceipt' ) ; this . current = null ; this . candidates = null ; this . error = null ; this . activeTarget = 'lookup' } ,
clearCurrent ( ) { if ( this . busy || this . receiptBatch ) return ; 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 ( ) } ,
@@ -100,4 +160,7 @@
@ import '../warehouse/operations.scss' ;
. candidate - heading . card - title { margin - bottom : 0 } . candidate - card { width : 100 % ; text - align : left } . select - link { color : # 135 ee8 ; font - size : 12 px ; font - weight : 800 } . field - label { display : block ; margin : 13 px 0 6 px ; color : # 52667 e ; font - size : 11 px ; font - weight : 700 } . remaining - copy { color : # 135 ee8 ; font - size : 11 px } . quantity - row { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; margin - top : 10 px ; color : # 65768 b ; font - size : 11 px } . quantity - row . number - input { width : 112 px ; text - align : center } . summary - bar { position : sticky ; bottom : 0 ; z - index : 4 ; display : flex ; align - items : center ; gap : 12 px ; margin - top : 12 px ; padding : 11 px 13 px ; border : 1 px solid # d8e3f1 ; border - radius : 13 px ; background : # fff ; box - shadow : 0 5 px 20 px rgba ( 22 , 55 , 96 , .14 ) } . summary - bar > view { min - width : 70 px } . summary - label , . summary - value { display : block } . summary - label { color : # 728197 ; font - size : 10 px } . summary - value { color : # 135 ee8 ; font - size : 22 px ; font - weight : 800 } . submit - button { margin - top : 0 } . result - card { text - align : left } . result - carton { display : block ; margin : 9 px 0 ; color : # 135 ee8 ; font - size : 20 px ; font - weight : 800 ; word - break : break - all } . link - button { width : auto } . work - card > . link - button { margin - top : 8 px ; padding - left : 0 } . success - box . primary { margin - top : 14 px } . scan - hint { display : block ; margin - top : 9 px ; color : # 65768 b ; font - size : 11 px ; text - align : center }
. photo - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 10 px ; margin - top : 12 px } . photo - tile { min - width : 0 ; overflow : hidden ; border : 1 px solid # d7e1ef ; border - radius : 8 px } . photo - thumbnail { display : block ; width : 100 % ; height : 130 px ; background : # f1f4f8 } . photo - name { display : block ; padding : 6 px 8 px ; overflow : hidden ; white - space : nowrap ; text - overflow : ellipsis ; font - size : 12 px ; color : # 52647 c } . photo - tile . link - button { margin : 0 8 px 6 px }
. unit - tabs { display : flex ; gap : 8 px ; margin : 10 px 0 } . unit - tab { flex : 1 ! important ; min - width : 0 ! important ; height : 34 px ! important ; min - height : 34 px ! important ; margin : 0 ! important ; padding : 0 10 px ! important ; border : 1 px solid # d6e1ef ! important ; border - radius : 8 px ! important ; background : # f7f9fc ! important ; color : # 53677 f ! important ; font - size : 13 px ! important ; line - height : 34 px ! important } . unit - tab . active { border - color : # 135 ee8 ! important ; background : # eaf2ff ! important ; color : # 135 ee8 ! important ; font - weight : 700 }
. dimension - grid { display : grid ; grid - template - columns : repeat ( 2 , 1 fr ) ; gap : 7 px ; margin - top : 10 px } . dimension - grid label > text { display : block ; margin - bottom : 3 px ; color : # 65768 b ; font - size : 10 px } . dimension - grid . number - input { box - sizing : border - box ; width : 100 % ; height : 34 px ; padding : 0 8 px ; border : 1 px solid # d7e1ef ; border - radius : 7 px ; color : # 24364 b ; font - size : 13 px ; text - align : center ; background : # fff } . dimension - grid . number - input : focus { border - color : # 2167 e8 ; box - shadow : 0 0 0 2 px rgba ( 33 , 103 , 232 , .12 ) } . volume - box { display : flex ; align - items : center ; justify - content : center ; gap : 4 px ; height : 34 px ; padding : 0 6 px ; border : 1 px solid # b9d3f4 ; border - radius : 7 px ; color : # 65768 b ; font - size : 10 px ; background : # f4f8ff } . volume - box strong { color : # 135 ee8 ; font - size : 13 px }
. work - card > . row - head . business - code { font - size : 16 px ; line - height : 20 px } . work - card > . row - head . meta - line { font - size : 10 px ; line - height : 15 px } . work - card > . row - head . status - pill { padding : 4 px 7 px ; font - size : 10 px }
< / style >