This commit is contained in:
2026-10-21 13:22:42 +08:00
parent eb0b0f85d1
commit 2333bd8bab
114 changed files with 1766 additions and 526 deletions
@@ -2,7 +2,6 @@
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ChevronRight } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
@@ -11,17 +10,17 @@ import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/mod
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import { useDispatchCandidates } from '../useDispatchCandidates'
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, Driver, DraftSelection, Vehicle, VersionCommand } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, DraftSelection, VersionCommand } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups } from '../dispatchContracts'
import { dispatchActionPermissions } from '../dispatchPresentation'
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
import type { InboundOrderListItem } from '../types'
import DispatchResourceFields from './DispatchResourceFields.vue'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
import type { SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
@@ -29,39 +28,65 @@ const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>
const router = useRouter(), auth = useAuthStore(), access = useAccessPreviewStore()
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}`
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[]; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
const pending = ref<DraftCommand | null>(null)
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
const draft = ref<DispatchDraft | null>(null), drafts = ref<DispatchDraft[]>([]), activeContainerId = ref(''), capacity = ref(30)
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
const working = ref(false), saving = ref(false), preparing = ref(false), error = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error)
const { candidates, page, size, total } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string }; vehicle: Vehicle; driver: Driver }>>({})
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string } }>>({})
const currentSettings = computed(() => {
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' }, vehicle: { mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' } }
return settings[key]!
})
const form = computed(() => currentSettings.value.form)
const vehicle = computed({ get: () => currentSettings.value.vehicle, set: (value: Vehicle) => { currentSettings.value.vehicle = value } })
const driver = computed({ get: () => currentSettings.value.driver, set: (value: Driver) => { currentSettings.value.driver = value } })
const frozen = computed(() => !canCreate.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
const currentContainer = computed(() => draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value))
const populatedGroupCount = computed(() => draft.value?.containers.filter(row => row.sourceKeys.length).length || 0)
const confirmDisabled = computed(() => loading.value || !canCreate.value || (!pending.value && !recovery.pending.value && (mode.value === 'empty' ? !warehouseId.value : !populatedGroupCount.value)))
const confirmDisabled = computed(() => preparing.value || loading.value || !canCreate.value || (!pending.value && !recovery.pending.value && (mode.value === 'empty' ? !warehouseId.value : !populatedGroupCount.value)))
function assignedGroup(row: Candidate) {
return draft.value?.containers.find(group => group.sourceKeys.some(key => row.selectionGroup.sources.some(source => source.sourceKey === key)))
}
async function startCargo() {
async function ensureFirstVehicle(sequence: number): Promise<string | null> {
if (!draft.value && !await draftAction('CREATE')) return null
if (sequence !== generation || !props.modelValue || !draft.value) return null
if (!draft.value.containers.length && !await draftAction('CONTAINER')) return null
if (sequence !== generation || !props.modelValue) return null
return currentContainer.value?.draftContainerId || null
}
async function addCargo(source: Candidate, targetContainerId = activeContainerId.value) {
if (frozen.value || loading.value || !warehouseId.value || assignedGroup(source)) return
const sequence = generation, target = targetContainerId
preparing.value = true
try {
requireSelectionGroup(source); candidatePage.assertCurrent([source])
const containerId = target || await ensureFirstVehicle(sequence)
if (!containerId || sequence !== generation || !props.modelValue) return
if (!draft.value?.containers.some(row => row.draftContainerId === containerId)) throw new DispatchInputError('所选车辆已变化,请重新选择')
activeContainerId.value = containerId
await draftAction('LOCK', source, containerId)
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '加入货物失败,请核对后重试') }
finally { preparing.value = false }
}
async function addVehicle() {
if (frozen.value || loading.value || !warehouseId.value) return
if (!draft.value) await draftAction('CREATE')
if (draft.value && !error.value && !pending.value && !draft.value.containers.length) await draftAction('CONTAINER')
const sequence = generation
preparing.value = true
try {
if (!await ensureFirstVehicle(sequence)) return
if (sequence === generation && props.modelValue) await draftAction('CONTAINER')
} finally { preparing.value = false }
}
function changeWarehouse() { candidatePage.reset(); warehouseTab.value = 'ALL'; void search() }
function changeTab(tab: SmartDispatchWarehouseTab) { warehouseTab.value = tab; void search() }
@@ -78,6 +103,10 @@ async function init() {
const sequence = ++generation
working.value = true; error.value = null
try {
if (recovery.pending.value && ['ADD_CARGO', 'REMOVE_CARGO'].includes(recovery.pending.value.action)) workspace.value = 'existing'
else if (pending.value || recovery.pending.value?.action === 'CREATE') workspace.value = 'create'
else if (!canCreate.value && canEditCargo.value) workspace.value = 'existing'
if (workspace.value === 'existing' || !canCreate.value) return
if (recovery.pending.value?.action === 'CREATE') { mode.value = 'empty'; warehouseId.value = String(recovery.pending.value.body.warehouseId || '') }
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
if (sequence !== generation) return
@@ -92,12 +121,20 @@ async function init() {
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
finally { if (sequence === generation) working.value = false }
}
function switchWorkspace(value: 'create' | 'existing') {
if (workspaceFrozen.value || value === workspace.value || (value === 'create' ? !canCreate.value : !canEditCargo.value)) return
workspace.value = value
existingBusy.value = false
candidatePage.reset()
void init()
}
async function loadCandidates(sequence = generation) {
if (sequence !== generation) return
const filterKey = JSON.stringify([warehouseId.value, query, warehouseTab.value, planIds.value, draft.value?.draftToken])
if (filterKey !== candidateFilterKey) { page.value = 1; candidateFilterKey = filterKey }
await candidatePage.load({ warehouseId: warehouseId.value, page: page.value, size: size.value, warehouseTab: warehouseTab.value, orderNo: query.orderNo || undefined, destinationWarehouseCode: query.destinationWarehouseCode || undefined, sourceStage: query.sourceStage || undefined, ...(query.sourceStage !== 'RELABEL' && planIds.value.length ? { planIds: planIds.value } : {}), draftToken: draft.value?.draftToken })
}
function resetSearch() { if (frozen.value) return; query.orderNo = ''; query.destinationWarehouseCode = ''; query.sourceStage = ''; warehouseTab.value = 'ALL'; void search() }
async function search() { if (frozen.value) return; page.value = 1; await loadCandidates() }
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
@@ -105,10 +142,10 @@ function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
const config = containerId ? settings[containerId] : currentSettings.value
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: config && (config.vehicle.vehicleId || config.vehicle.plate) ? JSON.parse(JSON.stringify(config.vehicle)) as Vehicle : null, driver: config && (config.driver.driverId || config.driver.name) ? JSON.parse(JSON.stringify(config.driver)) as Driver : null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
}
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string) {
if (!canCreate.value || loading.value || saving.value) return
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
if (!canCreate.value || loading.value || saving.value) return false
error.value = null
try {
if (!pending.value) {
@@ -133,10 +170,11 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
body = { ...common, groups } satisfies ConvertDraft
}
}
pending.value = { action, body: JSON.parse(JSON.stringify(body)) as Record<string, unknown>, containerId, attempted: false, rejected: false }; persist()
pending.value = { action, body: JSON.parse(JSON.stringify(body)) as Record<string, unknown>, containerId, ...(action === 'CONTAINER' ? { priorContainerIds: current!.containers.map(row => row.draftContainerId) } : {}), attempted: false, rejected: false }; persist()
}
await retryDraft()
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '草稿操作结果未确认,原请求已保留') }
return true
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '草稿操作结果未确认,原请求已保留'); return false }
}
async function retryDraft() {
if (!pending.value || saving.value) return
@@ -163,8 +201,15 @@ async function retryDraft() {
pending.value = null; persist(); emit('created'); emit('update:modelValue', false); draft.value = null; ElMessage.success('整车排车单已创建'); await router.push({ name: 'overseas-dispatches' }); return
}
requireVersion((result as DispatchDraft).recordVersion)
let addedContainerId: string | undefined
if (command.action === 'CONTAINER') {
const updated = result as DispatchDraft, previous = command.priorContainerIds
const added = previous && updated.containers.filter(row => !previous.includes(row.draftContainerId))
if (!previous || !added || added.length !== 1 || added[0]!.capacity !== body.capacity || updated.containers.length !== previous.length + 1 || previous.some(id => !updated.containers.some(row => row.draftContainerId === id))) throw new DispatchInputError('新增车辆结果尚未核对,请保留原请求重试')
addedContainerId = added[0]!.draftContainerId
}
draft.value = command.action === 'RELEASE' ? null : result as DispatchDraft
activeContainerId.value = draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
activeContainerId.value = addedContainerId || draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
// No new source mutation before the original response has been validated.
pending.value = null; persist()
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
@@ -172,7 +217,8 @@ async function retryDraft() {
finally { saving.value = false }
}
async function submit() {
if (!canCreate.value || saving.value || loading.value) return
if (workspace.value === 'existing') return
if (!canCreate.value || preparing.value || saving.value || loading.value) return
error.value = null
try {
if (pending.value) { await retryDraft(); return }
@@ -186,75 +232,59 @@ async function submit() {
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density admin-density-workspace admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<AppDialog class="admin-density-dialog admin-density admin-density-workspace admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
</div>
<SmartDispatchExistingCargo v-if="workspace === 'existing' && modelValue" @saved="emit('created')" @busy="existingBusy = $event" />
<template v-else-if="workspace === 'create'">
<div class="density-dialog-sections">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
<section class="density-dialog-section dispatch-setup" aria-label="排车设置">
<h3 class="density-dialog-section__title">1. 排车设置<InlineHelp title="在途预排说明"><p>货物离港并生成入库计划后,即可加入装车组。预排只安排运输,实际装车仍须完成海外仓实收和 PDA 拣货核平。</p></InlineHelp></h3>
<h3 class="density-dialog-section__title">1. 排车设置<InlineHelp title="在途预排说明"><p>货物离港并生成入库计划后,即可安排货物并生成排车单。车辆和司机在建单后维护;实际装车仍须完成海外仓实收和 PDA 拣货核平。</p></InlineHelp></h3>
<el-form inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
<el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">选货排车</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item>
<el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">先选货再建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item>
<el-form-item class="dispatch-warehouse-field" label="起运仓" required><el-select class="dispatch-warehouse-picker" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item>
</el-form>
</section>
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-if="mode === 'cargo'">
<section class="density-dialog-section dispatch-draft-toolbar" aria-label="整车草稿管理">
<h3 class="density-dialog-section__title">2. 选货分组<InlineHelp title="分组与草稿说明"><p>每个有货的装车组生成一张排车单,同仓可混装多个客户和目的地。草稿用于暂存选货并占用来源;退出后可继续已有草稿,放弃时释放草稿。</p></InlineHelp></h3>
<section class="density-dialog-section dispatch-draft-toolbar" aria-label="车辆选货安排">
<h3 class="density-dialog-section__title">2. 为车辆选货<InlineHelp title="选货步骤说明"><p>点击左侧订单查看中间托盘,再将托盘拖入右侧车辆;也可选择车辆后点击“加入这辆车”。添加车辆时,之前的车与货物会保留。每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成排车单的选货会暂存并占用货物;可继续待完成排车,或放弃本次安排以解除占用。</p></InlineHelp></h3>
<div class="dispatch-draft-tools density-actions">
<el-button v-if="!draft || !draft.containers.length" type="primary" :disabled="frozen || loading || !warehouseId" @click="startCargo">开始选货</el-button>
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续已有草稿" aria-label="继续已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '组 · ' + saved.sources.length + '条货物'" /></el-select>
<label class="dispatch-capacity-field"><span>计划托位</span><el-select aria-label="新组计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
<el-button v-if="draft?.containers.length" :disabled="frozen || loading" @click="draftAction('CONTAINER')">添加装车组</el-button>
<InlineHelp title="计划托位说明"><p>此处只设置新组的预排容量,不代表车辆实际载重或容积。真实车辆容量与装车实测仍由服务端校验。</p></InlineHelp>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">释放本草稿</el-button>
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续待完成排车" aria-label="继续待完成排车" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '辆车 · ' + saved.sources.length + '条货物'" /></el-select>
<label class="dispatch-capacity-field"><span>计划托位</span><el-select aria-label="下一辆车计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
<InlineHelp title="计划托位说明"><p>此处设置新车辆的预排托位,默认空车尚未加货时也使用此值,不代表车辆实际载重或容积。真实车辆容量与装车实测仍由服务端校验。</p></InlineHelp>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
</div>
</section>
<div class="cargo-workspace">
<section class="density-dialog-section" aria-label="排车候选货物"><h3 class="density-dialog-section__title">候选货物 <small>重量/体积仅供预排参考</small></h3>
<div class="dispatch-candidate-query"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button></div>
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</button></div>
<el-table :data="candidates" border :empty-text="!warehouseId ? '请先选择起运仓' : '暂无符合条件的可排货物;在途货物请核对离港节点、入库计划和目的地'">
<el-table-column label="操作" width="115" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || loading || !currentContainer || Boolean(selectionGroupBlockReason(row as Candidate)) || Boolean(assignedGroup(row as Candidate))" @click="draftAction('LOCK', row as Candidate, activeContainerId)">{{ assignedGroup(row as Candidate) ? '已加入第' + assignedGroup(row as Candidate)!.sequenceNo + '组' : '加入本组' }}</el-button></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip />
<el-table-column label="来源" min-width="130"><template #default="{ row }"><span class="density-dialog-note">{{ row.selection.sourceType === 'INBOUND_PLAN' ? (row.selection.warehousePalletId ? '已收货托盘' : '在途计划') : ({ RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}</span><LineClampText :content="row.selection.relabelNo || row.planNo || '—'" /></template></el-table-column>
<el-table-column prop="orderNo" label="订单号" min-width="170" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
<el-table-column label="托盘与完整来源组" min-width="210"><template #default="{ row }"><details class="dispatch-source-group"><summary class="dispatch-group-summary"><ChevronRight class="dispatch-source-toggle" :size="14" aria-hidden="true" /><LineClampText :content="row.palletNo || '—'" /><span>{{ row.selectionGroup.pallets.length }}{{ row.selection.warehousePalletId ? '个实托' : '个计划托盘' }}</span></summary><ul class="dispatch-source-list"><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details></template></el-table-column>
<el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ [row.destination.warehouseCode || row.destination.address, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}</template></el-table-column>
<el-table-column label="参考重量/体积" min-width="150"><template #default="{ row }">{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" show-overflow-tooltip /><el-table-column label="资格" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ selectionGroupBlockReason(row as Candidate) || '可排整组' }}</template></el-table-column>
</el-table>
<el-pagination aria-label="候选货物分页" :current-page="page" :page-size="size" :page-sizes="[10, 20, 50]" :total="total" :disabled="frozen || loading" layout="total, sizes, prev, pager, next" @update:current-page="changePage" @update:page-size="changeSize" />
</section>
<section class="density-dialog-section" aria-label="排车装车组"><h3 class="density-dialog-section__title">装车分组 <small>{{ populatedGroupCount }} 组有货</small><InlineHelp title="装车分组说明"><p>新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p></InlineHelp></h3>
<p v-if="!draft?.containers.length" class="density-dialog-note">暂无装车组</p>
<table v-for="container in draft?.containers || []" :key="container.draftContainerId" class="density-table draft-group-table" :aria-label="`装车组 ${container.sequenceNo}`"><tbody>
<tr><td><el-radio v-model="activeContainerId" :disabled="frozen || loading" :value="container.draftContainerId">装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位</el-radio></td><td><el-button link :disabled="frozen || loading" @click="draftAction('DELETE', undefined, container.draftContainerId)">移除本组</el-button></td></tr>
<tr><td colspan="2"><p v-if="!container.sourceKeys.length" class="density-dialog-note">暂无货物</p><ul class="dispatch-draft-sources"><li v-for="key in container.sourceKeys" :key="key" class="dispatch-draft-source"><LineClampText :content="draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复'" /><el-button link :disabled="frozen || loading || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></td></tr>
</tbody></table>
</section>
</div>
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button><el-button :disabled="frozen || loading || !warehouseId" @click="resetSearch">重置</el-button></div>
</section>
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全美' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</button></div>
<SmartDispatchCargoBoard :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize" />
</template>
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
<section class="density-dialog-section" aria-label="排车车辆与司机">
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '2. 车辆与时间' : '3. 车辆与时间 · 装车组 ' + currentContainer!.sequenceNo }}<small>可先保存,审核前补齐</small><InlineHelp title="车辆与时间说明"><p>车辆、司机、时间和备注分别保存在当前选中的装车组。切换右侧装车组,可分别填写每一趟的资料。</p></InlineHelp></h3>
<section class="density-dialog-section" aria-label="排车计划时间与备注">
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '2. 计划时间与备注' : '3. 第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆信息”补充。</p></InlineHelp></h3>
<table class="density-table density-table--editable dispatch-create-table"><tbody>
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
</tbody></table>
<DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading" />
</section>
</el-form>
</div>
</template>
</AppDialog>
</template>
<style scoped>
.dispatch-create-table { table-layout: fixed; }
.dispatch-create-table td:not([colspan]) { width: 50%; }
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: var(--admin-space-5); align-items: start; }
.cargo-workspace > .density-dialog-section { margin: 0; }
.cargo-workspace > .density-dialog-section > .density-dialog-section__title { min-height: 24px; align-items: center; }
.dispatch-setup, .dispatch-draft-toolbar { display: grid; grid-template-columns: 160px minmax(0, 1fr); align-items: center; gap: var(--admin-space-5); }
.dispatch-setup > .density-dialog-section__title, .dispatch-draft-toolbar > .density-dialog-section__title { align-items: center; margin: 0; }
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
@@ -265,14 +295,5 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
.dispatch-draft-picker { width: 240px; }
.dispatch-capacity-field { display: flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
.dispatch-capacity-picker { width: 100px; }
.dispatch-candidate-query { display: grid; grid-template-columns: 240px 160px 170px auto; justify-content: start; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.dispatch-group-summary { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; list-style: none; align-items: center; gap: var(--admin-space-2); }
.dispatch-source-group > summary { cursor: pointer; }
.dispatch-source-group > summary::-webkit-details-marker { display: none; }
.dispatch-source-group[open] .dispatch-source-toggle { transform: rotate(90deg); }
.dispatch-source-list { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
.draft-group-table + .draft-group-table { margin-top: var(--admin-space-4); }
.dispatch-draft-sources { margin: 0; padding: 0; list-style: none; }
.dispatch-draft-source { display: flex; align-items: center; gap: var(--admin-space-3); }
.dispatch-draft-source :deep(.line-clamp-text) { flex: 1; }
.dispatch-candidate-query { display: grid; grid-template-columns: 240px 160px 170px auto auto; justify-content: start; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
</style>