Files
admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
T

916 lines
38 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 { physicalWeightLb, totalPhysicalWeightLb } from '../dispatchPhysicalWeight'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { GripVertical, Plus } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import { formatOverseasDateTime } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs'
import OrderReferenceText from './OrderReferenceText.vue'
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
interface LoadingContainer {
id: string
capacity: number
maxWeightLb: number
vehicleLabel: string
palletIds: string[]
}
const props = defineProps<{
modelValue: boolean
orders: InboundOrderListItem[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: []
}>()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const draftError = ref<ApiErrorDetails | null>(null)
const draftLoading = ref(false)
const draftLoaded = ref(false)
const draftBlocked = computed(() => draftLoading.value || !draftLoaded.value || Boolean(draftError.value))
const mutationError = ref<ApiErrorDetails | null>(null)
const candidates = ref<DispatchPalletCandidateDto[]>([])
const palletSnapshots = ref<Record<string, DispatchPalletCandidateDto>>({})
const activeOrderKey = ref('')
const containers = ref<LoadingContainer[]>([])
const busyPalletIds = ref<string[]>([])
const draftToken = ref('')
const vehicleDialogOpen = ref(false)
const selectedVehicle = ref('')
const query = reactive({
orderNo: '',
orderType: '',
sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED' | 'RELABEL',
fbaWarehouseCode: '',
})
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
const orderTypeOptions = [
{ value: 'FIRST_LEG', label: '头程订单' },
{ value: 'RELABEL_APPLICATION', label: '换标下单' },
{ value: 'RETURN_RELABEL', label: '退货换标' },
{ value: 'LOCAL_LOOSEBOARD', label: '本土散板' },
{ value: 'WAREHOUSE_TRANSFER', label: '仓储转运' },
]
const planIds = computed(() => [...new Set(props.orders.map((item) => item.planId).filter(Boolean))])
const visibleCandidates = computed(() =>
candidates.value.filter((item) => {
const typeMatches = !query.orderType || String(item.orderType || '') === query.orderType
if (!typeMatches) return false
return matchesSmartDispatchWarehouseTab(item, warehouseTab.value)
}),
)
const orderGroups = computed(() => {
const groups = new Map<string, DispatchPalletCandidateDto[]>()
for (const item of visibleCandidates.value) {
const key = item.sourceStage === 'RELABEL' ? `RELABEL:${String(item.orderRefs || '')}` : String(item.orderId || item.orderNo || item.planId)
groups.set(key, [...(groups.get(key) || []), item])
}
return [...groups.entries()].map(([key, pallets]) => ({
key,
first: pallets[0]!,
pallets,
}))
})
const activePallets = computed(() => orderGroups.value.find((item) => item.key === activeOrderKey.value)?.pallets || [])
const assignedIds = computed(() => containers.value.flatMap((item) => item.palletIds))
const unresolvedAssignedIds = computed(() => assignedIds.value.filter(id => !palletSnapshot(id)))
const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)).filter((item): item is DispatchPalletCandidateDto => Boolean(item)))
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const selectedVehicleSpec = computed(() => dispatchVehicleSpecs.find((item) => item.value === selectedVehicle.value))
const weightLb = (item?: DispatchPalletCandidateDto) => physicalWeightLb(item?.grossWeightKg)
const orderWeightLb = (pallets: DispatchPalletCandidateDto[]) => totalPhysicalWeightLb(pallets)
const containerWeightLb = (container: LoadingContainer) => totalPhysicalWeightLb(container.palletIds.map(id => palletSnapshot(id)))
const formatWeightLb = (weight: number | null) => weight === null ? '待确认' : `${number(weight, 0)}LB`
const containerOverweight = (container: LoadingContainer) => {
const weight = containerWeightLb(container)
return weight !== null && weight > container.maxWeightLb * 1.1
}
function palletId(item: DispatchPalletCandidateDto) {
if (item.sourceStage === 'RELABEL') return String(item.warehousePalletId || '')
if (item.sourceType === 'RETURN_RECEIPT') return item.returnReceiptId ? `RETURN:${item.returnReceiptId}` : ''
return String(item.planPalletId || item.warehousePalletId || '')
}
function text(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized || '—'
}
function number(value: unknown, digits = 0) {
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized.toFixed(digits) : '0'
}
function dateTime(value: unknown) {
return formatOverseasDateTime(value)
}
function privateWarehouse(item: DispatchPalletCandidateDto) {
if (item.destinationType !== 'PRIVATE') return '—'
return [item.deliveryAddress, item.postalCode].map((value) => String(value || '').trim()).filter(Boolean).join(' · ') || '—'
}
function orderType(value: unknown) {
return (
(
{
FIRST_LEG: '头程订单',
RELABEL_APPLICATION: '换标下单',
RETURN_RELABEL: '退货换标',
LOCAL_LOOSEBOARD: '本土散板',
WAREHOUSE_TRANSFER: '仓储转运',
ONE_CLICK_DROPSHIP: '一件代发',
REJECTED: '拒收订单',
EXPRESS: '快递订单',
} as Record<string, string>
)[String(value || '')] || text(value)
)
}
function relabelCartonMarks(item: DispatchPalletCandidateDto) {
return String(item.relabelCartonMarks || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean)
}
function containerFor(id: string) {
return containers.value.find((item) => item.palletIds.includes(id))
}
function palletSnapshot(id: string) {
return palletSnapshots.value[id]
}
function makeDraftToken() {
return globalThis.crypto?.randomUUID?.() || `dispatch-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function ensureDraftToken() {
if (!draftToken.value.trim()) draftToken.value = makeDraftToken()
return draftToken.value
}
function rememberPallets(items: DispatchPalletCandidateDto[]) {
palletSnapshots.value = {
...palletSnapshots.value,
...Object.fromEntries(items.map((item) => [palletId(item), item])),
}
}
async function hydrateDraftPallets() {
const restored = await listDispatchCandidates({
draftToken: ensureDraftToken(),
})
rememberPallets(restored)
}
async function load() {
if (draftBlocked.value) return
loading.value = true
loadError.value = null
try {
candidates.value = await listDispatchCandidates({
planIds: query.sourceStage !== 'RELABEL' && planIds.value.length ? planIds.value.join(',') : undefined,
orderNo: query.orderNo.trim() || undefined,
fbaWarehouseCode: query.fbaWarehouseCode.trim() || undefined,
sourceStage: query.sourceStage || undefined,
draftToken: ensureDraftToken(),
})
rememberPallets(candidates.value)
if (unresolvedAssignedIds.value.length) await hydrateDraftPallets()
if (unresolvedAssignedIds.value.length) throw new Error('草稿中部分已选来源尚未恢复,请重试加载;原选择已保留')
activeOrderKey.value = orderGroups.value[0]?.key || ''
} catch (error) {
candidates.value = []
activeOrderKey.value = ''
loadError.value = getApiErrorDetails(error, '可排车入仓单加载失败,请重试')
} finally {
loading.value = false
}
}
function reset() {
query.orderNo = ''
query.orderType = ''
query.sourceStage = 'RECEIVED'
query.fbaWarehouseCode = ''
warehouseTab.value = 'ALL'
void load()
}
function addContainer() {
if (saving.value || draftBlocked.value) return
selectedVehicle.value = ''
vehicleDialogOpen.value = true
}
async function confirmAddContainer() {
if (saving.value || draftBlocked.value) return
const spec = selectedVehicleSpec.value
if (!spec) {
ElMessage.warning('请选择车型')
return
}
try {
const created = await createDispatchDraftContainer(ensureDraftToken(), spec.capacity)
containers.value.push({
id: created.id,
capacity: created.capacity,
maxWeightLb: spec.maxWeightLb,
vehicleLabel: spec.label,
palletIds: created.palletIds || [],
})
vehicleDialogOpen.value = false
} catch (error) {
mutationError.value = getApiErrorDetails(error, '添加装车车型失败,请重试')
}
}
async function movePallet(item: DispatchPalletCandidateDto, containerId: string) {
const id = palletId(item)
if (saving.value || draftBlocked.value || busyPalletIds.value.includes(id)) return
const target = containers.value.find((container) => container.id === containerId)
if (!target || target.palletIds.includes(id)) return
if (!String(item.overseasWarehouseId || '').trim()) {
ElMessage.warning('该托盘尚未关联海外仓,不能排车')
return
}
if (containerFor(id)) {
ElMessage.warning('该托盘已在其他装车托盘中')
return
}
if (target.palletIds.length >= target.capacity) {
ElMessage.warning('该装车托盘已达到设置的托数')
return
}
const currentWeight = containerWeightLb(target), incomingWeight = weightLb(item)
if (currentWeight === null || incomingWeight === null) ElMessage.warning('存在未确认的实际毛重,请在正式配载前补齐;材积重不能用于载重校验')
else {
const nextWeight = currentWeight + incomingWeight
if (nextWeight > target.maxWeightLb * 1.1) ElMessage.error(`${target.vehicleLabel}重量将达到 ${nextWeight.toFixed(0)}LB,已超过限重10%,请注意调整`)
else if (nextWeight > target.maxWeightLb) ElMessage.warning(`${target.vehicleLabel}当前重量 ${nextWeight.toFixed(0)}LB,已超过额定限重 ${target.maxWeightLb}LB`)
}
const anchor = assigned.value[0]
if (anchor?.customerId && anchor.customerId !== item.customerId) {
ElMessage.warning('一次排车只能装同一客户的托盘')
return
}
if (anchor?.overseasWarehouseId && anchor.overseasWarehouseId !== item.overseasWarehouseId) {
ElMessage.warning('一次排车只能装同一海外仓的托盘')
return
}
busyPalletIds.value.push(id)
mutationError.value = null
try {
if (item.sourceStage !== 'RELABEL') await lockDispatchDraftPallet(id, ensureDraftToken(), target.id)
target.palletIds.push(id)
} catch (error) {
mutationError.value = getApiErrorDetails(error, '托盘锁定失败,请刷新后重试')
} finally {
busyPalletIds.value = busyPalletIds.value.filter((value) => value !== id)
}
}
async function removePallet(id: string) {
if (saving.value || draftBlocked.value || busyPalletIds.value.includes(id)) return
const owner = containerFor(id)
if (!owner) return
busyPalletIds.value.push(id)
mutationError.value = null
try {
if (palletSnapshot(id)?.sourceStage !== 'RELABEL') await unlockDispatchDraftPallet(id, ensureDraftToken())
owner.palletIds = owner.palletIds.filter((value) => value !== id)
} catch (error) {
mutationError.value = getApiErrorDetails(error, '托盘解锁失败,请重试')
} finally {
busyPalletIds.value = busyPalletIds.value.filter((value) => value !== id)
}
}
async function deleteContainer(container: LoadingContainer) {
if (saving.value || draftBlocked.value) return
for (const id of [...container.palletIds]) {
await removePallet(id)
}
if (!container.palletIds.length) {
try {
await deleteDispatchDraftContainer(ensureDraftToken(), container.id)
containers.value = containers.value.filter((item) => item.id !== container.id)
} catch (error) {
mutationError.value = getApiErrorDetails(error, '删除装车车型失败,请重试')
}
}
}
function dragStart(event: DragEvent, item: DispatchPalletCandidateDto) {
event.dataTransfer?.setData('text/plain', palletId(item))
}
function dropInto(event: DragEvent, containerId: string) {
const id = event.dataTransfer?.getData('text/plain') || ''
const item = candidates.value.find((candidate) => palletId(candidate) === id)
if (item) void movePallet(item, containerId)
}
async function close(value = false) {
if (saving.value) return
emit('update:modelValue', value)
}
async function submit() {
if (saving.value) return
if (draftBlocked.value) {
ElMessage.warning('排车草稿尚未恢复,请先重试恢复草稿;原选择已保留')
return
}
if (busyPalletIds.value.length) {
ElMessage.warning('托盘来源正在锁定,请稍后再预排车')
return
}
if (loading.value || loadError.value || unresolvedAssignedIds.value.length) {
ElMessage.warning('草稿中部分已选来源尚未恢复,请重试加载后再预排;原选择已保留')
return
}
if (!assignedIds.value.length) {
ElMessage.warning('请至少移入一个托盘')
return
}
const selectedContainers = containers.value.filter(container => container.palletIds.length > 0)
if (new Set(assignedIds.value).size !== assignedIds.value.length
|| selectedContainers.some(container => !container.id.trim())
|| new Set(selectedContainers.map(container => container.id)).size !== selectedContainers.length) {
ElMessage.warning('草稿中存在重复托盘或无效装车容器,请核对分组;原草稿已保留')
return
}
if (assigned.value.some(item => item.sourceStage === 'RELABEL'
? !String(item.warehousePalletId || '').trim() : item.sourceType === 'RETURN_RECEIPT'
? !String(item.returnReceiptId || '').trim() : !String(item.planPalletId || '').trim())) {
ElMessage.warning('所选托盘来源标识缺失,请重新加载核对;原草稿已保留')
return
}
if (assigned.value.some((item) => !String(item.overseasWarehouseId || '').trim())) {
ElMessage.warning('所选托盘中存在尚未关联海外仓的托盘')
return
}
const relabelOrders = [...new Set(assigned.value.filter((item) => item.sourceStage === 'RELABEL')
.map((item) => String(item.orderRefs || '').trim()).filter(Boolean))]
if (assigned.value.some((item) => item.sourceStage === 'RELABEL' && !String(item.orderRefs || '').trim())) {
ElMessage.warning('换标来源单号缺失,请刷新后重新选择')
return
}
if (relabelOrders.length && (relabelOrders.length !== 1 || assigned.value.some((item) => item.sourceStage !== 'RELABEL'))) {
ElMessage.warning('一张换标单的托盘需要单独排车')
return
}
if (relabelOrders.length && selectedContainers.length !== 1) {
ElMessage.warning('一张换标单仅支持一组排车,请将全部托盘移入同一个装车容器')
return
}
saving.value = true
mutationError.value = null
try {
if (relabelOrders.length) {
const latest = await listDispatchCandidates({ sourceStage: 'RELABEL', orderNo: relabelOrders[0], draftToken: ensureDraftToken() })
const required = latest.filter(item => item.sourceStage === 'RELABEL' && String(item.orderRefs || '').trim() === relabelOrders[0])
const selected = new Set(assignedIds.value)
if (!required.length || required.length !== selected.size || required.some(item => !selected.has(palletId(item)))) {
mutationError.value = { message: '换标必须一次选择本申请全部应排托盘,并放入同一装车容器;候选可能已变化,请重新查询核对,原选择已保留' }
return
}
await preArrangeDispatch({
planPalletIds: assigned.value.map(palletId),
returnReceiptIds: [],
relabelNo: relabelOrders[0],
})
} else {
const groups = selectedContainers.map(container => {
const sources = container.palletIds.map(id => palletSnapshot(id)!)
return {
draftContainerId: container.id,
planPalletIds: sources.filter(item => item.sourceType !== 'RETURN_RECEIPT').map(item => String(item.planPalletId)),
returnReceiptIds: sources.filter(item => item.sourceType === 'RETURN_RECEIPT').map(item => String(item.returnReceiptId)),
}
})
const results = await preArrangeDraftDispatch({ draftToken: ensureDraftToken(), groups })
const expectedIds = new Set(groups.map(group => group.draftContainerId))
if (!Array.isArray(results) || results.length !== groups.length
|| results.some(result => !result || typeof result.draftContainerId !== 'string'
|| typeof result.dispatch?.dispatchNo !== 'string' || !result.dispatch.dispatchNo.trim())
|| new Set(results.map(result => result.draftContainerId)).size !== groups.length
|| results.some(result => !expectedIds.has(result.draftContainerId))
|| new Set(results.map(result => result.dispatch.dispatchNo)).size !== groups.length) {
throw new Error('预排车返回的分组结果与原草稿不一致,原草稿已保留,请核对保存结果')
}
}
emit('update:modelValue', false)
emit('created')
draftLoaded.value = false
draftToken.value = ''
containers.value = []
palletSnapshots.value = {}
ElMessage.success('排车明细已同步到预排列表')
await router.push({ name: 'overseas-dispatches' })
} catch (error) {
mutationError.value = getApiErrorDetails(error, '预排车创建失败,请重试')
} finally {
saving.value = false
}
}
async function restoreDraft() {
if (draftLoading.value) return
draftLoading.value = true
draftError.value = null
try {
const drafts = await listDispatchDrafts()
const draft = draftToken.value ? drafts.find(item => item.draftToken === draftToken.value) : drafts[0]
if (draftToken.value && containers.value.length && (!draft || containers.value.some(container =>
container.palletIds.some(id => palletSnapshot(id)?.sourceStage === 'RELABEL') && !draft.containers?.some(item => item.id === container.id)))) {
draftError.value = { message: '原草稿已失效或装车容器已变化,原选择已保留。请重试恢复;仍无法恢复时可释放草稿后重新选择。' }
return
}
if (draft?.draftToken) {
const restoredIds = new Set((draft.containers || []).flatMap(container => container.palletIds || []))
if (assignedIds.value.some(id => palletSnapshot(id)?.sourceStage !== 'RELABEL' && !restoredIds.has(id))) {
ElMessage.warning('原草稿部分来源锁已释放,已按服务端当前草稿恢复,请重新选择这些来源')
}
draftToken.value = draft.draftToken
containers.value = (draft.containers || []).map((c) => {
const spec = dispatchVehicleSpecs.find((item) => item.capacity === c.capacity) || dispatchVehicleSpecs[0]!
const localRelabel = containers.value.find(item => item.id === c.id)?.palletIds.filter(id => palletSnapshot(id)?.sourceStage === 'RELABEL') || []
return { id: c.id, capacity: c.capacity, maxWeightLb: spec.maxWeightLb, vehicleLabel: spec.label, palletIds: [...new Set([...(c.palletIds || []), ...localRelabel])] }
})
} else {
ensureDraftToken()
}
draftLoaded.value = true
} catch (error) {
draftError.value = getApiErrorDetails(error, '排车草稿恢复失败,原选择已保留,请重试恢复后继续')
} finally {
draftLoading.value = false
}
if (draftLoaded.value && !draftError.value) await load()
}
async function restartDraft() {
if (draftLoading.value || saving.value || !draftToken.value) return
try {
await ElMessageBox.confirm('释放原草稿的普通及退回来源锁,并清空本页换标选择后重新开始?', '重新选择排车来源', { customClass: 'admin-density-confirm', confirmButtonText: '释放并重新选择', cancelButtonText: '保留草稿', type: 'warning' })
} catch { return }
draftLoading.value = true
try {
await releaseDispatchDraft(draftToken.value)
draftToken.value = ''
containers.value = []
palletSnapshots.value = {}
draftLoaded.value = false
draftError.value = null
} catch (error) {
draftError.value = getApiErrorDetails(error, '释放草稿失败,原选择已保留,请重试')
} finally { draftLoading.value = false }
if (!draftError.value) await restoreDraft()
}
watch(
() => props.modelValue,
(open) => {
if (!open) return
query.orderNo = ''
query.orderType = ''
query.sourceStage = 'RECEIVED'
query.fbaWarehouseCode = ''
warehouseTab.value = 'ALL'
mutationError.value = null
draftLoaded.value = false
void restoreDraft()
},
{ immediate: true },
)
watch([warehouseTab, () => query.orderType], () => {
activeOrderKey.value = orderGroups.value[0]?.key || ''
})
</script>
<template>
<OperationErrorToast :details="mutationError" />
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" @update:model-value="close($event)">
<section class="dispatch-query" aria-label="智能排车筛选">
<el-input v-model="query.orderNo" clearable :placeholder="query.sourceStage === 'RELABEL' ? '换标单号 / 原入仓码' : '订单号 / 换标单号 / 原入仓码'" @keyup.enter="load" />
<el-select v-model="query.orderType" clearable placeholder="全部订单类型"><el-option v-for="option in orderTypeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select>
<el-select v-model="query.sourceStage" clearable placeholder="全部来源"><el-option label="已入库 / 待出库换标" value="RECEIVED" /><el-option label="待出库换标单" value="RELABEL" /><el-option label="计划托盘" value="PLAN" /></el-select>
<el-input v-model="query.fbaWarehouseCode" clearable placeholder="FBA仓库" @keyup.enter="load" />
<el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button :disabled="loading" @click="reset">重置</el-button>
</section>
<p v-if="query.sourceStage === 'RELABEL'" class="dispatch-source-hint">仅展示客户已确认、卡车派送且尚未排车的换标单。必须一次选择本申请全部应排托盘并放入同一装车容器。换标选择仅保留在当前页面,尚未锁定;刷新页面后需重新选择,提交时由服务端重新校验。</p>
<nav class="warehouse-tabs density-tabs" aria-label="海外仓筛选">
<button
v-for="tab in [
{ value: 'ALL', label: '全部' },
{ value: 'LAX', label: '洛杉矶' },
{ value: 'NYC', label: '纽约' },
]"
:key="tab.value"
type="button"
:class="['warehouse-tab', { active: warehouseTab === tab.value }]"
@click="warehouseTab = tab.value as SmartDispatchWarehouseTab"
>
{{ tab.label }}
</button>
</nav>
<LoadErrorState v-if="draftError" title="排车草稿恢复失败" :message="draftError.message" :loading="draftLoading" @retry="restoreDraft"><template #details><ApiErrorMeta :details="draftError" /></template></LoadErrorState>
<p v-else-if="draftLoading" role="status">正在恢复排车草稿,请稍候…</p>
<LoadErrorState v-else-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"
><template #details><ApiErrorMeta :details="loadError" /></template
></LoadErrorState>
<div v-else v-loading="draftLoading" class="dispatch-workbench">
<section class="order-pane" aria-label="可排车入仓单">
<header>
<div>
<h3>
出库单列表 <span>({{ orderGroups.length }}单)</span>
</h3>
<div class="order-type-tabs density-tabs">
<button v-for="option in [{ value: '', label: '全部' }, ...orderTypeOptions]" :key="option.value || 'all'" type="button" :class="{ active: query.orderType === option.value }" @click="query.orderType = option.value">
{{ option.label }}
</button>
</div>
</div>
</header>
<div v-loading="loading" class="order-table-wrap">
<el-table :data="orderGroups" border height="520" highlight-current-row row-key="key" @current-change="activeOrderKey = $event?.key || ''">
<el-table-column label="订单号" min-width="170"
><template #default="{ row }"><OrderReferenceText :value="text(row.first.orderNo)" /></template
></el-table-column>
<el-table-column label="换标单号" min-width="180"
><template #default="{ row }">{{ row.first.sourceStage === 'RELABEL' ? text(row.first.orderRefs) : '—' }}</template
></el-table-column>
<el-table-column label="原入仓码" min-width="150"
><template #default="{ row }">{{ text(row.first.inboundCode) }}</template
></el-table-column>
<el-table-column label="订单类型" width="100"
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
>
<el-table-column label="重量(lb)" width="100" align="right"
><template #default="{ row }">{{ formatWeightLb(orderWeightLb(row.pallets)) }}</template></el-table-column
>
<el-table-column label="托盘数量" width="90" align="right"
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
>
<el-table-column label="目的地FBA" width="110"
><template #default="{ row }">{{ text(row.first.fbaWarehouseCode) }}</template></el-table-column
>
<el-table-column label="私人仓" min-width="220" show-overflow-tooltip
><template #default="{ row }">{{ privateWarehouse(row.first) }}</template></el-table-column
>
<el-table-column label="体积(m³)" width="100" align="right"
><template #default="{ row }">{{
number(
row.pallets.reduce((sum: number, item: DispatchPalletCandidateDto) => sum + Number(item.volumeCbm || 0), 0),
2,
)
}}</template></el-table-column
>
<el-table-column label="货柜号" min-width="125"
><template #default="{ row }">{{ text(row.first.containerNo) }}</template></el-table-column
>
<el-table-column label="预约送仓时间" min-width="145"
><template #default="{ row }">{{ dateTime(row.first.appointmentAt) }}</template></el-table-column
>
<el-table-column label="预计装车时间" min-width="145"
><template #default="{ row }">{{ dateTime(row.first.expectedLoadingAt) }}</template></el-table-column
>
</el-table>
</div>
</section>
<section class="detail-pane" aria-label="入仓单托盘和箱号详情">
<header>
<h3>
托盘明细
<span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span>
</h3>
<span class="detail-order-reference">订单号:<OrderReferenceText :value="text(activePallets[0]?.orderNo)" /></span>
</header>
<div class="pallet-list">
<div v-for="item in activePallets" :key="palletId(item)" class="pallet-row" :class="{ assigned: containerFor(palletId(item)) }" draggable="true" @dragstart="dragStart($event, item)">
<GripVertical :size="16" class="drag-handle" />
<div class="pallet-copy">
<strong>{{ text(item.palletNo) }}</strong
><span>{{ Number(item.packageCount || 0) }}箱 · {{ formatWeightLb(weightLb(item)) }}</span>
<span v-if="item.sourceType === 'RETURN_RECEIPT'">退回实收:{{ text(item.returnReceiptNo) }}</span>
<div v-if="relabelCartonMarks(item).length" class="carton-marks">
<div v-for="mark in relabelCartonMarks(item)" :key="`relabel-${mark}`" class="carton-mark-line">
<span>{{ mark }}</span
><el-tag size="small" type="warning">换标</el-tag>
</div>
</div>
</div>
<el-tag v-if="containerFor(palletId(item))" :type="item.sourceStage === 'RELABEL' ? 'warning' : 'success'" size="small">{{ item.sourceStage === 'RELABEL' ? '已选择,尚未锁定' : '已锁定' }}</el-tag>
<el-dropdown v-else trigger="click" :disabled="!containers.length || busyPalletIds.includes(palletId(item))" @command="movePallet(item, String($event))">
<el-button class="density-action" data-tone="primary" :loading="busyPalletIds.includes(palletId(item))">移入 </el-button>
<template #dropdown
><el-dropdown-menu
><el-dropdown-item v-for="(container, index) in containers" :key="container.id" :command="container.id" :disabled="container.palletIds.length >= container.capacity">装车托盘 {{ index + 1 }}({{ container.palletIds.length }}/{{ container.capacity }})</el-dropdown-item></el-dropdown-menu
></template
>
</el-dropdown>
</div>
<el-empty v-if="!activePallets.length" :image-size="64" description="点击左侧入仓单查看托盘和箱号" />
</div>
</section>
<aside class="container-pane" aria-label="装车托盘容器">
<header>
<div>
<h3>装车托盘</h3>
<p>
托盘 {{ assigned.length }} · 箱数 {{ assignedPackages }} · 立方
{{ assignedVolume.toFixed(2) }}
</p>
</div>
<el-button type="primary" plain @click="addContainer"><Plus :size="15" />添加装车托盘</el-button>
</header>
<div class="container-list">
<div
v-for="(container, index) in containers"
:key="container.id"
class="loading-container"
:class="{
'loading-container--overweight': containerOverweight(container),
}"
@dragover.prevent
@drop.prevent="dropInto($event, container.id)"
>
<div class="container-head">
<strong>{{ container.vehicleLabel }}</strong
><span
>托数 <b>{{ container.palletIds.length }}</b
>/{{ container.capacity }} · 重量 <b>{{ formatWeightLb(containerWeightLb(container)) }}</b>/{{ container.maxWeightLb }}LB <em v-if="containerOverweight(container)">超限10%</em></span
><el-button class="density-action" data-tone="danger" :disabled="saving" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)">删除</el-button>
</div>
<div class="container-body">
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet">
<span
><strong>{{ text(palletSnapshot(id)?.palletNo || '托盘号待补充') }}</strong
><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span
><el-button class="density-action" data-tone="danger" :disabled="saving" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button>
</div>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘;换标选择提交时校验,其他来源移入后锁定</span>
</div>
</div>
<el-empty v-if="!containers.length" :image-size="64" description="请先添加装车托盘" />
</div>
</aside>
</div>
<template #footer><footer class="dialog-footer">
<span>普通/退回来源移入后锁定;换标选择尚未锁定,仅保留在当前页面。重量按 1kg = 2.2046lb 换算。</span>
<div><el-button v-if="draftError && draftToken" :disabled="draftLoading" @click="restartDraft">释放草稿并重新选择</el-button><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="draftBlocked || !assignedIds.length || busyPalletIds.length > 0" @click="submit">预排车</el-button></div>
</footer></template>
</AppDialog>
<el-dialog class="admin-density-dialog" v-model="vehicleDialogOpen" title="选择车型" width="360px" append-to-body>
<el-radio-group v-model="selectedVehicle" class="vehicle-options">
<el-radio v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.value">{{ spec.label }} · 限重 {{ spec.maxWeightLb }}LB</el-radio>
</el-radio-group>
<template #footer><el-button @click="vehicleDialogOpen = false">取消</el-button><el-button type="primary" @click="confirmAddContainer">确定</el-button></template>
</el-dialog>
</template>
<style scoped>
.dispatch-query {
display: flex;
align-items: center;
gap: var(--admin-space-5);
padding: var(--admin-space-5) var(--admin-space-5);
border: 1px solid #e6eaf2;
border-radius: var(--admin-radius);
background: #f7f8fb;
}
.dispatch-query :deep(.el-input),
.dispatch-query :deep(.el-select) {
width: 190px;
}
.warehouse-tabs {
margin-top: var(--admin-space-5);
}
.dispatch-workbench {
display: grid;
grid-template-columns: minmax(550px, 1.5fr) minmax(330px, 0.85fr) minmax(390px, 1fr);
gap: var(--admin-space-5);
margin-top: var(--admin-space-5);
}
.order-pane,
.detail-pane,
.container-pane {
min-width: 0;
}
.order-pane header,
.detail-pane header,
.container-pane header {
display: flex;
min-height: 74px;
align-items: center;
justify-content: space-between;
gap: var(--admin-space-4);
}
.detail-order-reference {
display: flex;
min-width: 0;
max-width: 240px;
align-items: baseline;
gap: 3px;
}
h3,
p {
margin: 0;
}
h3 {
font-size: var(--admin-font-section);
}
h3 span,
header > span,
p {
color: var(--cc-muted);
font-size: var(--admin-font-caption);
font-weight: 400;
}
.order-type-tabs {
margin-top: var(--admin-space-4);
}
.order-table-wrap {
min-height: 520px;
}
.detail-pane,
.container-pane {
border-left: 1px solid var(--cc-border);
padding-left: var(--admin-space-5);
}
.pallet-list,
.container-list {
height: 520px;
overflow-y: auto;
border: 1px solid var(--cc-border);
border-radius: var(--admin-radius);
background: #fafbfc;
}
.pallet-row:hover {
background: #f8f6ff;
}
.pallet-row.assigned {
background: #f2fbf6;
}
.drag-handle {
color: var(--cc-muted);
}
.pallet-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--admin-space-1);
}
.pallet-copy > span,
.carton-marks > span {
color: var(--cc-muted);
font-size: var(--admin-font-caption);
}
.carton-marks {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--admin-space-1);
}
.carton-mark-line {
display: flex;
min-width: 0;
align-items: center;
gap: var(--admin-space-2);
color: var(--cc-muted);
font-size: var(--admin-font-caption);
}
.carton-mark-line > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.container-pane > .el-input {
margin-bottom: var(--admin-space-4);
}
.container-list {
height: 460px;
padding: var(--admin-space-3);
}
.loading-container {
margin-bottom: var(--admin-space-4);
border: 1px dashed #bcc6d5;
border-radius: var(--admin-radius);
background: #fff;
}
.loading-container--overweight {
border-color: var(--el-color-danger);
}
.loading-container--overweight .container-head {
background: var(--el-color-danger-light-9);
}
.loading-container--overweight .container-head span,
.loading-container--overweight .container-head b {
color: var(--el-color-danger);
}
.container-head em {
margin-left: var(--admin-space-1);
color: var(--el-color-danger);
font-style: normal;
font-weight: 700;
}
.container-head span {
color: var(--cc-muted);
font-size: var(--admin-font-caption);
}
.container-head b {
color: var(--el-color-primary);
}
.container-body {
min-height: 76px;
padding: var(--admin-space-3);
}
.drop-hint {
display: grid;
min-height: 60px;
place-items: center;
color: var(--cc-muted);
font-size: var(--admin-font-caption);
}
.loaded-pallet {
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: var(--admin-space-3);
border-bottom: 1px solid var(--cc-border);
}
.loaded-pallet > span {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
color: var(--cc-muted);
font-size: var(--admin-font-caption);
}
.loaded-pallet strong {
color: var(--cc-text);
font-size: var(--admin-font-body);
}
.dialog-footer > span {
color: var(--cc-muted);
font-size: var(--admin-font-caption);
}
.vehicle-options {
display: grid;
gap: var(--admin-space-5);
}
@media (max-width: 1280px) {
.dispatch-workbench {
grid-template-columns: minmax(500px, 1.3fr) minmax(320px, 1fr);
}
.container-pane {
grid-column: 1 / -1;
border-left: 0;
padding-left: 0;
}
.container-list {
height: 300px;
}
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--admin-space-5); margin: 0; padding: 0;
border-top: 1px solid var(--cc-border); background: transparent; border: 0; text-align: left; }
.container-head {
display: grid; grid-template-columns: minmax(0, 1fr) auto auto;
min-height: 44px;
align-items: center;
gap: var(--admin-space-3);
padding: 0 var(--admin-space-3) 0 var(--admin-space-5);
background: #f4f6fa; }
.pallet-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto; min-height: var(--admin-empty-height);
align-items: center;
gap: var(--admin-space-3);
padding: var(--admin-space-4) var(--admin-space-5);
border-bottom: 1px solid var(--cc-border);
background: #fff;
cursor: grab;
transition: background 0.18s ease; }
.order-pane header, .detail-pane header, .container-pane header { min-height: 60px; }
</style>