@@ -11,7 +11,7 @@ import { returnRelabelCandidate } from '../utils/returnRelabelCandidates'
import {
createDiscard , createRelabel , createTransfer , getDiscard , getRelabel , getTransfer , resubmitRelabel ,
getTransferCartonLabel , submitTransfer , updateTransfer ,
confirmRelabelCompletion , confirmTransferMeasurement , downloadRelabelCompletionPhoto , downloadTransferMeasurementPhoto , uploadRelabelShippingLabel ,
confirmRelabelCompletion , confirmTransferMeasurement , downloadRelabelCompletionPhoto , downloadTransferMeasurementPhoto , downloadDiscardPhoto , type DiscardPhoto , uploadRelabelShippingLabel ,
uploadRelabelFile ,
listDiscards , listRelabelInventoryCandidates , listRelabelHistoricalOrders , listRelabels , listTransfers , cancelDiscard ,
type RelabelInventoryCandidate ,
@@ -119,6 +119,7 @@ const editingTransferNo = ref('')
interface TransferPallet { id ? : string | number ; sequenceNo ? : number ; palletNo ? : string ; warehouseLocationCode ? : string ; cartonCount ? : number ; lengthCm ? : number ; widthCm ? : number ; heightCm ? : number ; grossWeightKg ? : number ; volumeCbm ? : number ; status ? : string ; completedAt ? : string }
interface TransferMeasurementPhoto { fileId : string | number ; fileName ? : string ; contentType ? : string ; sizeBytes ? : number ; uploadedAt ? : string }
interface ServiceDetail extends Record < string , unknown > {
discardNo ? : string ; photos ? : DiscardPhoto [ ]
transferNo ? : string ; status ? : string ; operatingWarehouseId ? : string ; fbaWarehouseCode ? : string
destinationType ? : 'NONE' | 'FBA' | 'PRIVATE' ; destinationWarehouseCode ? : string ; destinationWarehouseName ? : string
privateDestinationAddress ? : string ; privateDestinationPostalCode ? : string ; privateDestinationContactName ? : string ; privateDestinationContactPhone ? : string
@@ -146,6 +147,21 @@ const measurementPhotoLoading = reactive<Record<string, boolean>>({})
const measurementPhotoErrors = reactive < Record < string , boolean > > ( { } )
const measurementPhotoPreviewUrls = computed ( ( ) => ( selectedDetail . value ? . measurementPhotos || [ ] )
. map ( ( photo ) => measurementPhotoUrls [ photo . fileId ] ) . filter ( Boolean ) )
const discardPhotoUrls = reactive < Record < string , string > > ( { } )
const discardPhotoLoading = reactive < Record < string , boolean > > ( { } )
const discardPhotoErrors = reactive < Record < string , boolean > > ( { } )
const discardPhotoPreviewUrls = computed ( ( ) => ( selectedDetail . value ? . photos || [ ] ) . map ( photo => discardPhotoUrls [ photo . fileId ] ) . filter ( Boolean ) )
let discardPhotoGeneration = 0
function clearDiscardPhotos ( ) { discardPhotoGeneration ++ ; Object . values ( discardPhotoUrls ) . forEach ( URL . revokeObjectURL ) ; for ( const map of [ discardPhotoUrls , discardPhotoLoading , discardPhotoErrors ] ) Object . keys ( map ) . forEach ( key => delete map [ key ] ) }
async function loadDiscardPhoto ( fileId : string ) {
const current = selectedDetail . value ; const generation = discardPhotoGeneration
if ( ! current ? . discardNo || discardPhotoLoading [ fileId ] ) return
discardPhotoLoading [ fileId ] = true ; discardPhotoErrors [ fileId ] = false
try { const blob = await downloadDiscardPhoto ( current . discardNo , fileId ) ; if ( generation !== discardPhotoGeneration || selectedDetail . value !== current || mode . value !== 'detail' ) return ; if ( discardPhotoUrls [ fileId ] ) URL . revokeObjectURL ( discardPhotoUrls [ fileId ] ) ; discardPhotoUrls [ fileId ] = URL . createObjectURL ( blob ) }
catch { if ( generation === discardPhotoGeneration ) discardPhotoErrors [ fileId ] = true }
finally { if ( generation === discardPhotoGeneration ) discardPhotoLoading [ fileId ] = false }
}
async function loadDiscardPhotos ( ) { clearDiscardPhotos ( ) ; await Promise . all ( ( selectedDetail . value ? . photos || [ ] ) . map ( photo => loadDiscardPhoto ( photo . fileId ) ) ) }
const fbaWarehouses = ref < MerchantWarehouse [ ] > ( [ ] )
const operationWarehouses = ref < MerchantWarehouse [ ] > ( [ ] )
const fbaWarehousesLoading = ref ( false )
@@ -202,7 +218,10 @@ const selectedTransferSourceOrderIds = ref<string[]>([])
const isReturnRelabelSource = computed ( ( ) => props . service === 'labeling' && selectedRelabelInboundOrderId . value . startsWith ( 'RETURN_ORDER:' ) )
const selectedReturnOrderNo = computed ( ( ) => isReturnRelabelSource . value ? selectedRelabelInboundOrderId . value . slice ( 'RETURN_ORDER:' . length ) : '' )
const currentRelabelCandidates = computed ( ( ) => relabelCandidates . value . map ( item => returnRelabelCandidate ( item , labelingMode . value ) ) )
const selectedReturnCandidates = computed ( ( ) => currentRelabelCandidates . value . filter ( item => Boolean ( item . returnCartonId ) && returnSourceNos ( item ) . includes ( selectedReturnOrderNo . value ) ) )
const returnCartonQuery = ref ( String ( route . query . cartonMark || '' ) )
const selectedReturnCandidates = computed ( ( ) => currentRelabelCandidates . value . filter ( item => Boolean ( item . returnCartonId )
&& returnSourceNos ( item ) . includes ( selectedReturnOrderNo . value )
&& ( ! returnCartonQuery . value . trim ( ) || ( item . cartonMark || '' ) . toUpperCase ( ) . includes ( returnCartonQuery . value . trim ( ) . toUpperCase ( ) ) ) ) )
const availableReturnCandidates = computed ( ( ) => selectedReturnCandidates . value . filter ( item => inventoryQuantity ( item . availablePackages ) > 0 ) . map ( item => ( { ... item , skuItems : ( item . skuItems || [ ] ) . filter ( sku => inventoryQuantity ( sku . quantity ) > 0 ) } ) ) )
const returnBoxCandidates = computed ( ( ) => availableReturnCandidates . value . filter ( item => item . sourceType === 'RETURN_CARTON' ) )
const returnLooseCandidates = computed ( ( ) => availableReturnCandidates . value . filter ( item => item . sourceType === 'RETURN_PARCEL' ) )
@@ -221,7 +240,7 @@ function returnRelabelProgress(candidate: RelabelInventoryCandidate) {
}
function returnInventoryState ( candidate : RelabelInventoryCandidate ) {
const labels : Record < string , string > = { AVAILABLE : '可选库存' , RELABEL _PENDING : '换标占用' , PACKING _RESERVED : '合箱计划预留' , OUTBOUND _PENDING : '出库占用' , DISCARD _PENDING : '废弃申请占用' , OUTBOUNDED : '已出库' , MERGED : '已合箱' }
return [ ... ( candidate . mergedIntoCartonMarks ? . length ? [ '历史部分转入 ' + candidate . mergedIntoCartonMarks . join ( '、' ) + '(不代表当前库存位置)' ] : [ ] ) , labels [ candidate . inventoryStatus || '' ] || ( inventoryQuantity ( candidate . availablePackages ) > 0 ? '可选库存' : '当前不可选' ) , candidate . occupiedRelabelNo ] . filter ( Boolean ) . join ( ' · ' )
return [ ... ( returnSourceNos ( candidate ) . length > 1 ? [ '跨来源单合箱,数量为整箱内容' ] : [ ] ) , ... ( candidate . mergedIntoCartonMarks ? . length ? [ '历史部分转入 ' + candidate . mergedIntoCartonMarks . join ( '、' ) + '(不代表当前库存位置)' ] : [ ] ) , labels [ candidate . inventoryStatus || '' ] || ( inventoryQuantity ( candidate . availablePackages ) > 0 ? '可选库存' : '当前不可选' ) , candidate . occupiedRelabelNo ] . filter ( Boolean ) . join ( ' · ' )
}
const relabelInboundOptions = computed ( ( ) => {
const grouped = new Map < string , { value : string ; inboundOrderNo : string ; inboundCode : string ; availablePackages : number ; quantityUnit : ' 箱 ' | ' 件 ' ; stockQuantity : number | null ; units : number ; hasOutbound : boolean ; reasons : string [ ] } > ( )
@@ -278,7 +297,7 @@ watch(() => [props.service, props.createPage, props.detailPage, detailNumber.val
else if ( props . detailPage && detailNumber . value ) void loadDetailPage ( )
else void loadPage ( )
} )
watch ( mode , ( value ) => { if ( value !== 'detail' ) { clearCompletionPhotos ( ) ; clearMeasurementPhotos ( ) } } )
watch ( mode , ( value ) => { if ( value !== 'detail' ) { clearCompletionPhotos ( ) ; clearMeasurementPhotos ( ) ; clearDiscardPhotos ( ) } } )
watch ( ( ) => form . relabel , ( value ) => {
if ( props . service === 'transfer' && value ) {
labelingMode . value = 'carton_and_sku'
@@ -321,7 +340,7 @@ onMounted(() => {
else void loadPage ( )
void nextTick ( refreshWarehouseMarkLabels )
} )
onBeforeUnmount ( ( ) => { clearCompletionPhotos ( ) ; clearMeasurementPhotos ( ) } )
onBeforeUnmount ( ( ) => { clearCompletionPhotos ( ) ; clearMeasurementPhotos ( ) ; clearDiscardPhotos ( ) } )
function resetFilters ( ) { Object . assign ( filters , { number : '' , inboundNo : '' , warehouse : '' , destination : '' , status : '' , start : '' , end : '' , reviewStart : '' , reviewEnd : '' , completedStart : '' , completedEnd : '' , measuredStart : '' , measuredEnd : '' , outboundStart : '' , outboundEnd : '' } ) }
function resetForm ( ) {
orderInfoCollapsed . value = false
@@ -1186,6 +1205,7 @@ async function openDetail(value: Record<string, string>) {
mode . value = 'detail'
if ( props . service === 'labeling' ) void loadCompletionPhotos ( )
if ( props . service === 'transfer' ) void loadMeasurementPhotos ( )
if ( props . service === 'disposal' ) void loadDiscardPhotos ( )
} catch ( error ) { selectedDetail . value = null ; selectedRelabelDetail . value = null ; detailError . value = error instanceof Error ? error . message : '详情加载失败' ; mode . value = 'detail' ; ElMessage . error ( detailError . value ) }
}
async function loadDetailPage ( ) {
@@ -1587,6 +1607,8 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
< input ref = "skuTemplateInput" class = "warehouse-hidden-input" type = "file" accept = ".csv,text/csv" @change ="importRelabelTemplate($event, 'sku')" > < input ref = "cartonTemplateInput" class = "warehouse-hidden-input" type = "file" accept = ".csv,text/csv" @change ="importRelabelTemplate($event, 'carton')" > < input ref = "mixedTemplateInput" class = "warehouse-hidden-input" type = "file" accept = ".csv,text/csv" @change ="importRelabelTemplate($event, 'mixed')" > < input ref = "cartonTotalCreateInput" class = "warehouse-hidden-input" type = "file" accept = "application/pdf,.pdf" @change ="selectCartonTotalFile" > < input ref = "skuTotalCreateInput" class = "warehouse-hidden-input" type = "file" accept = "application/pdf,.pdf" @change ="selectSkuTotalFile" >
< p v-if = "isReturnRelabelSource" class="warehouse-list-notice" > 仓库识别码用于定位实物 , 不代表商品已换标 。 SKU 换标完成后扣减旧 SKU 、 增加新 SKU , 包裹 / 箱总件数不变 ; 已换标件数不可重复申请 , 剩余未换标件数可继续选择 。 客户确认前新 SKU 保持占用 。 < / p >
< div v-if = "isReturnRelabelSource" class="warehouse-table-scroll" >
< el -input v-model = "returnCartonQuery" clearable placeholder="按当前箱唛筛选贴标箱子" / >
< p class = "warehouse-hint" > 来源退货单用于查找 ; 贴标选择当前实物箱 。 跨来源单箱内数量为整箱内容 , 同 SKU 部分贴标请先现场分拣确认 。 < / p >
< template v-if = "returnLooseCandidates.length" >
< div class = "warehouse-section-heading" > < h3 > 可用包裹 < / h3 > < el-button text type = "primary" native -type = " button " :aria-expanded = "!availableSkusCollapsed" aria -controls = " return -available -skus " @click ="availableSkusCollapsed = !availableSkusCollapsed" > {{ availableSkusCollapsed ? ' 展开明细 ' : ' 收起明细 ' }} < / el -button > < / div >
< table v-show = "!availableSkusCollapsed" id="return-available-skus" class="warehouse-edit-table"><thead><tr><th>包裹识别码</th><th>来源退货单</th><th>SKU</th><th>SKU 换标结果</th><th>剩余可换(件)</th><th>操作</th></tr></thead><tbody><template v-for="source in returnLooseCandidates" :key="source.returnCartonId"><tr v-for="sku in source.skuItems" :key="sku.skuCode"><td>{{ source.cartonMark }}</td><td>{{ source.inboundOrderNo }}</td><td>{{ sku.skuCode }}</td><td><div v-for="conversion in (source.skuConversions || []).filter(item => item.status !== 'REVERSED')" :key="`${conversion.relabelNo}-${conversion.oldSkuCode}-${conversion.newSkuCode}`">{{ conversion.oldSkuCode }} → {{ conversion.newSkuCode }}:已换 {{ conversion.quantity }} 件{{ conversion.status === 'APPLIED' ? '(待确认)' : '' }}</div><span v-if="!source.skuConversions?.some(item => item.status !== 'REVERSED')">尚未换标</span></td><td>{{ sku.quantity }}</td><td><el-button size="small" :disabled="inventoryQuantity(source.availablePackages) <= 0" @click="labelingMode === 'sku' ? addReturnSkuBinding(source, sku.skuCode) : addReturnBoxBinding(source)">添加贴标</el-button></td></tr></template><tr v-if="!returnLooseCandidates.length"><td colspan="6" > 暂无可用包裹库存 < / td > < / tr > < / tbody > < / table >
@@ -1659,6 +1681,9 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<div v-if=" detailError " class=" warehouse - detail - error " role=" alert "><strong>{{ detailError }}</strong><el-button @click=" loadDetailPage ">重新加载</el-button></div>
<el-alert v-else-if=" props . service === 'labeling' && selectedRelabelDetail ? . application . status === 'REJECTED' " class=" warehouse - rejected - alert " type=" error " :closable=" false " show-icon title=" 贴标单审核已驳回 " :description=" selectedRelabelDetail . application . reviewRemark || '平台未填写驳回备注' " />
<div v-else class=" warehouse - card warehouse - form - section "><h3>订单概览</h3><div v-if=" props . service === 'labeling' " class=" warehouse - detail - grid "><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>客户参考号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数(种)</span><strong>{{ selectedRelabelDetail ? relabelSkuCount(selectedRelabelDetail.items) : '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else-if=" props . service === 'transfer' " class=" warehouse - detail - grid warehouse - transfer - overview - grid "><div><span>状态</span><el-tag :type=" transferStatusTagType ( selectedDetail ? . status || '' ) " effect=" light ">{{ statusLabel(selectedDetail?.status || '') }}</el-tag></div><div><span>目的去向</span><strong>{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if=" selectedDetail ? . destinationType !== 'PRIVATE' "><span>FBA 目的仓</span><strong>{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if=" selectedDetail ? . destinationType !== 'PRIVATE' "><span>FBA 货件号</span><strong>{{ selectedDetail?.fbaShipmentNo || '—' }}</strong></div><div v-if=" selectedDetail ? . destinationType === 'PRIVATE' " class=" warehouse - overview - wide "><span>私人仓地址</span><strong>{{ selectedDetail?.privateDestinationAddress || '—' }}</strong></div><div v-if=" selectedDetail ? . destinationType === 'PRIVATE' "><span>邮编</span><strong>{{ selectedDetail?.privateDestinationPostalCode || '—' }}</strong></div><div v-if=" selectedDetail ? . destinationType === 'PRIVATE' "><span>私人仓联系人</span><strong>{{ selectedDetail?.privateDestinationContactName || '—' }}</strong></div><div v-if=" selectedDetail ? . destinationType === 'PRIVATE' "><span>联系电话</span><strong>{{ selectedDetail?.privateDestinationContactPhone || '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>期望发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>换标状态</span><el-tag :type=" transferRelabelStatusTagType ( transferRelabelStatus ( selectedDetail || { } ) . code ) " effect=" light ">{{ transferRelabelStatus(selectedDetail || {}).label }}</el-tag></div><div><span>换标单号</span><strong>{{ selectedDetail?.relabelNo || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div><div><span>转运单号</span><strong>{{ selectedDetail?.transferNo || '—' }}</strong></div></div><div v-else class=" warehouse - detail - grid "><div v-for=" ( column , index ) in config . columns . slice ( 1 ) " :key=" column "><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
<div v-if=" props . service === 'disposal' " class=" warehouse - card warehouse - form - section ">
<h3>废弃现场照片</h3><div v-if=" selectedDetail ? . photos ? . length " class=" warehouse - photo - grid "><div v-for=" photo in selectedDetail . photos " :key=" photo . fileId " class=" warehouse - photo - item "><el-image v-if=" discardPhotoUrls [ photo . fileId ] " :src=" discardPhotoUrls [ photo . fileId ] " :preview-src-list=" discardPhotoPreviewUrls " :initial-index=" discardPhotoPreviewUrls . indexOf ( discardPhotoUrls [ photo . fileId ] ) " fit=" cover " preview-teleported /><div v-else-if=" discardPhotoLoading [ photo . fileId ] " class=" warehouse - photo - placeholder ">照片加载中…</div><button v-else class=" warehouse - photo - placeholder warehouse - photo - retry " type=" button " @click=" loadDiscardPhoto ( photo . fileId ) ">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button><small>{{ formatDateTime(photo.submittedAt) }}</small></div></div><div v-else class=" warehouse - empty ">暂无已提交的废弃现场照片</div>
</div>
<div v-if=" props . service === 'transfer' " class=" warehouse - card warehouse - form - section warehouse - transfer - measurement - card ">
<div class=" warehouse - section - heading "><h3>PDA 打托实测数据</h3><span class=" warehouse - hint ">PDA 作业完成即显示入库完成,客户可确认仓库实测数据</span></div>
<div v-if=" selectedDetail ? . pallets ? . length " class=" warehouse - table - scroll "><table><thead><tr><th>托盘号</th><th>库位</th><th>箱数</th><th>长 × 宽 × 高(cm)</th><th>毛重(kg)</th><th>体积(m³)</th><th>状态</th><th>完成时间</th></tr></thead><tbody><tr v-for=" pallet in selectedDetail . pallets " :key=" String ( pallet . id || pallet . sequenceNo ) "><td>{{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }}</td><td>{{ pallet.warehouseLocationCode || '—' }}</td><td>{{ pallet.cartonCount ?? '—' }}</td><td>{{ [pallet.lengthCm, pallet.widthCm, pallet.heightCm].every((value) => value != null) ? `${pallet.lengthCm} × ${pallet.widthCm} × ${pallet.heightCm}` : '—' }}</td><td>{{ pallet.grossWeightKg ?? '—' }}</td><td>{{ pallet.volumeCbm ?? '—' }}</td><td>{{ statusLabel(pallet.status || '') }}</td><td>{{ formatDateTime(pallet.completedAt) }}</td></tr></tbody></table></div>