2026-08-25 20:57:51 +08:00
< script setup lang = "ts" >
2026-09-14 20:03:27 +08:00
import '@/styles/adminDensity.css'
2026-09-01 14:23:53 +08:00
import { computed , onMounted , ref } from 'vue'
2026-09-15 15:24:59 +08:00
import { Plus , Search } from '@lucide/vue'
2026-08-26 15:48:34 +08:00
import { ElMessage , ElMessageBox } from 'element-plus'
2026-09-01 14:23:53 +08:00
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-25 20:57:51 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
2026-08-26 15:48:34 +08:00
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
2026-08-25 20:57:51 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-09-01 14:23:53 +08:00
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
2026-08-26 15:48:34 +08:00
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
2026-09-17 13:36:31 +08:00
import { bindWarehouseTransferDestination , cancelOutbound , createOneClickDropshipOutbound , createReturnOutbound , listOneClickOutboundCandidates , listReturnOutboundCandidates , listWarehouseTransferOutboundCandidates , listOutbounds , type OneClickOutboundCandidate , type OutboundRow , type ReturnOutboundCandidate , type WarehouseTransferOutboundCandidate } from '../api/outbounds'
2026-09-10 15:33:54 +08:00
import { type InventoryBalanceDto , type InventoryPositionDto } from '../api/inventory'
2026-08-28 14:33:57 +08:00
import { formatOverseasDateTime } from '../lastMilePresentation'
2026-09-01 14:23:53 +08:00
import { overseasWarehousePermissions as permission } from '../permissions'
2026-09-10 20:31:17 +08:00
import { listPlatformTenants , type PlatformTenantResource } from '@/modules/iam'
2026-09-10 15:33:54 +08:00
import { listPlatformWarehouses , type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getReturnCarton , listReturnCartons , listReturnStock , type ReturnCartonDetail , type ReturnCartonRow , type ReturnStockRow } from '../api/returnInventory'
2026-08-25 20:57:51 +08:00
2026-09-01 14:23:53 +08:00
const router = useRouter (); const access = useAccessPreviewStore ()
const rows = ref < OutboundRow [] >([]); const page = ref ( 1 ); const pageSize = ref ( 20 ); const total = ref ( 0 ); const loading = ref ( false ); const error = ref < ApiErrorDetails | null >( null ); const mutationError = ref < ApiErrorDetails | null >( null )
2026-09-17 13:36:31 +08:00
const filters = ref < QueryModel >({ documentNo : '' , lxCode : '' , warehouseId : '' , status : '' , outboundType : '' }); const createOpen = ref ( false ); const saving = ref ( false ); const recipient = ref ( '' ); const remark = ref ( '' ); const quantity = ref < number | undefined >(); const cartonMarks = ref ( '' ); const selectedReturnMarks = ref < string [] >([]); const selectedCustomerId = ref ( '' ); const selectedPositionId = ref ( '' ); const positionOptions = ref < Array < { balance : InventoryBalanceDto ; position : InventoryPositionDto }> > ([]); const dropshipCandidates = ref < OneClickOutboundCandidate [] >([]); const selectedDropshipIds = ref < string [] >([]); const returnCandidates = ref < ReturnOutboundCandidate [] >([]); const selectedWarehouseId = ref ( '' ); const selectedReturnNo = ref ( '' ); const outboundType = ref < 'ONE_CLICK_DROPSHIP' | 'RETURN_OUTBOUND' | 'WAREHOUSE_TRANSFER_OUTBOUND' > ( 'ONE_CLICK_DROPSHIP' ); const inventoryLoading = ref ( false ); const dropshipQueryInput = ref ( '' ); const dropshipQuery = ref ( '' ); const dropshipPage = ref ( 1 ); const dropshipPageSize = ref ( 5 )
2026-09-10 15:33:54 +08:00
const tenantOptions = ref < PlatformTenantResource [] >([])
const warehouseOptionsSource = ref < PlatformWarehouseSummaryResource [] >([])
2026-09-17 13:36:31 +08:00
const fbaWarehouseOptionsSource = ref < PlatformWarehouseSummaryResource [] >([])
const transferCandidates = ref < WarehouseTransferOutboundCandidate [] >([])
const selectedTransferPalletIds = ref < string [] >([])
const destinationType = ref < 'FBA' | 'PRIVATE' > ( 'FBA' )
const selectedDestinationWarehouseId = ref ( '' )
const deliveryAddress = ref ( '' ); const postalCode = ref ( '' ); const contactName = ref ( '' ); const contactPhone = ref ( '' )
2026-09-10 15:33:54 +08:00
const returnStockRows = ref < ReturnStockRow [] >([])
const returnCartonRows = ref < ReturnCartonRow [] >([])
const selectedReturnSkus = ref < string [] >([])
2026-09-29 20:52:21 +08:00
const returnPickMode = ref < 'BOX' | 'SKU' > ( 'BOX' )
2026-09-10 15:33:54 +08:00
const returnSkuQueryInput = ref ( '' ); const returnSkuQuery = ref ( '' )
const returnCartonQueryInput = ref ( '' ); const returnCartonQuery = ref ( '' )
2026-09-29 20:52:21 +08:00
const returnSkuPage = ref ( 1 ); const returnCartonPage = ref ( 1 ); const returnUnitType = ref < 'BOX' | 'PALLET' > ( 'BOX' )
2026-09-10 15:33:54 +08:00
const returnSkuPageSize = ref ( 5 ); const returnCartonPageSize = ref ( 5 )
const returnCartonDetails = ref < Record < string , ReturnCartonDetail > > ({})
2026-09-01 14:23:53 +08:00
const customerOptions = computed (() => {
const customers = new Map < string , string >()
2026-09-10 15:33:54 +08:00
tenantOptions . value . forEach (( item ) => {
if ( item . status !== 'ACTIVE' ) return
const name = item . tenantName ? . trim () || '客户名称待补充'
const phone = item . contactPhone ? . trim ()
customers . set ( String ( item . id ), phone ? ` ${ name } - ${ phone } ` : name )
})
2026-09-01 14:23:53 +08:00
positionOptions . value . forEach (({ balance }) => customers . set ( balance . customerId , balance . customerName ? . trim () || '客户名称待补充' ))
return [... customers ]. map (([ value , label ]) => ({ value , label }))
})
const availablePositionOptions = computed (() => positionOptions . value . filter (({ balance }) => balance . customerId === selectedCustomerId . value ))
2026-09-15 15:24:59 +08:00
const warehouseOptions = computed (() => warehouseOptionsSource . value . filter (( item ) => item . status === 'ACTIVE' && item . warehouseType === 'OVERSEAS' ). map (( item ) => ({ value : String ( item . id ), label : [ item . warehouseName || '仓库名称待补充' , item . warehouseCode ]. filter ( Boolean ). join ( ' · ' ) })))
2026-09-17 13:36:31 +08:00
const fbaWarehouseOptions = computed (() => fbaWarehouseOptionsSource . value . filter ( item => item . status === 'ACTIVE' ). map ( item => ({ value : String ( item . id ), code : item . warehouseCode || '' , name : item . warehouseName || '' , label : [ item . warehouseName || 'FBA仓名称待补充' , item . warehouseCode ]. filter ( Boolean ). join ( ' · ' ) })))
const selectedTransferRows = computed (() => transferCandidates . value . filter ( item => selectedTransferPalletIds . value . includes ( ` ${ item . customerId } : ${ item . transferNo } ` )))
function transferCustomerName ( row : WarehouseTransferOutboundCandidate ) {
const snapshotName = row . customerName ? . trim ()
if ( snapshotName ) return snapshotName
const customer = tenantOptions . value . find ( item => String ( item . id ) === String ( row . customerId ))
return customer ? . legalName ? . trim () || customer ? . tenantName ? . trim () || `客户 ${ row . customerId } `
}
2026-10-09 17:27:47 +08:00
const selectedReturnStock = computed (() => returnStockRows . value . filter (( item ) => Number ( returnPickMode . value === 'SKU' ? item . looseAvailableQuantity || 0 : item . availableQuantity || 0 ) > 0 ))
2026-09-10 15:33:54 +08:00
const selectedReturnCartons = computed (() => returnCartonRows . value . filter (( item ) => item . status === 'AVAILABLE' ))
2026-09-29 20:52:21 +08:00
const returnOrderOptions = computed (() => [... new Set ([
... returnCandidates . value . flatMap ( item => ( item . returnNo || '' ). split ( ',' )),
... returnCartonRows . value . flatMap ( item => ( item . sourceReturnNo || '' ). split ( ',' )),
]. map ( no => no . trim ()). filter ( Boolean ))])
const returnCandidateByMark = computed (() => Object . fromEntries ( returnCandidates . value . map ( item => [ item . cartonMark || '' , item ])))
const returnPallets = computed (() => [... new Set ( returnCandidates . value . filter ( item => ! selectedReturnNo . value || ( item . returnNo || '' ). split ( ',' ). includes ( selectedReturnNo . value )). map ( item => item . palletCode ). filter (( code ) : code is string => Boolean ( code )))])
2026-09-10 15:33:54 +08:00
const queryTokens = ( value : string ) => value . split ( /[,, \n]+/ ). map (( item ) => item . replace ( /\s+/g , '' ). toLowerCase ()). filter ( Boolean )
const matchesAnyToken = ( value : string , query : string ) => { const tokens = queryTokens ( query ); const normalized = value . replace ( /\s+/g , '' ). toLowerCase (); return ! tokens . length || tokens . some (( token ) => normalized . includes ( token )) }
const filteredReturnStocks = computed (() => selectedReturnStock . value . filter (( item ) => matchesAnyToken ( ` ${ item . skuCode } , ${ item . productNameZh || '' } ` , returnSkuQuery . value )))
const filteredReturnCartons = computed (() => selectedReturnCartons . value . filter (( item ) => {
2026-09-29 20:52:21 +08:00
if ( selectedReturnNo . value && ! ( item . sourceReturnNo || returnCandidateByMark . value [ item . cartonMark ] ? . returnNo || '' ). split ( ',' ). includes ( selectedReturnNo . value )) return false
if ( returnPickMode . value === 'SKU' ) {
const detail = returnCartonDetails . value [ item . cartonMark ]
2026-10-09 17:27:47 +08:00
if ( ! selectedReturnSkus . value . length || item . sourceUnitType !== 'PARCEL' || ! returnCandidateByMark . value [ item . cartonMark ]
2026-09-29 20:52:21 +08:00
|| detail ? . items ? . length !== 1 || detail . items [ 0 ] ? . skuCode !== selectedReturnSkus . value [ 0 ]) return false
}
2026-09-10 15:33:54 +08:00
const detailSkus = returnCartonDetails . value [ item . cartonMark ] ? . items ? . map (( detail ) => detail . skuCode ). join ( ',' ) || ''
return matchesAnyToken ( ` ${ item . cartonMark } , ${ item . fbaCartonMark || '' } , ${ detailSkus } ` , returnCartonQuery . value )
}))
const pagedReturnStocks = computed (() => filteredReturnStocks . value . slice (( returnSkuPage . value - 1 ) * returnSkuPageSize . value , returnSkuPage . value * returnSkuPageSize . value ))
const pagedReturnCartons = computed (() => filteredReturnCartons . value . slice (( returnCartonPage . value - 1 ) * returnCartonPageSize . value , returnCartonPage . value * returnCartonPageSize . value ))
const effectiveReturnMarks = computed (() => [... new Set ( selectedReturnMarks . value )]. filter ( Boolean ) as string [])
2026-09-09 13:35:43 +08:00
const returnMarksModel = computed ({ get : () => selectedReturnMarks . value , set : ( value : string []) => { selectedReturnMarks . value = value } })
2026-09-10 15:33:54 +08:00
const filteredDropshipCandidates = computed (() => dropshipCandidates . value . filter ( item => ! dropshipQuery . value . trim () || [ item . orderNo , item . inboundCode , item . customerName , item . palletNo , item . skuCode , item . productName ]. join ( ' ' ). toLowerCase (). includes ( dropshipQuery . value . trim (). toLowerCase ())))
const pagedDropshipCandidates = computed (() => filteredDropshipCandidates . value . slice (( dropshipPage . value - 1 ) * dropshipPageSize . value , dropshipPage . value * dropshipPageSize . value ))
2026-09-15 15:24:59 +08:00
const selectedDropshipKeys = computed (() => new Set ( selectedDropshipIds . value ))
const selectedPageCount = computed (() => pagedDropshipCandidates . value . filter ( item => selectedDropshipKeys . value . has ( dropshipCandidateKey ( item ))). length )
const allPageSelected = computed (() => pagedDropshipCandidates . value . length > 0 && selectedPageCount . value === pagedDropshipCandidates . value . length )
const somePageSelected = computed (() => selectedPageCount . value > 0 && ! allPageSelected . value )
const selectedDropshipCustomerCount = computed (() => new Set ( dropshipCandidates . value . filter ( item => selectedDropshipKeys . value . has ( dropshipCandidateKey ( item ))). map ( item => item . customerId ). filter ( Boolean )). size )
2026-09-09 13:35:43 +08:00
const oneClickMarks = computed (() => typeof cartonMarks . value === 'string' ? cartonMarks . value . split ( /[,\n, ]+/ ). map ( value => value . trim ()). filter ( Boolean ) : [])
2026-09-18 14:58:40 +08:00
const columns : DataTableColumn [] = [{ key : 'actions' , label : '操作' , width : 180 , fixed : 'left' , slotName : 'actions' }, { key : 'outboundNo' , prop : 'outboundNo' , label : '出库单号' , minWidth : 180 }, { key : 'outboundType' , prop : 'outboundType' , label : '出库类型' , width : 150 , slotName : 'outboundType' }, { key : 'lxCodes' , prop : 'lxCodes' , label : 'LX代码' , minWidth : 180 }, { key : 'dispatchNo' , prop : 'dispatchNo' , label : '关联排车单' , minWidth : 160 }, { key : 'status' , prop : 'status' , label : '状态' , width : 130 , slotName : 'status' }, { key : 'palletCount' , prop : 'palletCount' , label : '托盘数' , width : 100 }, { key : 'packageCount' , prop : 'packageCount' , label : '箱数' , width : 100 }, { key : 'grossWeightKg' , prop : 'grossWeightKg' , label : '毛重(kg)' , width : 120 , align : 'right' }, { key : 'volumeWeightKg' , prop : 'volumeWeightKg' , label : '材积重(kg)' , width : 130 , align : 'right' }, { key : 'volumeCbm' , prop : 'volumeCbm' , label : '体积(m³)' , width : 120 , align : 'right' }, { key : 'createdAt' , prop : 'createdAt' , label : '创建时间' , minWidth : 180 }]
2026-09-17 16:42:02 +08:00
const fields : QueryField [] = [{ key : 'documentNo' , type : 'input' , label : '出库单号' , placeholder : '请输入出库单号' , width : 220 }, { key : 'lxCode' , type : 'input' , label : 'LX代码' , placeholder : '请输入领星入库单代码' , width : 220 }, { key : 'warehouseId' , type : 'input' , label : '目的仓' , placeholder : '请输入目的仓' , width : 220 }, { key : 'status' , type : 'select' , label : '状态' , placeholder : '全部状态' , width : 160 , options : [{ label : '全部' , value : '' }, { label : '待拣货' , value : 'WAIT_PICK' }, { label : '拣货中' , value : 'PICKING' }, { label : '已拣货' , value : 'PICKED' }, { label : '待出库' , value : 'WAIT_OUTBOUND' }, { label : '已出库' , value : 'OUTBOUNDED' }, { label : '已取消' , value : 'CANCELLED' }] }, { key : 'outboundType' , type : 'select' , label : '出库类型' , placeholder : '全部出库类型' , width : 170 , options : [{ label : '全部' , value : '' }, { label : '仓储转运出库' , value : 'WAREHOUSE_TRANSFER_OUTBOUND' }, { label : '一件代发出库' , value : 'ONE_CLICK_DROPSHIP' }, { label : '退货出库' , value : 'RETURN_OUTBOUND' }, { label : '派送出库' , value : 'DISPATCH_DELIVERY' }] }]
2026-08-26 15:48:34 +08:00
2026-09-10 15:33:54 +08:00
async function load ( model : QueryModel = filters . value ) { Object . assign ( filters . value , model ); loading . value = true ; error . value = null ; try { const result = await listOutbounds ({ page : page . value , size : pageSize . value , documentNo : String ( filters . value . documentNo || '' ). trim () || undefined , lxCode : String ( filters . value . lxCode || '' ). trim () || undefined , warehouseId : String ( filters . value . warehouseId || '' ). trim () || undefined , status : String ( filters . value . status || '' ). trim () || undefined , outboundType : String ( filters . value . outboundType || '' ). trim () || undefined }); rows . value = result . records . map (( row ) => ({ ... row , createdAt : formatOverseasDateTime ( row . createdAt ) })); total . value = result . total } catch ( cause ) { error . value = getApiErrorDetails ( cause , '出库单列表加载失败' ) } finally { loading . value = false } }
2026-09-17 13:36:31 +08:00
function outboundTypeLabel ( value ?: string | null ) { return value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' }
2026-10-08 20:25:25 +08:00
async function loadCustomerOptions () {
const records : PlatformTenantResource [] = []
let page = 1
while ( true ) {
const result = await listPlatformTenants ({ page , size : 200 , status : 'ACTIVE' })
records . push (... result . records )
if ( page >= result . pages || ! result . records . length ) return records
page += 1
}
}
2026-09-10 15:33:54 +08:00
async function loadInventoryOptions () {
inventoryLoading . value = true
try {
2026-09-17 13:36:31 +08:00
const [ tenants , warehouses , fbaWarehouses ] = await Promise . allSettled ([
2026-10-08 20:25:25 +08:00
loadCustomerOptions (),
2026-09-10 15:33:54 +08:00
listPlatformWarehouses ({ page : 1 , size : 200 , warehouseType : 'OVERSEAS' , status : 'ACTIVE' }),
2026-09-17 13:36:31 +08:00
listPlatformWarehouses ({ page : 1 , size : 200 , warehouseType : 'FBA' , status : 'ACTIVE' }),
2026-09-10 15:33:54 +08:00
])
2026-10-08 20:25:25 +08:00
if ( tenants . status === 'fulfilled' ) tenantOptions . value = tenants . value
2026-09-10 15:33:54 +08:00
else ElMessage . error ( getApiErrorDetails ( tenants . reason , '客户列表加载失败' ). message )
if ( warehouses . status === 'fulfilled' ) {
warehouseOptionsSource . value = warehouses . value . records
if ( ! selectedWarehouseId . value ) {
const lax = warehouses . value . records . find (( item ) => item . warehouseCode === 'US-LAX-01' || item . warehouseName ? . includes ( '洛杉矶' ))
selectedWarehouseId . value = String ( lax ? . id || warehouses . value . records [ 0 ] ? . id || '' )
}
} else ElMessage . error ( getApiErrorDetails ( warehouses . reason , '海外仓列表加载失败' ). message )
2026-09-17 13:36:31 +08:00
if ( fbaWarehouses . status === 'fulfilled' ) fbaWarehouseOptionsSource . value = fbaWarehouses . value . records
else ElMessage . error ( getApiErrorDetails ( fbaWarehouses . reason , 'FBA目的仓加载失败' ). message )
2026-09-10 15:33:54 +08:00
} finally { inventoryLoading . value = false }
}
2026-09-17 13:36:31 +08:00
async function openCreate () { createOpen . value = true ; outboundType . value = 'ONE_CLICK_DROPSHIP' ; selectedCustomerId . value = '' ; selectedWarehouseId . value = '' ; selectedDropshipIds . value = []; selectedTransferPalletIds . value = []; transferCandidates . value = []; destinationType . value = 'FBA' ; selectedDestinationWarehouseId . value = '' ; deliveryAddress . value = '' ; postalCode . value = '' ; contactName . value = '' ; contactPhone . value = '' ; dropshipCandidates . value = []; dropshipQueryInput . value = '' ; dropshipQuery . value = '' ; dropshipPage . value = 1 ; returnSkuQueryInput . value = '' ; returnSkuQuery . value = '' ; returnCartonQueryInput . value = '' ; returnCartonQuery . value = '' ; returnSkuPage . value = 1 ; returnCartonPage . value = 1 ; await loadInventoryOptions (); await loadDropshipCandidates () }
2026-09-10 15:33:54 +08:00
async function loadDropshipCandidates () { if ( ! selectedWarehouseId . value ) { dropshipCandidates . value = []; return }; dropshipPage . value = 1 ; inventoryLoading . value = true ; try { dropshipCandidates . value = await listOneClickOutboundCandidates ( selectedCustomerId . value || undefined , { warehouseId : selectedWarehouseId . value }) } catch ( cause ) { dropshipCandidates . value = []; ElMessage . error ( getApiErrorDetails ( cause , '一件代发订单加载失败' ). message ) } finally { inventoryLoading . value = false } }
async function changeDropshipWarehouse () { selectedDropshipIds . value = []; await loadDropshipCandidates () }
async function selectCustomer () { selectedPositionId . value = '' ; selectedDropshipIds . value = []; selectedReturnNo . value = '' ; selectedReturnMarks . value = []; selectedReturnSkus . value = []; returnCandidates . value = []; returnStockRows . value = []; returnCartonRows . value = []; returnCartonDetails . value = {}; dropshipCandidates . value = []; dropshipPage . value = 1 ; if ( outboundType . value === 'RETURN_OUTBOUND' ) { if ( selectedCustomerId . value && selectedWarehouseId . value ) await loadReturnCandidates (); return }; await loadDropshipCandidates () }
2026-09-17 13:36:31 +08:00
async function changeOutboundType () { selectedReturnNo . value = '' ; selectedReturnMarks . value = []; selectedReturnSkus . value = []; returnCandidates . value = []; returnStockRows . value = []; returnCartonRows . value = []; returnCartonDetails . value = {}; selectedDropshipIds . value = []; selectedTransferPalletIds . value = []; dropshipCandidates . value = []; transferCandidates . value = []; if ( outboundType . value === 'ONE_CLICK_DROPSHIP' ) await loadDropshipCandidates (); if ( outboundType . value === 'WAREHOUSE_TRANSFER_OUTBOUND' ) await loadTransferCandidates () }
2026-09-17 16:34:36 +08:00
async function loadTransferCandidates () {
inventoryLoading . value = true
try {
transferCandidates . value = await listWarehouseTransferOutboundCandidates ({ customerId : selectedCustomerId . value || undefined , warehouseId : selectedWarehouseId . value || undefined })
const candidateKeys = new Set ( transferCandidates . value . map ( transferSelectionKey ))
selectedTransferPalletIds . value = selectedTransferPalletIds . value . filter ( key => candidateKeys . has ( key ))
if ( transferCandidates . value . length === 1 ) selectedTransferPalletIds . value = [ transferSelectionKey ( transferCandidates . value [ 0 ])]
} catch ( cause ) {
transferCandidates . value = []
selectedTransferPalletIds . value = []
ElMessage . error ( getApiErrorDetails ( cause , '无目的地仓储转运单加载失败' ). message )
} finally { inventoryLoading . value = false }
}
2026-09-17 13:36:31 +08:00
function transferSelectionKey ( row : WarehouseTransferOutboundCandidate ) { return ` ${ row . customerId } : ${ row . transferNo } ` }
function toggleTransferPallet ( row : WarehouseTransferOutboundCandidate , checked : boolean | string | number ) { const key = transferSelectionKey ( row ); selectedTransferPalletIds . value = checked ? [... new Set ([... selectedTransferPalletIds . value , key ])] : selectedTransferPalletIds . value . filter ( id => id !== key ) }
2026-09-17 16:34:36 +08:00
function transferDestinationLabel ( row : WarehouseTransferOutboundCandidate ) {
if ( row . destinationType === 'PRIVATE' || row . privateDestinationAddress ? . trim ()) {
return [ '私人仓' , row . privateDestinationAddress ? . trim (), row . privateDestinationPostalCode ? . trim ()]. filter ( Boolean ). join ( ' · ' )
}
if ( row . destinationWarehouseCode ? . trim ()) return `FBA · ${ row . destinationWarehouseCode . trim () } `
return '未设置'
}
function transferHasDestination ( row : WarehouseTransferOutboundCandidate ) {
return Boolean ( row . destinationWarehouseCode ? . trim () || row . privateDestinationAddress ? . trim ())
}
2026-09-10 15:33:54 +08:00
async function loadReturnCandidates () { if ( ! selectedCustomerId . value || ! selectedWarehouseId . value ) return ; inventoryLoading . value = true ; try { const [ candidates , stock , cartons ] = await Promise . all ([ listReturnOutboundCandidates ( selectedCustomerId . value , selectedWarehouseId . value ), listReturnStock ({ page : 1 , size : 200 , customerId : selectedCustomerId . value , overseasWarehouseId : selectedWarehouseId . value , status : 'AVAILABLE' }), listReturnCartons ({ page : 1 , size : 200 , customerId : selectedCustomerId . value , overseasWarehouseId : selectedWarehouseId . value , status : 'AVAILABLE' })]); returnCandidates . value = candidates ; returnStockRows . value = stock . records ; returnCartonRows . value = cartons . records ; const details = await Promise . all ( cartons . records . map ( async ( row ) => [ row . cartonMark , await getReturnCarton ( row . cartonMark )] as const )); returnCartonDetails . value = Object . fromEntries ( details ); selectedReturnSkus . value = []; selectedReturnMarks . value = [] } catch ( cause ) { ElMessage . error ( getApiErrorDetails ( cause , '客户库存加载失败' ). message ) } finally { inventoryLoading . value = false } }
function dropshipCandidateKey ( item : OneClickOutboundCandidate ) { return ` ${ item . inventoryPositionId } : ${ item . skuCode || '' } ` }
function isDropshipSelected ( item : OneClickOutboundCandidate ) { return selectedDropshipIds . value . includes ( dropshipCandidateKey ( item )) }
function toggleDropshipCandidate ( item : OneClickOutboundCandidate , checked : boolean | string | number ) { const key = dropshipCandidateKey ( item ); selectedDropshipIds . value = checked ? [... new Set ([... selectedDropshipIds . value , key ])] : selectedDropshipIds . value . filter ( value => value !== key ) }
2026-09-15 15:24:59 +08:00
function selectDropshipRows ( items : OneClickOutboundCandidate [], checked : boolean | string | number ) {
const keys = new Set ( items . map ( dropshipCandidateKey ))
selectedDropshipIds . value = checked
? [... new Set ([... selectedDropshipIds . value , ... keys ])]
: selectedDropshipIds . value . filter ( key => ! keys . has ( key ))
}
2026-09-10 15:33:54 +08:00
function searchDropshipCandidates () { dropshipQuery . value = dropshipQueryInput . value . trim (); dropshipPage . value = 1 }
function resetDropshipCandidates () { dropshipQueryInput . value = '' ; dropshipQuery . value = '' ; dropshipPage . value = 1 }
function normalizeBatchQuery ( value : string ) { return value . split ( /[,, \n]+/ ). map (( item ) => item . replace ( /\s+/g , '' )). filter ( Boolean ). join ( ',' ) }
function searchReturnSkus () { returnSkuQueryInput . value = normalizeBatchQuery ( returnSkuQueryInput . value ); returnSkuQuery . value = returnSkuQueryInput . value ; returnSkuPage . value = 1 }
function searchReturnCartons () { returnCartonQueryInput . value = normalizeBatchQuery ( returnCartonQueryInput . value ); returnCartonQuery . value = returnCartonQueryInput . value ; returnCartonPage . value = 1 }
2026-09-29 20:52:21 +08:00
function toggleReturnSku ( sku : string , checked : boolean | string | number ) { selectedReturnSkus . value = checked ? [ sku ] : []; selectedReturnMarks . value = [] }
function changeReturnPickMode () { selectedReturnSkus . value = []; selectedReturnMarks . value = []; returnUnitType . value = 'BOX' }
function changeReturnUnitType () { selectedReturnMarks . value = [] }
2026-09-10 20:38:06 +08:00
function toggleReturnCarton ( cartonMark : string , checked : boolean | string | number ) { selectedReturnMarks . value = checked ? [... new Set ([... selectedReturnMarks . value , cartonMark ])] : selectedReturnMarks . value . filter (( value ) => value !== cartonMark ) }
2026-09-29 20:52:21 +08:00
function toggleReturnPallet ( palletCode : string , checked : boolean | string | number ) {
const marks = returnCandidates . value . filter ( item => item . palletCode === palletCode && item . cartonMark ). map ( item => item . cartonMark ! )
selectedReturnMarks . value = checked ? [... new Set ([... selectedReturnMarks . value , ... marks ])] : selectedReturnMarks . value . filter ( mark => ! marks . includes ( mark ))
}
2026-09-17 13:36:31 +08:00
async function submit () {
if ( outboundType . value !== 'WAREHOUSE_TRANSFER_OUTBOUND' && ! selectedWarehouseId . value ) { ElMessage . warning ( '请选择海外仓' ); return }
if ( outboundType . value === 'RETURN_OUTBOUND' && ! selectedCustomerId . value ) { ElMessage . warning ( '请选择客户' ); return }
saving . value = true
try {
if ( outboundType . value === 'WAREHOUSE_TRANSFER_OUTBOUND' ) {
2026-09-17 16:34:36 +08:00
const selected = selectedTransferRows . value . length ? selectedTransferRows . value : transferCandidates . value . length === 1 ? [ transferCandidates . value [ 0 ]] : []
2026-09-17 13:36:31 +08:00
if ( ! selected . length ) { ElMessage . warning ( '请至少选择一个无目的地的仓储转运单' ); return }
const destination = fbaWarehouseOptions . value . find ( item => item . value === selectedDestinationWarehouseId . value )
if ( destinationType . value === 'FBA' && ! destination ) { ElMessage . warning ( '请选择FBA目的仓' ); return }
if ( destinationType . value === 'PRIVATE' && ( ! deliveryAddress . value . trim () || ! postalCode . value . trim () || ! contactName . value . trim () || ! contactPhone . value . trim ())) { ElMessage . warning ( '请完整填写私人仓地址、邮编、联系人和电话' ); return }
await bindWarehouseTransferDestination ({ transfers : selected . map ( item => ({ customerId : item . customerId , transferNo : item . transferNo })), destinationType : destinationType . value , destinationWarehouseId : destination ? . value , destinationWarehouseCode : destination ? . code , destinationWarehouseName : destination ? . name , privateDestinationAddress : deliveryAddress . value . trim () || undefined , privateDestinationPostalCode : postalCode . value . trim () || undefined , privateDestinationContactName : contactName . value . trim () || undefined , privateDestinationContactPhone : contactPhone . value . trim () || undefined , idempotencyKey : `web-transfer-destination- ${ Date . now () } ` })
ElMessage . success ( `已为 ${ selected . length } 个转运单绑定目的地,可进入智能排车` )
} else if ( outboundType . value === 'RETURN_OUTBOUND' ) {
2026-10-09 17:27:47 +08:00
if ( ! recipient . value . trim ()) { ElMessage . warning ( '请填写收件信息' ); return }
if ( ! selectedReturnNo . value ) { ElMessage . warning ( '请选择退货单' ); return }
2026-09-29 20:52:21 +08:00
if ( returnPickMode . value === 'SKU' && selectedReturnSkus . value . length !== 1 ) { ElMessage . warning ( '请选择一个 SKU' ); return }
2026-10-09 17:27:47 +08:00
if ( ! selectedReturnMarks . value . length ) {
ElMessage . warning ( returnPickMode . value === 'SKU'
? filteredReturnCartons . value . length ? '请在右侧选择该 SKU 的散件来源记录' : '该退货单没有此 SKU 可出库的散件库存;已装箱货物请切换箱/托出库'
: '请选择出库箱号或托盘' )
return
}
2026-09-29 20:52:21 +08:00
await createReturnOutbound ( selectedCustomerId . value , { recipient : recipient . value . trim (), overseasWarehouseId : selectedWarehouseId . value , returnNo : selectedReturnNo . value , cartonMarks : selectedReturnMarks . value , skuCode : returnPickMode . value === 'SKU' ? selectedReturnSkus . value [ 0 ] : undefined , unitType : returnPickMode . value === 'SKU' ? 'BOX' : returnUnitType . value , remark : remark . value . trim () || undefined , idempotencyKey : `web-return- ${ Date . now () } ` })
ElMessage . success ( '退货出库单和待派送单已创建,作业已推送 PDA' )
2026-09-17 13:36:31 +08:00
} else {
const selected = dropshipCandidates . value . filter ( item => selectedDropshipIds . value . includes ( dropshipCandidateKey ( item )))
if ( ! selected . length ) { ElMessage . warning ( '请至少选择一个已完成入库的一件代发订单' ); return }
const customerIds = new Set ( selected . map ( item => item . customerId ). filter ( Boolean )); if ( customerIds . size !== 1 ) { ElMessage . warning ( '一张出库单只能选择同一个客户的订单' ); return }
const warehouses = new Set ( selected . map ( item => item . overseasWarehouseId ). filter ( Boolean )); if ( warehouses . size > 1 || ( warehouses . size === 1 && ! warehouses . has ( selectedWarehouseId . value ))) { ElMessage . warning ( '所选订单必须属于当前海外仓' ); return }
const items = selected . map ( item => ({ inventoryPositionId : item . inventoryPositionId , packages : item . availablePackages , sku : item . skuCode || '' , cartonMarks : [] as string [] }))
await createOneClickDropshipOutbound ([... customerIds ][ 0 ] || '' , { recipient : '一件代发订单直出' , items , remark : remark . value . trim () || undefined , idempotencyKey : `web-dropship- ${ Date . now () } ` })
ElMessage . success ( `已按订单生成 ${ selected . length } 个托盘出库明细并推送PDA拣货任务` )
}
createOpen . value = false ; await load ()
} catch ( cause ) { ElMessage . error ( getApiErrorDetails ( cause , '出库单创建失败' ). message ) } finally { saving . value = false }
}
2026-09-01 14:23:53 +08:00
function openDetail ( row : OutboundRow ) { void router . push ({ name : 'overseas-outbound-detail' , params : { outboundNo : row . outboundNo } }) }
2026-09-15 20:52:05 +08:00
function canCancel ( row : OutboundRow ) { return access . can ( permission . outboundCancel ) && [ 'WAIT_PICK' , 'WAIT_OUTBOUND' ]. includes ( String ( row . status || '' )) }
2026-10-03 04:35:55 +08:00
async function cancel ( row : OutboundRow ) { if ( ! canCancel ( row )) return ; let reason = '' ; try { const result = await ElMessageBox . prompt ( `出库单 ${ row . outboundNo } · 出库数量 ${ row . packageCount ?? 0 } 。已关联车次请先解除关联;取消将申请撤销未履行的派送应收,装车、买约及其他费用保留待核对。` , '取消出库单' , { customClass : 'admin-density-confirm' , inputType : 'textarea' , inputPlaceholder : '请输入取消原因' , inputPattern : /\S+/ , inputErrorMessage : '请输入取消原因' , confirmButtonText : '确认取消' , cancelButtonText : '返回' , type : 'warning' }); reason = result . value . trim () } catch { return }; mutationError . value = null ; try { await cancelOutbound ( row . outboundNo , reason ); ElMessage . success ( '出库单已取消;派送应收撤销结果请查看费用明细,排车占用需回排车列表取消' ); await load () } catch ( cause ) { mutationError . value = getApiErrorDetails ( cause , '取消出库单失败' ) } }
2026-08-26 15:48:34 +08:00
function search ( model : QueryModel ) { page . value = 1 ; void load ( model ) }
function reset ( model : QueryModel ) { page . value = 1 ; void load ( model ) }
onMounted ( load )
2026-08-25 20:57:51 +08:00
</ script >
2026-08-26 15:48:34 +08:00
< template >
2026-09-14 20:03:27 +08:00
< AdminPage class = "admin-density" >< ListPageLayout aria -label =" 出库单 " >
2026-09-01 14:23:53 +08:00
< OperationErrorToast :details = "mutationError" />
2026-09-30 14:01:58 +08:00
< section >< DataTable appearance = "integrated" :stripe = "false" column -settings-mode =" show " :results-visible = "!error" v -model:page = "page" v -model:page-size = "pageSize" :data = "rows" :columns = "columns" :loading = "loading" :total = "total" preference -key =" overseas -outbounds " row -key =" id " empty -text =" 暂无出库单 " @ update :page = "load()" @ update : page -size =" page = 1 ; load ()" >
< template # toolbar -start >< h1 > 出库单 </ h1 ></ template >
< template # filters >< QueryToolbar v-model = "filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></ template >
< template # notices >< template v-if = "error"><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></ template ></ LoadErrorState ></ template ></ template >
2026-09-09 13:35:43 +08:00
< template # toolbar -actions >< el-button v-if = "access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" /> 新建出库单 </ el-button ></ template >
2026-09-30 14:01:58 +08:00
< template # actions = "{ row }" >< div class = "density-actions row-actions admin-text-actions" >< el-button class = "density-action" data -tone =" neutral " size = "small" @click ="openDetail(row as OutboundRow)" > 详情 </ el -button >< el-button class = "density-action" data -tone =" danger " v-if = "canCancel(row as OutboundRow)" size="small" @click="cancel(row as OutboundRow)" > 取消 </ el -button ></ div ></ template >
2026-09-10 15:33:54 +08:00
< template # outboundType = "{ row }" >{{ outboundTypeLabel ( row . outboundType ) }}</ template >
2026-09-15 20:52:05 +08:00
< template # status = "{ row }" >< StatusBadge : status = "row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></ template >
2026-08-26 15:48:34 +08:00
</ DataTable ></ section >
</ ListPageLayout ></ AdminPage >
2026-09-17 13:36:31 +08:00
< AppDialog class = "admin-density-dialog" v-model = "createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving" @confirm="submit" >
2026-09-14 20:03:27 +08:00
< el-form class = "create-outbound-form density-form" label -position =" top " >
2026-09-10 15:33:54 +08:00
< el-form-item label = "出库类型" required >
< el-radio-group v-model = "outboundType" @change="changeOutboundType" >
< el-radio-button label = "ONE_CLICK_DROPSHIP" > 一件代发出库 </ el-radio-button >
< el-radio-button label = "RETURN_OUTBOUND" > 退货出库 </ el-radio-button >
2026-09-17 13:36:31 +08:00
< el-radio-button label = "WAREHOUSE_TRANSFER_OUTBOUND" > 仓储转运出库 </ el-radio-button >
2026-09-10 15:33:54 +08:00
</ el-radio-group >
</ el-form-item >
< el-form-item v-if = "outboundType === 'RETURN_OUTBOUND'" label="客户" required >
< el-select v-model = "selectedCustomerId" :loading="inventoryLoading" clearable filterable placeholder="请选择客户" @change="selectCustomer" >
< el-option v-for = "item in customerOptions" :key="item.value" :value="item.value" :label="item.label" />
</ el-select >
</ el-form-item >
2026-09-17 13:36:31 +08:00
< template v-if = "outboundType === 'ONE_CLICK_DROPSHIP'" >
2026-09-15 18:17:17 +08:00
< div class = "dropship-search-row" >
2026-09-10 15:33:54 +08:00
< el-form-item label = "海外仓" required >
2026-09-15 15:24:59 +08:00
< el-select v-model = "selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse" >
2026-09-10 15:33:54 +08:00
< el-option v-for = "item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" />
</ el-select >
</ el-form-item >
2026-09-15 18:17:17 +08:00
< el-form-item label = "一件代发订单" >
2026-09-10 15:33:54 +08:00
< div class = "candidate-query" >
2026-09-15 18:17:17 +08:00
< el-input v-model = "dropshipQueryInput" :prefix-icon="Search" clearable aria-label="搜索可出库订单" placeholder="订单号 / 入库单号 / 客户 / 托盘 / SKU / 品名" @keyup.enter="searchDropshipCandidates" />
2026-09-10 15:33:54 +08:00
< el-button type = "primary" :loading = "inventoryLoading" @click ="searchDropshipCandidates" > 查询 </ el -button >
< el-button @click ="resetDropshipCandidates" > 重置 </ el -button >
</ div >
2026-09-15 18:17:17 +08:00
</ el-form-item >
</ div >
< section class = "candidate-results" aria -label =" 已完成入库的一件代发订单 " >
2026-09-15 15:24:59 +08:00
< el-table v-loading = "inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table" max-height="360" >
2026-09-10 15:33:54 +08:00
< el-table-column label = "选择" width = "68" align = "center" >
2026-09-15 15:24:59 +08:00
< template # header >< el-checkbox aria -label =" 全选本页订单 " :model-value = "allPageSelected" :indeterminate = "somePageSelected" : disabled = "inventoryLoading || !pagedDropshipCandidates.length" @change ="selectDropshipRows(pagedDropshipCandidates, $event)" /></ template >
< template # default = "{ row }" >< el-checkbox : aria -label ="` 选择 $ { row.orderNo || ' 订单 ' } / $ { row.palletNo || ' 托盘 ' } / $ { row.skuCode || ' SKU ' } `" : model -value =" isDropshipSelected ( row as OneClickOutboundCandidate )" @change ="toggleDropshipCandidate(row as OneClickOutboundCandidate, $event)" /></ template >
2026-09-10 15:33:54 +08:00
</ el-table-column >
< el-table-column prop = "customerName" label = "客户" min -width =" 150 " show -overflow-tooltip />
< el-table-column prop = "orderNo" label = "尾程订单号" min -width =" 170 " show -overflow-tooltip />
< el-table-column prop = "inboundCode" label = "入库单号" min -width =" 160 " show -overflow-tooltip />
< el-table-column prop = "palletNo" label = "托盘号" min -width =" 140 " show -overflow-tooltip />
< el-table-column prop = "skuCode" label = "SKU" min -width =" 140 " show -overflow-tooltip />
< el-table-column prop = "productName" label = "品名" min -width =" 140 " show -overflow-tooltip />
< el-table-column prop = "availablePackages" label = "可出箱数" width = "95" align = "right" />
2026-09-18 15:23:43 +08:00
< el-table-column prop = "grossWeightKg" label = "可出毛重(kg)" width = "125" align = "right" />
< el-table-column prop = "volumeWeightKg" label = "可出材积重(kg)" width = "135" align = "right" />
< el-table-column prop = "volumeCbm" label = "可出体积(m³)" width = "125" align = "right" />
2026-09-10 15:33:54 +08:00
</ el-table >
< div v-if = "!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint" > 暂无可直接出库的已完成入库订单 </ div >
2026-09-15 18:17:17 +08:00
< div class = "candidate-pagination" >
< span aria -live =" polite " title = "翻页保留选择" > 已选择 {{ selectedDropshipIds . length }} 条 · 第 {{ dropshipPage }} / {{ Math . max ( 1 , Math . ceil ( filteredDropshipCandidates . length / dropshipPageSize )) }} 页 </ span >
2026-09-15 15:24:59 +08:00
< el-pagination v -model:current-page = "dropshipPage" v -model:page-size = "dropshipPageSize" : page -sizes ="[ 5 , 10 , 20 , 50 ]" :pager-count = "5" :total = "filteredDropshipCandidates.length" layout = "total, sizes, prev, pager, next, jumper" @ size -change =" dropshipPage = 1 " />
2026-09-10 15:33:54 +08:00
</ div >
2026-09-15 15:24:59 +08:00
< el-alert v-if = "selectedDropshipCustomerCount > 1" title="当前选择包含多个客户,请按客户搜索后选择;一张出库单只能属于同一客户。" type="warning" :closable="false" />
2026-09-15 18:17:17 +08:00
< p v-else class = "candidate-hint" > 按托盘与 SKU 选择 , 默认出库全部可用箱数 ; 仅限同一客户 、 同一海外仓 , 翻页保留选择 。 </ p >
</ section >
2026-09-10 15:33:54 +08:00
</ template >
2026-09-17 13:36:31 +08:00
< template v-else-if = "outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND'" >
< el-alert title = "仅展示已完成入库、换标已完成或无需换标,并且尚未设置目的地的仓储转运单。绑定后将从此列表移除并进入智能排车候选。" type = "info" :closable = "false" />
< el-form-item label = "无目的地转运单" required >
< el-table v-loading = "inventoryLoading" :data="transferCandidates" border stripe class="dropship-candidate-table" max-height="380" >
< el-table-column label = "选择" width = "68" align = "center" >< template # default = "{ row }" >< el-checkbox : model -value =" selectedTransferPalletIds.includes ( transferSelectionKey ( row as WarehouseTransferOutboundCandidate ))" @change ="toggleTransferPallet(row as WarehouseTransferOutboundCandidate, $event)" /></ template ></ el-table-column >
< el-table-column label = "客户" min -width =" 180 " show -overflow-tooltip >< template # default = "{ row }" >{{ transferCustomerName ( row as WarehouseTransferOutboundCandidate ) }}</ template ></ el-table-column >
< el-table-column prop = "transferNo" label = "转运单号" min -width =" 190 " show -overflow-tooltip />
< el-table-column prop = "packageCount" label = "入库箱数" width = "100" align = "right" />
< el-table-column prop = "volumeCbm" label = "总体积(m³)" width = "120" align = "right" />
< el-table-column label = "换标" width = "110" >< template # default = "{ row }" >{{ row . needRelabel ? `已换标 ${ row . relabelNo ? ` · ${ row . relabelNo } ` : '' } ` : '原箱出库' }}</ template ></ el-table-column >
2026-09-17 16:34:36 +08:00
< el-table-column label = "主单目的去向" min -width =" 240 " show -overflow-tooltip >< template # default = "{ row }" >< StatusBadge : status = "transferHasDestination(row as WarehouseTransferOutboundCandidate) ? 'READY' : 'PENDING'" : label = "transferDestinationLabel(row as WarehouseTransferOutboundCandidate)" /></ template ></ el-table-column >
2026-09-17 13:36:31 +08:00
</ el-table >
< div v-if = "!inventoryLoading && !transferCandidates.length" class="form-hint" > 暂无已入库且尚无目的地的仓储转运单 </ div >
</ el -form-item >
< el-form-item label = "目的地类型" required >< el-radio-group v-model = "destinationType"><el-radio-button label="FBA">FBA目的仓</el-radio-button><el-radio-button label="PRIVATE" > 私人仓 </ el -radio-button ></ el-radio-group ></ el-form-item >
< el-form-item v-if = "destinationType === 'FBA'" label="FBA目的仓" required><el-select v-model="selectedDestinationWarehouseId" filterable clearable placeholder="请选择FBA目的仓"><el-option v-for="item in fbaWarehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></ el-select ></ el-form-item >
< template v-else >
< el-form-item label = "私人仓地址" required >< el-input v-model = "deliveryAddress" placeholder="请输入完整派送地址" /></ el-form-item >
< el-form-item label = "邮编" required >< el-input v-model = "postalCode" maxlength="32" placeholder="请输入邮编" /></ el-form-item >
< el-form-item label = "联系人" required >< el-input v-model = "contactName" placeholder="请输入联系人" /></ el-form-item >
< el-form-item label = "联系电话" required >< el-input v-model = "contactPhone" placeholder="请输入联系电话" /></ el-form-item >
</ template >
</ template >
2026-09-10 15:33:54 +08:00
< template v-else >
< el-form-item label = "收件信息" required >< el-input v-model = "recipient" placeholder="请输入收件人、电话和地址" /></ el-form-item >
< el-form-item label = "海外仓" required >< el-select v-model = "selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></ el-select ></ el-form-item >
2026-09-29 20:52:21 +08:00
< el-form-item label = "退货单" required >< el-select v-model = "selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></ el-select ></ el-form-item >
< el-form-item label = "出库维度" >< el-radio-group v-model = "returnPickMode" @change="changeReturnPickMode"><el-radio-button value="BOX">箱 / 托</el-radio-button><el-radio-button value="SKU" > 单 SKU </ el -radio-button ></ el-radio-group ></ el-form-item >
< el-form-item v-if = "returnPickMode === 'BOX'" label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按箱号出库</el-radio-button><el-radio-button value="PALLET" > 按托盘出库 </ el -radio-button ></ el-radio-group ></ el-form-item >
< el-form-item v-if = "returnPickMode === 'BOX' && returnUnitType === 'PALLET' && returnPallets.length" label="整托选择"><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)" >{{ code }} </ el -checkbox ></ div ></ el-form-item >
2026-09-10 15:33:54 +08:00
< div class = "return-lists" >
< section class = "return-list-card" >
2026-09-29 20:52:21 +08:00
< div class = "list-title" >< span > SKU库存 </ span >< small > 按 SKU 查询 </ small ></ div >
2026-09-10 15:33:54 +08:00
< div class = "candidate-query" >< el-input v-model = "returnSkuQueryInput" clearable placeholder="如:SKU001,SKU002" /><el-button type="primary" @click="searchReturnSkus" > 查询 </ el -button ></ div >
2026-10-09 17:27:47 +08:00
< el-table :data = "pagedReturnStocks" border stripe class = "return-result-table" max -height =" 400 " >< el-table-column v-if = "returnPickMode === 'SKU'" label="选择" width="60" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedReturnSkus.includes(row.skuCode)" @change="toggleReturnSku(row.skuCode, $event)" /></template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column :label="returnPickMode === 'SKU' ? '散件可用数量' : '可用数量'" width="110" align="right"><template #default="{ row }" >{{ returnPickMode = == ' SKU ' ? row.looseAvailableQuantity : row.availableQuantity }}</ template > </ el -table-column ></ el-table >
2026-09-29 20:52:21 +08:00
< div class = "return-pagination" >< span >{{ returnPickMode === 'SKU' ? `已选择 ${ selectedReturnSkus . length } 个 SKU` : 'SKU 库存' }}</ span >< el-pagination v -model:current-page = "returnSkuPage" v -model:page-size = "returnSkuPageSize" : page -sizes ="[ 5 , 10 , 20 , 50 ]" :total = "filteredReturnStocks.length" layout = "total, sizes, prev, pager, next" @ size -change =" returnSkuPage = 1 " /></ div >
2026-09-10 15:33:54 +08:00
</ section >
< section class = "return-list-card" >
< div class = "list-title" >< span > 箱号列表 </ span >< small > 支持多个箱号或 SKU , 用英文逗号分隔 </ small ></ div >
< div class = "candidate-query" >< el-input v-model = "returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons" > 查询 </ el -button ></ div >
2026-09-29 20:52:21 +08:00
< el-table :data = "pagedReturnCartons" border stripe class = "return-result-table" max -height =" 400 " >< el-table-column label = "选择" width = "60" align = "center" >< template # default = "{ row }" >< el-checkbox :model-value = "selectedReturnMarks.includes(row.cartonMark)" @change ="toggleReturnCarton(row.cartonMark, $event)" /></ template ></ el-table-column >< el-table-column prop = "cartonMark" label = "箱号" min -width =" 190 " show -overflow-tooltip />< el-table-column label = "托盘" min -width =" 150 " >< template # default = "{ row }" >{{ returnCandidateByMark [ row . cartonMark ] ? . palletCode || '散箱' }}</ template ></ el-table-column >< el-table-column label = "箱内SKU及数量" min -width =" 230 " >< template # default = "{ row }" >{{ returnCartonDetails [ row . cartonMark ] ? . items ? . map (( item ) => ` ${ item . skuCode } × ${ item . quantity } ` ). join ( ', ' ) || '加载中' }}</ template ></ el-table-column ></ el-table >
2026-09-10 15:33:54 +08:00
< div class = "return-pagination" >< span > 已选择 {{ selectedReturnMarks . length }} 个箱号 </ span >< el-pagination v -model:current-page = "returnCartonPage" v -model:page-size = "returnCartonPageSize" : page -sizes ="[ 5 , 10 , 20 , 50 ]" :total = "filteredReturnCartons.length" layout = "total, sizes, prev, pager, next" @ size -change =" returnCartonPage = 1 " /></ div >
</ section >
</ div >
</ template >
2026-09-15 18:17:17 +08:00
< el-form-item label = "备注" >< el-input v-model = "remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" placeholder="请输入备注(选填)" /></ el-form-item >
2026-09-10 15:33:54 +08:00
</ el-form >
</ AppDialog >
2026-08-26 15:48:34 +08:00
</ template >
2026-09-02 15:43:11 +08:00
< style scoped >
2026-09-14 20:03:27 +08:00
. create - outbound - form { padding : 0 var ( -- admin - space - 1 ); }
2026-09-15 15:24:59 +08:00
. create - outbound - form : deep (. el - form - item__content > . el - input ), . create - outbound - form : deep (. el - form - item__content > . el - select ), . create - outbound - form : deep (. el - form - item__content > . el - input - number ) { width : 100 % ; }
2026-09-14 20:03:27 +08:00
. form - hint { margin - top : var ( -- admin - space - 2 ); color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption ); }
. candidate - list { display : grid ; gap : var ( -- admin - space - 3 ); max - height : 300 px ; overflow : auto ; padding : var ( -- admin - space - 3 ) 0 ; }
2026-09-10 15:33:54 +08:00
. candidate - list : deep (. el - checkbox ) { margin - right : 0 ; }
2026-09-14 20:03:27 +08:00
. candidate - row { display : grid ; grid - template - columns : minmax ( 120 px , 1 fr ) minmax ( 150 px , 1 fr ) auto ; gap : var ( -- admin - space - 5 ); align - items : center ; width : min ( 700 px , 100 % ); }
. candidate - row span , . candidate - row em { color : var ( -- cc - muted ); font - style : normal ; font - size : var ( -- admin - font - caption ); }
. candidate - query { display : flex ; width : 100 % ; gap : var ( -- admin - space - 4 ); }
2026-09-10 15:33:54 +08:00
. candidate - query : deep (. el - input ) { flex : 1 ; }
. candidate - query : deep (. el - button ) { flex : 0 0 auto ; min - width : 78 px ; margin - left : 0 ; }
2026-09-15 18:17:17 +08:00
. dropship - search - row { display : grid ; grid - template - columns : minmax ( 0 , 280 px ) minmax ( 0 , 640 px ); align - items : end ; gap : var ( -- admin - space - 5 ); }
. dropship - search - row : deep (. el - form - item ) { min - width : 0 ; margin - bottom : var ( -- admin - space - 3 ); }
. candidate - results { min - width : 0 ; }
. candidate - hint { margin : 0 0 var ( -- admin - space - 3 ); color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption ); line - height : 1.5 ; }
. dropship - candidate - table { width : 100 % ; }
2026-09-14 20:03:27 +08:00
. candidate - pagination { display : flex ; width : 100 % ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 5 ); padding : var ( -- admin - space - 5 ) 0 ; color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption ); }
2026-09-15 15:24:59 +08:00
. candidate - pagination , . return - pagination { flex - wrap : wrap ; }
. candidate - pagination > span , . return - pagination > span { flex - shrink : 0 ; white - space : nowrap ; }
. candidate - pagination : deep (. el - pagination ), . return - pagination : deep (. el - pagination ) { min - width : 0 ; max - width : 100 % ; flex - wrap : wrap ; gap : var ( -- admin - space - 2 ); justify - content : flex - end ; }
. candidate - pagination : deep (. el - pagination__sizes ), . return - pagination : deep (. el - pagination__sizes ) { flex : 0 0 128 px ; }
. candidate - pagination : deep (. el - pagination__sizes . el - select ), . return - pagination : deep (. el - pagination__sizes . el - select ) { width : 128 px ; }
. candidate - pagination : deep (. el - pagination__jump . el - input ) { width : 56 px ; }
2026-09-14 20:03:27 +08:00
. return - lists { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) minmax ( 0 , 1 fr ); gap : var ( -- admin - space - 5 ); width : 100 % ; margin : var ( -- admin - space - 1 ) 0 var ( -- admin - space - 5 ); }
2026-09-29 20:52:21 +08:00
. return - pallet - options { display : flex ; flex - wrap : wrap ; gap : 8 px 16 px ; }
2026-09-14 20:03:27 +08:00
. return - list - card { min - width : 0 ; padding : var ( -- admin - space - 5 ); border : 1 px solid var ( -- el - border - color - light ); border - radius : var ( -- admin - radius ); background : var ( -- el - fill - color - extra - light ); }
. list - title { display : flex ; align - items : baseline ; justify - content : space - between ; gap : var ( -- admin - space - 5 ); margin - bottom : var ( -- admin - space - 5 ); }
. list - title span { color : var ( -- el - text - color - primary ); font - size : var ( -- admin - font - section ); font - weight : 650 ; }
. list - title small { color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption ); font - weight : 400 ; }
. return - result - table { width : 100 % ; margin - top : var ( -- admin - space - 5 ); background : var ( -- el - bg - color ); }
. return - pagination { display : flex ; min - height : 44 px ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 5 ); padding - top : var ( -- admin - space - 5 ); color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption ); }
2026-09-10 15:33:54 +08:00
. return - pagination : deep (. el - pagination ) { flex - wrap : wrap ; justify - content : flex - end ; }
@ media ( max - width : 980 px ) { . return - lists { grid - template - columns : 1 fr ; }. return - pagination { align - items : flex - start ; flex - direction : column ; } }
2026-09-02 15:43:11 +08:00
</ style >