Files
admin-web/src/modules/overseas-warehouse/pages/OutboundsView.vue
T
2026-10-20 14:52:06 +08:00

413 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { Plus, Search } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
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'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { bindWarehouseTransferDestination, cancelOutbound, createOneClickDropshipOutbound, createReturnOutbound, listOneClickOutboundCandidates, listReturnOutboundCandidates, listWarehouseTransferOutboundCandidates, listOutbounds, type OneClickOutboundCandidate, type OutboundRow, type ReturnOutboundCandidate, type WarehouseTransferOutboundCandidate } from '../api/outbounds'
import { type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getReturnCarton, listReturnCartons, listReturnStock, type ReturnCartonDetail, type ReturnCartonRow, type ReturnStockRow } from '../api/returnInventory'
const router = useRouter(); const access = useAccessPreviewStore()
const route = useRoute()
function openDispatch(row: OutboundRow) {
if (!row.dispatchNo || !access.can(permission.dispatchList)) return
void router.push({ name: 'overseas-dispatches', query: { dispatchNo: row.dispatchNo } })
}
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)
const filters = ref<QueryModel>({ documentNo: typeof route.query.documentNo === 'string' ? route.query.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)
const tenantOptions = ref<PlatformTenantResource[]>([])
const warehouseOptionsSource = ref<PlatformWarehouseSummaryResource[]>([])
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('')
const returnStockRows = ref<ReturnStockRow[]>([])
const returnCartonRows = ref<ReturnCartonRow[]>([])
const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('')
const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('')
const returnSkuPage = ref(1); const returnCartonPage = ref(1); const returnUnitType = ref<'BOX' | 'PALLET'>('BOX')
const returnSkuPageSize = ref(5); const returnCartonPageSize = ref(5)
const returnCartonDetails = ref<Record<string, ReturnCartonDetail>>({})
const resourceErrors = ref<ApiErrorDetails[]>([]), candidateError = ref<ApiErrorDetails | null>(null), createError = ref<ApiErrorDetails | null>(null)
const createLocked = computed(() => saving.value || inventoryLoading.value || !access.can(permission.outboundCreate))
const customerOptions = computed(() => {
const customers = new Map<string, string>()
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)
})
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))
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(' · ') })))
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() || '客户名称待补充'
}
const selectedReturnStock = computed(() => returnStockRows.value.filter(item => Number(item.availableQuantity || 0) > 0))
const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
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)))])
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) => {
if (selectedReturnNo.value && !(item.sourceReturnNo || returnCandidateByMark.value[item.cartonMark]?.returnNo || '').split(',').includes(selectedReturnNo.value)) return false
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[])
watch([selectedReturnNo, selectedCustomerId, selectedWarehouseId], () => {
selectedReturnMarks.value = []
})
const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } })
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))
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)
const oneClickMarks = computed(() => typeof cartonMarks.value === 'string' ? cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean) : [])
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 }]
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' }, { label: '废弃出库', value: 'DISCARD_OUTBOUND' }, { label: '贴标出库', value: 'RELABEL_OUTBOUND' }] }]
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 } }
function outboundTypeLabel(value?: string | null) { return value === 'RELABEL_OUTBOUND' ? '贴标出库' : value === 'DISCARD_OUTBOUND' ? '废弃出库' : value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' }
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
}
}
async function loadInventoryOptions() {
inventoryLoading.value = true; resourceErrors.value = []
try {
const [tenants, warehouses, fbaWarehouses] = await Promise.allSettled([
loadCustomerOptions(),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'FBA', status: 'ACTIVE' }),
])
if (tenants.status === 'fulfilled') tenantOptions.value = tenants.value
else resourceErrors.value.push(getApiErrorDetails(tenants.reason, '客户列表加载失败'))
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 resourceErrors.value.push(getApiErrorDetails(warehouses.reason, '海外仓列表加载失败'))
if (fbaWarehouses.status === 'fulfilled') fbaWarehouseOptionsSource.value = fbaWarehouses.value.records
else resourceErrors.value.push(getApiErrorDetails(fbaWarehouses.reason, 'FBA目的仓加载失败'))
} finally { inventoryLoading.value = false }
}
async function openCreate() { if (createOpen.value || saving.value || inventoryLoading.value || !access.can(permission.outboundCreate)) return; createOpen.value = true; createError.value = null; candidateError.value = null; recipient.value = ''; remark.value = ''; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnUnitType.value = 'BOX'; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; 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() }
async function loadDropshipCandidates() { candidateError.value = null; 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 = []; candidateError.value = getApiErrorDetails(cause, '一件代发订单加载失败') } finally { inventoryLoading.value = false } }
async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() }
async function selectCustomer() { candidateError.value = null; selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.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() }
async function changeOutboundType() { candidateError.value = null; selectedReturnNo.value = ''; selectedReturnMarks.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() }
async function loadTransferCandidates() {
candidateError.value = null; inventoryLoading.value = true
try {
const pallets = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined })
// The API returns pallet rows, but destination binding applies to the whole transfer.
const transfers = new Map<string, WarehouseTransferOutboundCandidate>()
for (const pallet of pallets) {
const key = transferSelectionKey(pallet)
const transfer = transfers.get(key)
if (transfer) {
transfer.packageCount += pallet.packageCount
transfer.grossWeightKg = sumTransferMeasurement(transfer.grossWeightKg, pallet.grossWeightKg)
transfer.volumeCbm = sumTransferMeasurement(transfer.volumeCbm, pallet.volumeCbm)
} else transfers.set(key, { ...pallet })
}
transferCandidates.value = [...transfers.values()]
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 = []
candidateError.value = getApiErrorDetails(cause, '无目的地仓储转运单加载失败')
} finally { inventoryLoading.value = false }
}
function transferSelectionKey(row: WarehouseTransferOutboundCandidate) { return `${row.customerId}:${row.transferNo}` }
function sumTransferMeasurement(left?: number | null, right?: number | null) {
return left == null && right == null ? null : Number(((left ?? 0) + (right ?? 0)).toFixed(6))
}
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) }
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())
}
async function loadReturnCandidates() { candidateError.value = null; 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); selectedReturnMarks.value = [] } catch (cause) { candidateError.value = getApiErrorDetails(cause, '客户库存加载失败') } 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) }
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))
}
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 }
function changeReturnUnitType() { selectedReturnMarks.value = [] }
function toggleReturnCarton(cartonMark: string, checked: boolean | string | number) {
selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, cartonMark])] : selectedReturnMarks.value.filter(value => value !== cartonMark)
}
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))
}
async function retryCreateCandidates() {
if (!createOpen.value || saving.value || inventoryLoading.value) return
if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates()
else if (outboundType.value === 'RETURN_OUTBOUND') await loadReturnCandidates()
else await loadTransferCandidates()
}
async function retryCreateResources() {
if (!createOpen.value || saving.value || inventoryLoading.value) return
await loadInventoryOptions(); await retryCreateCandidates()
}
async function submit() {
if (!createOpen.value || createLocked.value || candidateError.value) return
createError.value = null
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') {
const selected = selectedTransferRows.value.length ? selectedTransferRows.value : transferCandidates.value.length === 1 ? [transferCandidates.value[0]] : []
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') {
if (!recipient.value.trim()) { ElMessage.warning('请填写收件信息'); return }
if (!selectedReturnNo.value) { ElMessage.warning('请选择退货单'); return }
// Return outbound is physical-unit based. Partial SKU quantities must go
// through the PDA packing flow and produce a new labelled carton first.
if (!selectedReturnMarks.value.length) {
ElMessage.warning('请选择出库包裹/箱号或托盘')
return
}
await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, unitType: returnUnitType.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` })
ElMessage.success('退货出库单和待派送单已创建,作业已推送 PDA')
} 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) { createError.value = getApiErrorDetails(cause, '出库操作未完成') } finally { saving.value = false }
}
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.outboundType !== 'DISCARD_OUTBOUND' && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(row.status || '')) }
async function cancel(row: OutboundRow) {
if (!canCancel(row)) return
let reason = ''
const dispatchHint = row.dispatchNo ? '关联排车须在出库取消后再取消原排车,释放占用后才能重新预排。' : ''
try {
const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}。已关联车次请先解除关联;取消将申请撤销未履行的派送应收,装车、买约及其他费用保留待核对。${dispatchHint}`, '取消出库单', {
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)
const nextStep = !row.dispatchNo ? '' : access.can(permission.dispatchList)
? ',关联排车请点击“处理原排车”取消并释放占用,再从入仓单或换标单重新预排'
: ',请由有排车权限的人员取消原排车并释放占用,再从入仓单或换标单重新预排'
ElMessage.success(`出库单已取消;派送应收撤销结果请查看费用明细${nextStep}`)
await load()
} catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') }
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="出库单">
<OperationErrorToast :details="mutationError" />
<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>
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" :disabled="createOpen || saving || inventoryLoading" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
<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><el-button v-if="row.dispatchNo && access.can(permission.dispatchList)" class="density-action" data-tone="primary" size="small" @click="openDispatch(row as OutboundRow)">{{ row.status === 'CANCELLED' ? '处理原排车' : '查看排车' }}</el-button></div></template>
<template #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template>
<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>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1760px, calc(100vw - 32px))" fullscreenable :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving || inventoryLoading" :confirm-disabled="createLocked || Boolean(candidateError)" @confirm="submit">
<div class="density-dialog-sections">
<LoadErrorState v-for="(details, index) in resourceErrors" :key="index" title="出库基础资料读取失败" :message="details.message" :loading="inventoryLoading || saving" @retry="retryCreateResources"><template #details><ApiErrorMeta :details="details" /></template></LoadErrorState>
<LoadErrorState v-if="candidateError" title="出库候选读取失败" :message="candidateError.message" :loading="inventoryLoading || saving" @retry="retryCreateCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState>
<LoadErrorState v-if="createError" title="出库操作未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
<el-alert v-if="!access.can(permission.outboundCreate)" title="当前账号没有新建出库权限。" type="warning" :closable="false" show-icon />
<el-form class="create-outbound-form density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="createLocked">
<section class="density-dialog-section" data-tone="work" aria-label="出库模式"><h3 class="density-dialog-section__title">出库类型</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><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>
<el-radio-button label="WAREHOUSE_TRANSFER_OUTBOUND">仓储转运出库</el-radio-button>
</el-radio-group>
</el-form-item></td></tr></tbody></table></section>
<template v-if="outboundType === 'ONE_CLICK_DROPSHIP'">
<section class="density-dialog-section" data-tone="work" aria-label="一件代发出库范围"><h3 class="density-dialog-section__title">出库范围</h3><table class="density-table density-table--editable outbound-editor-table dropship-search-row"><tbody><tr><td><el-form-item label="海外仓" required>
<el-select :disabled="createLocked" v-model="selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse">
<el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item></td><td><el-form-item label="一件代发订单">
<div class="candidate-query">
<el-input v-model="dropshipQueryInput" :prefix-icon="Search" clearable aria-label="搜索可出库订单" placeholder="订单号 / 入库单号 / 客户 / 托盘 / SKU / 品名" @keyup.enter="searchDropshipCandidates" />
<el-button type="primary" :loading="inventoryLoading" @click="searchDropshipCandidates">查询</el-button>
<el-button @click="resetDropshipCandidates">重置</el-button>
</div>
</el-form-item></td></tr></tbody></table></section>
<section class="candidate-results density-dialog-section" data-tone="source" aria-label="已完成入库的一件代发订单"><h3 class="density-dialog-section__title">已完成入库的订单</h3>
<el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table">
<el-table-column label="选择" width="68" align="center" fixed="left">
<template #header><el-checkbox aria-label="全选本页订单" :model-value="allPageSelected" :indeterminate="somePageSelected" :disabled="createLocked || Boolean(candidateError) || !pagedDropshipCandidates.length" @change="selectDropshipRows(pagedDropshipCandidates, $event)" /></template>
<template #default="{ row }"><el-checkbox :disabled="createLocked || Boolean(candidateError)" :aria-label="`选择 ${row.orderNo || '订单'} / ${row.palletNo || '托盘'} / ${row.skuCode || 'SKU'}`" :model-value="isDropshipSelected(row as OneClickOutboundCandidate)" @change="toggleDropshipCandidate(row as OneClickOutboundCandidate, $event)" /></template>
</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" />
<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" />
</el-table>
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="density-dialog-note">暂无可直接出库的已完成入库订单</div>
<div class="candidate-pagination">
<span aria-live="polite" title="翻页保留选择">已选择 {{ selectedDropshipIds.length }} 条 · 第 {{ dropshipPage }} / {{ Math.max(1, Math.ceil(filteredDropshipCandidates.length / dropshipPageSize)) }} 页</span>
<el-pagination :disabled="createLocked" 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" />
</div>
<el-alert v-if="selectedDropshipCustomerCount > 1" title="当前选择包含多个客户,请按客户搜索后选择;一张出库单只能属于同一客户。" type="warning" :closable="false" />
<p v-else class="density-dialog-note">按托盘与 SKU 选择,默认出库全部可用箱数;仅限同一客户、同一海外仓,翻页保留选择。</p>
</section>
</template>
<template v-else-if="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND'">
<el-alert title="仅展示已完成入库、换标已完成或无需换标,并且尚未设置目的地的仓储转运单。绑定后将从此列表移除并进入智能排车候选。" type="info" :closable="false" />
<section class="density-dialog-section" data-tone="source" aria-label="无目的地转运单"><h3 class="density-dialog-section__title">无目的地转运单</h3><el-table v-loading="inventoryLoading" :data="transferCandidates" border stripe class="dropship-candidate-table">
<el-table-column label="选择" width="68" align="center" fixed="left"><template #default="{ row }"><el-checkbox :disabled="createLocked || Boolean(candidateError)" :aria-label="'选择转运单 ' + row.transferNo" :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>
<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>
</el-table><p v-if="!inventoryLoading && !transferCandidates.length" class="density-dialog-note">暂无已入库且尚无目的地的仓储转运单</p></section>
<section class="density-dialog-section" data-tone="work" aria-label="转运目的地登记"><h3 class="density-dialog-section__title">目的地</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><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></td></tr><tr v-if="destinationType === 'FBA'"><td colspan="2"><el-form-item 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></td></tr><template v-else><tr><td><el-form-item label="私人仓地址" required><el-input v-model="deliveryAddress" placeholder="请输入完整派送地址" /></el-form-item></td><td><el-form-item label="邮编" required><el-input v-model="postalCode" maxlength="32" placeholder="请输入邮编" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></template></tbody></table></section>
</template>
<template v-else>
<el-alert title="整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。" type="info" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="退货出库资料"><h3 class="density-dialog-section__title">退货出库资料</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td><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></td><td><el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="createLocked || !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></td></tr><tr><td><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item></td><td><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></td></tr><tr><td colspan="2"><el-form-item 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></td></tr></tbody></table></section>
<section v-if="returnUnitType === 'PALLET' && returnPallets.length" class="density-dialog-section" data-tone="work" aria-label="整托选择"><h3 class="density-dialog-section__title">整托选择</h3><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :disabled="createLocked || Boolean(candidateError)" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></section>
<div class="return-lists">
<section class="return-list-card density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">SKU库存</h3>
<div class="candidate-query"><el-input v-model="returnSkuQueryInput" clearable placeholder="如:SKU001,SKU002" /><el-button type="primary" @click="searchReturnSkus">查询</el-button></div>
<el-table :data="pagedReturnStocks" border stripe class="return-result-table"><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="可用数量" width="110" align="right"><template #default="{ row }">{{ row.availableQuantity }}</template></el-table-column></el-table>
<div class="return-pagination"><span>SKU 库存</span><el-pagination :disabled="createLocked" 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>
</section>
<section class="return-list-card density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">包裹 / 箱号列表</h3><p class="density-dialog-note">支持多个箱号或 SKU,用英文逗号分隔。</p>
<div class="candidate-query"><el-input v-model="returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons">查询</el-button></div>
<el-table :data="pagedReturnCartons" border stripe class="return-result-table"><el-table-column label="选择" width="60" align="center" fixed="left"><template #default="{ row }"><el-checkbox :disabled="createLocked || Boolean(candidateError)" :aria-label="'选择箱号 ' + row.cartonMark" :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="toggleReturnCarton(row.cartonMark, $event)" /></template></el-table-column><el-table-column label="包裹 / 箱号" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.cartonMark }}</template></el-table-column><el-table-column label="托盘" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '无需托盘' : returnCandidateByMark[row.cartonMark]?.palletCode || '散箱' }}</template></el-table-column><el-table-column label="包裹 / 箱内 SKU 及数量" min-width="230" show-overflow-tooltip><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个包裹 / 箱</span><el-pagination :disabled="createLocked" 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>
<section class="density-dialog-section" data-tone="work" aria-label="本次出库备注"><h3 class="density-dialog-section__title">备注</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="备注"><el-input v-model="remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" /></el-form-item></td></tr></tbody></table></section>
</el-form>
</div>
</AppDialog>
</template>
<style scoped>
.outbound-editor-table { table-layout: fixed; }
.outbound-editor-table td:not([colspan]) { width: 50%; }
.candidate-query { display: flex; width: 100%; min-width: 0; gap: var(--admin-space-3); }
.candidate-query > .el-input { flex: 1; min-width: 0; }
.candidate-query > .el-button { flex: 0 0 auto; }
.candidate-results { min-width: 0; }
.candidate-pagination, .return-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: var(--admin-space-4); flex-wrap: wrap; padding-top: var(--admin-space-4); }
.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 128px; }
.candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; }
.candidate-pagination :deep(.el-pagination__jump .el-input) { width: 56px; }
.return-lists { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: var(--admin-space-5); width: 100%; }
.return-pallet-options { display: flex; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-4); }
.return-list-card { min-width: 0; margin: 0; }
.return-result-table { width: 100%; margin-top: var(--admin-space-4); }
@media (max-width: 980px) { .return-lists { grid-template-columns: 1fr; }.return-pagination { align-items: flex-start; flex-direction: column; } }
</style>