303 lines
29 KiB
Vue
303 lines
29 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, reactive, ref, watch } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useAuthStore } from '@/core/auth/auth'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import InlineHelp from '@/shared/components/InlineHelp.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, 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 SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
|
||
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
|
||
import type { SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
|
||
|
||
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
|
||
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; 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 settingsExpanded = 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), 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 } }>>({})
|
||
const currentSettings = computed(() => {
|
||
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
|
||
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' } }
|
||
return settings[key]!
|
||
})
|
||
const form = computed(() => currentSettings.value.form)
|
||
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(() => 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 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
|
||
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() }
|
||
function changePage(value: number) { page.value = value; void loadCandidates() }
|
||
function changeSize(value: number) { size.value = value; void search() }
|
||
|
||
let generation = 0
|
||
let candidateFilterKey = ''
|
||
function persist() {
|
||
if (!owner || owner !== auth.user?.id) throw new DispatchInputError('登录身份已变化,请重新进入排车列表')
|
||
if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey)
|
||
}
|
||
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
|
||
warehouses.value = directory; drafts.value = saved
|
||
if (pending.value?.body.draftToken) draft.value = saved.find(row => row.draftToken === pending.value!.body.draftToken) || null
|
||
if (draft.value) warehouseId.value = draft.value.warehouseId
|
||
if (!warehouseId.value) {
|
||
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
|
||
if (selectedWarehouses.size === 1 && directory.some(row => String(row.id) === [...selectedWarehouses][0])) warehouseId.value = [...selectedWarehouses][0]!
|
||
else if (directory[0]) warehouseId.value = String(directory[0].id)
|
||
}
|
||
if (warehouseId.value) await loadCandidates(sequence)
|
||
} 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 {
|
||
candidatePage.assertCurrent(cargo)
|
||
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: 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): Promise<boolean> {
|
||
if (!canCreate.value || loading.value || saving.value) return false
|
||
error.value = null
|
||
try {
|
||
if (!pending.value) {
|
||
if (!warehouseId.value) throw new DispatchInputError('请选择真实起运仓')
|
||
const key = crypto.randomUUID(), current = draft.value
|
||
let body: object
|
||
if (action === 'CREATE') body = { warehouseId: warehouseId.value, idempotencyKey: key }
|
||
else {
|
||
if (!current) throw new DispatchInputError('请先新建或恢复草稿')
|
||
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
|
||
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
|
||
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') { requireSelectionGroup(source); candidatePage.assertCurrent([source]) } body = { ...common, draftContainerId: containerId, source: source.selection } }
|
||
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
|
||
else if (action === 'RELEASE') body = common
|
||
else {
|
||
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
|
||
const cargo = row.sourceKeys.map(keyValue => current.sources.find(value => value.sourceKey === keyValue))
|
||
if (cargo.some(rowValue => !rowValue)) throw new DispatchInputError('草稿来源尚未恢复完整,请重新读取')
|
||
return { draftContainerId: row.draftContainerId, dispatch: creation(cargo as Candidate[], row.draftContainerId) }
|
||
})
|
||
if (!groups.length) throw new DispatchInputError('请至少选择一组真实货物;空单请使用先建空单')
|
||
body = { ...common, groups } satisfies ConvertDraft
|
||
}
|
||
}
|
||
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()
|
||
return true
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '草稿操作结果未确认,原请求已保留'); return false }
|
||
}
|
||
async function retryDraft() {
|
||
if (!pending.value || saving.value) return
|
||
const command = pending.value, previousAttempt = command.attempted
|
||
saving.value = true; command.attempted = true; persist()
|
||
try {
|
||
const body = command.body
|
||
let result: DispatchDraft | ConvertedDraft
|
||
switch (command.action) {
|
||
case 'CREATE': result = await api.createDispatchDraft(body as unknown as { warehouseId: string; idempotencyKey: string }); break
|
||
case 'CONTAINER': result = await api.createDispatchDraftContainer(body as unknown as VersionCommand & { draftToken: string; capacity: number }); break
|
||
case 'LOCK': result = await api.lockDispatchDraftPallet(body as unknown as DraftSelection); break
|
||
case 'UNLOCK': result = await api.unlockDispatchDraftPallet(body as unknown as DraftSelection); break
|
||
case 'RELEASE': result = await api.releaseDispatchDraft(body as unknown as VersionCommand & { draftToken: string }); break
|
||
case 'DELETE': result = await api.deleteDispatchDraftContainer(String(body.draftToken), command.containerId!, body as unknown as VersionCommand & { reason: string }); break
|
||
case 'CONVERT': result = command.result || await api.convertDispatchDraft(body as unknown as ConvertDraft); break
|
||
}
|
||
if (body.draftToken && result.draftToken !== body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致')
|
||
if (command.action === 'CONVERT') {
|
||
const converted = result as ConvertedDraft, expected = (body as unknown as ConvertDraft).groups
|
||
if (converted.vehicles.length !== expected.length || new Set(converted.vehicles.map(row => row.dispatch.dispatchNo)).size !== expected.length || expected.some(group => !converted.vehicles.some(row => row.draftContainerId === group.draftContainerId && row.dispatch.action === 'CREATE' && row.dispatch.idempotencyKey === group.dispatch.idempotencyKey && Boolean(row.dispatch.dispatchNo)))) throw new DispatchInputError('整车转换结果与原草稿容器不一致,请核对原请求')
|
||
command.result = converted; persist()
|
||
await Promise.all(converted.vehicles.map(async row => validateCurrentDispatch(await api.getDispatch(row.dispatch.dispatchNo), row.dispatch)))
|
||
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 = 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()
|
||
} catch (cause) { if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) command.rejected = true; persist(); throw cause }
|
||
finally { saving.value = false }
|
||
}
|
||
async function submit() {
|
||
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 }
|
||
if (recovery.pending.value && recovery.pending.value.action !== 'CREATE') throw new DispatchInputError('另一次排车操作尚待核对,请从列表的原请求恢复入口继续')
|
||
if (!recovery.pending.value && mode.value === 'cargo') { await draftAction('CONVERT'); return }
|
||
if (!recovery.pending.value) { if (!warehouseId.value) throw new DispatchInputError('请选择真实起运仓'); recovery.begin('', 'CREATE', creation([])) }
|
||
const result = await recovery.execute(command => api.createDispatch(command.body as unknown as CreateDispatch), api.getDispatch, api.getDispatchCommand)
|
||
if (result) { emit('created'); emit('update:modelValue', false); ElMessage.success(`空排车单 ${result.dispatchNo} 已创建`) }
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单结果尚未核对,请使用原请求重试') }
|
||
}
|
||
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
|
||
</script>
|
||
<template>
|
||
<AppDialog class="admin-density-dialog admin-density 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 dispatch-workspace">
|
||
<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-search" aria-label="候选货物查询">
|
||
<div class="dispatch-candidate-query">
|
||
<el-select class="dispatch-warehouse-picker" aria-label="起运仓" 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>
|
||
<template v-if="mode === 'cargo'"><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 type="primary" :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button><el-button :disabled="frozen || loading || !warehouseId" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button></div>
|
||
</section>
|
||
<section v-show="settingsExpanded" id="smart-dispatch-settings" class="dispatch-context-toolbar" aria-label="排车设置">
|
||
<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>
|
||
<div v-if="mode === 'cargo'" class="dispatch-draft-tools density-actions" aria-label="车辆选货安排">
|
||
<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>
|
||
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
|
||
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击加入。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
|
||
</div>
|
||
</section>
|
||
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
|
||
<template v-if="mode === 'cargo'">
|
||
<div class="warehouse-tabs density-actions" role="tablist" aria-label="候选仓库展示"><el-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" :class="{ active: warehouseTab === tab.value }" :type="warehouseTab === tab.value ? 'primary' : undefined" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</el-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 #vehicle-controls>
|
||
<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>
|
||
</template>
|
||
</SmartDispatchCargoBoard>
|
||
</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' ? '计划时间与备注' : '第 ' + 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>
|
||
</section>
|
||
</el-form>
|
||
</div>
|
||
</template>
|
||
</AppDialog>
|
||
</template>
|
||
<style scoped>
|
||
.dispatch-create-table { table-layout: fixed; }
|
||
.dispatch-create-table td:not([colspan]) { width: 50%; }
|
||
.dispatch-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
|
||
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
|
||
.dispatch-setup-fields :deep(.el-form-item) { margin: 0; }
|
||
.dispatch-draft-tools { flex-wrap: wrap; align-items: center; }
|
||
.dispatch-draft-picker { width: 240px; }
|
||
.dispatch-capacity-field { display: inline-flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
|
||
.dispatch-capacity-picker { width: 92px; }
|
||
.dispatch-candidate-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||
.dispatch-candidate-query > .el-input { width: 180px; }
|
||
.dispatch-candidate-query > .el-select { width: 170px; }
|
||
.dispatch-candidate-query > .dispatch-warehouse-picker { width: 200px; }
|
||
.dispatch-candidate-query > .el-button { margin-left: 0; }
|
||
.dispatch-workspace { margin-top: var(--admin-space-5); }
|
||
</style>
|