Files
admin-web/src/modules/overseas-warehouse/pages/OutboundsView.vue
T
2026-09-17 13:36:31 +08:00

317 lines
42 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 } from 'vue'
import { Plus, Search } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { 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 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: '', 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 selectedReturnSkus = ref<string[]>([])
const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('')
const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('')
const returnSkuPage = ref(1); const returnCartonPage = ref(1)
const returnSkuPageSize = ref(5); const returnCartonPageSize = ref(5)
const returnCartonDetails = ref<Record<string, ReturnCartonDetail>>({})
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() || `客户 ${row.customerId}`
}
const selectedReturnStock = computed(() => returnStockRows.value.filter((item) => Number(item.availableQuantity || 0) > 0))
const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
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) => {
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[])
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 allFilteredSelected = computed(() => filteredDropshipCandidates.value.length > 0 && filteredDropshipCandidates.value.every(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))))
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: '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_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }, { 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' }] }]
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 === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' }
async function loadInventoryOptions() {
inventoryLoading.value = true
try {
const [tenants, warehouses, fbaWarehouses] = await Promise.allSettled([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
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.records
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)
if (fbaWarehouses.status === 'fulfilled') fbaWarehouseOptionsSource.value = fbaWarehouses.value.records
else ElMessage.error(getApiErrorDetails(fbaWarehouses.reason, 'FBA目的仓加载失败').message)
} finally { inventoryLoading.value = false }
}
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() }
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() }
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() }
async function loadTransferCandidates() { inventoryLoading.value = true; try { transferCandidates.value = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined }) } catch (cause) { transferCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '无目的地仓储转运单加载失败').message) } finally { inventoryLoading.value = false } }
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) }
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) }
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 toggleReturnSku(sku: string, checked: boolean | string | number) { selectedReturnSkus.value = checked ? [...new Set([...selectedReturnSkus.value, sku])] : selectedReturnSkus.value.filter((value) => value !== sku) }
function toggleReturnCarton(cartonMark: string, checked: boolean | string | number) { selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, cartonMark])] : selectedReturnMarks.value.filter((value) => value !== cartonMark) }
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') {
const selected = selectedTransferRows.value
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() || !selectedReturnNo.value || !selectedReturnMarks.value.length) { ElMessage.warning('请填写收件信息并选择退货单和箱唛'); return }
await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.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) { ElMessage.error(getApiErrorDetails(cause, '出库单创建失败').message) } 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) && ['WAIT_OUTBOUND', 'WAIT_PICK', 'PICKED'].includes(String(row.status || '')) }
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, '取消出库单失败') } }
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" />
<template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable 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-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
<template #actions="{ row }"><div class="density-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>
<template #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template>
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template>
</DataTable></section>
</ListPageLayout></AdminPage>
<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">
<el-form class="create-outbound-form density-form" label-position="top">
<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>
<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>
<template v-if="outboundType === 'ONE_CLICK_DROPSHIP'">
<el-form-item label="海外仓" required>
<el-select 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>
<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>
<div class="candidate-selection" aria-label="订单批量选择">
<el-button :disabled="inventoryLoading || !filteredDropshipCandidates.length || allFilteredSelected" @click="selectDropshipRows(filteredDropshipCandidates, true)">全选筛选结果({{ filteredDropshipCandidates.length }})</el-button>
<el-button :disabled="!selectedDropshipIds.length" @click="selectedDropshipIds = []">清空选择</el-button>
<span aria-live="polite">已选择 {{ selectedDropshipIds.length }} 条,翻页保留选择</span>
</div>
<el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table" max-height="360">
<el-table-column label="选择" width="68" align="center">
<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>
</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>
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint">暂无可直接出库的已完成入库订单</div>
<div v-if="filteredDropshipCandidates.length" class="candidate-pagination">
<span>第 {{ dropshipPage }} / {{ Math.max(1, Math.ceil(filteredDropshipCandidates.length / dropshipPageSize)) }} 页</span>
<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" />
</div>
<el-alert v-if="selectedDropshipCustomerCount > 1" title="当前选择包含多个客户,请按客户搜索后选择;一张出库单只能属于同一客户。" type="warning" :closable="false" />
<el-alert v-else title="一件代发按托盘与SKU选择;数量默认使用所选行全部可用箱数,同一张出库单只能选择同一客户、同一海外仓。" type="info" :closable="false" />
</el-form-item>
</template>
<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>
</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>
<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>
<div class="return-lists">
<section class="return-list-card">
<div class="list-title"><span>SKU库存</span><small>支持多个 SKU,用英文逗号分隔</small></div>
<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" max-height="400"><el-table-column 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 prop="availableQuantity" label="可用数量" width="100" align="right" /></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnSkus.length }} 个 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>
</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>
<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="箱内SKU及数量" min-width="230"><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 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>
<el-alert title="退货出库支持按客户选择单个或多个箱子;SKU数量直接从所选箱唛明细扣减。" type="info" :closable="false" />
</template>
<el-form-item label="备注"><el-input v-model="remark" type="textarea" placeholder="请输入备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
</template>
<style scoped>
.create-outbound-form { padding: 0 var(--admin-space-1); }
.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%; }
.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: 300px; overflow: auto; padding: var(--admin-space-3) 0; }
.candidate-list :deep(.el-checkbox) { margin-right: 0; }
.candidate-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr) auto; gap: var(--admin-space-5); align-items: center; width: min(700px, 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); }
.candidate-query :deep(.el-input) { flex: 1; }
.candidate-query :deep(.el-button) { flex: 0 0 auto; min-width: 78px; margin-left: 0; }
.candidate-selection { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); width: 100%; margin-top: var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.candidate-selection :deep(.el-button + .el-button) { margin-left: 0; }
.dropship-candidate-table { margin-top: var(--admin-space-5); width: 100%; }
.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); }
.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 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, 1fr) minmax(0, 1fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); }
.return-list-card { min-width: 0; padding: var(--admin-space-5); border: 1px 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: 44px; 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); }
.return-pagination :deep(.el-pagination) { flex-wrap: wrap; justify-content: flex-end; }
@media (max-width: 980px) { .return-lists { grid-template-columns: 1fr; }.return-pagination { align-items: flex-start; flex-direction: column; } }
</style>