2026-08-21 11:20:09 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-09-14 20:03:27 +08:00
|
|
|
|
import '@/styles/adminDensity.css'
|
2026-08-21 11:20:09 +08:00
|
|
|
|
import { computed, reactive, ref, watch } from 'vue'
|
2026-08-21 17:20:40 +08:00
|
|
|
|
import { useRouter } from 'vue-router'
|
2026-10-19 17:56:31 +08:00
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
|
import { useAuthStore } from '@/core/auth/auth'
|
|
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
|
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
2026-08-21 11:20:09 +08:00
|
|
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
2026-10-19 17:56:31 +08:00
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
2026-08-21 11:20:09 +08:00
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
2026-10-19 17:56:31 +08:00
|
|
|
|
import * as api from '../api/dispatches'
|
|
|
|
|
|
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, Driver, DraftSelection, Vehicle, VersionCommand } from '../dispatchContracts'
|
|
|
|
|
|
import { DispatchInputError, requireVersion, requireBatchVersions } from '../dispatchContracts'
|
|
|
|
|
|
import { dispatchActionPermissions } from '../dispatchPresentation'
|
|
|
|
|
|
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
|
2026-08-21 11:20:09 +08:00
|
|
|
|
import type { InboundOrderListItem } from '../types'
|
2026-10-19 17:56:31 +08:00
|
|
|
|
import DispatchResourceFields from './DispatchResourceFields.vue'
|
2026-09-18 11:41:27 +08:00
|
|
|
|
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
|
2026-08-21 11:20:09 +08:00
|
|
|
|
|
2026-10-19 17:56:31 +08:00
|
|
|
|
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; 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 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 loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), candidates = ref<Candidate[]>([])
|
|
|
|
|
|
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
|
2026-09-18 11:41:27 +08:00
|
|
|
|
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
|
2026-10-19 17:56:31 +08:00
|
|
|
|
const visibleCandidates = computed(() => candidates.value.filter(row => matchesSmartDispatchWarehouseTab(row, warehouseTab.value)))
|
|
|
|
|
|
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string }; vehicle: Vehicle; driver: Driver }>>({})
|
|
|
|
|
|
const currentSettings = computed(() => {
|
|
|
|
|
|
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
|
|
|
|
|
|
return 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 } } }
|
2026-08-21 17:20:40 +08:00
|
|
|
|
})
|
2026-10-19 17:56:31 +08:00
|
|
|
|
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(() => saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
|
|
|
|
|
|
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
|
|
|
|
|
|
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
|
|
|
|
|
|
let generation = 0
|
|
|
|
|
|
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)
|
2026-09-18 11:41:27 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
async function init() {
|
|
|
|
|
|
const sequence = ++generation
|
|
|
|
|
|
loading.value = true; error.value = null
|
2026-08-21 11:20:09 +08:00
|
|
|
|
try {
|
2026-10-19 17:56:31 +08:00
|
|
|
|
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]!
|
2026-10-18 16:37:37 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
if (warehouseId.value) await loadCandidates(sequence)
|
|
|
|
|
|
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
|
|
|
|
|
|
finally { if (sequence === generation) loading.value = false }
|
2026-09-13 21:10:05 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
async function loadCandidates(sequence = generation) {
|
|
|
|
|
|
if (!warehouseId.value) return
|
|
|
|
|
|
const result = await api.listDispatchCandidates({ warehouseId: warehouseId.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 })
|
|
|
|
|
|
if (sequence !== generation) return
|
|
|
|
|
|
if (result.complete !== true) throw new DispatchInputError('候选来源尚未完整返回,请缩小筛选范围重试')
|
|
|
|
|
|
if (result.candidates.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.selection.sourceVersion !== row.sourceVersion)) throw new DispatchInputError('候选起运仓或来源版本不完整,请重新读取')
|
|
|
|
|
|
candidates.value = result.candidates
|
2026-08-21 17:20:40 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
async function search() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
|
|
|
|
|
|
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 {
|
|
|
|
|
|
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: cargo.map(row => row.selection), 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 } }))) }
|
2026-08-21 17:20:40 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string) {
|
|
|
|
|
|
if (!canCreate.value || loading.value || saving.value) return
|
|
|
|
|
|
error.value = null
|
2026-08-21 11:20:09 +08:00
|
|
|
|
try {
|
2026-10-19 17:56:31 +08:00
|
|
|
|
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 || source.blockReasons.length) throw new DispatchInputError('请选择可排货物和装车容器'); 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
|
2026-10-05 03:30:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
pending.value = { action, body: JSON.parse(JSON.stringify(body)) as Record<string, unknown>, containerId, attempted: false, rejected: false }; persist()
|
2026-10-05 03:30:06 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
await retryDraft()
|
|
|
|
|
|
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '草稿操作结果未确认,原请求已保留') }
|
2026-08-21 11:20:09 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
async function retryDraft() {
|
|
|
|
|
|
if (!pending.value || saving.value) return
|
|
|
|
|
|
const command = pending.value, previousAttempt = command.attempted
|
|
|
|
|
|
saving.value = true; command.attempted = true; persist()
|
2026-10-18 16:37:37 +08:00
|
|
|
|
try {
|
2026-10-19 17:56:31 +08:00
|
|
|
|
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
|
2026-10-18 16:37:37 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
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
|
2026-10-18 16:37:37 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
requireVersion((result as DispatchDraft).recordVersion)
|
|
|
|
|
|
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 || ''
|
|
|
|
|
|
// No new source mutation before the original response has been validated.
|
|
|
|
|
|
pending.value = null; persist()
|
|
|
|
|
|
await loadCandidates()
|
|
|
|
|
|
} catch (cause) { if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) command.rejected = true; persist(); throw cause }
|
|
|
|
|
|
finally { saving.value = false }
|
2026-10-18 16:37:37 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
async function submit() {
|
|
|
|
|
|
if (!canCreate.value || saving.value || loading.value) return
|
|
|
|
|
|
error.value = null
|
2026-10-18 16:37:37 +08:00
|
|
|
|
try {
|
2026-10-19 17:56:31 +08:00
|
|
|
|
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 : '建单结果尚未核对,请使用原请求重试') }
|
2026-10-18 16:37:37 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
watch(() => props.modelValue, value => { if (value) void init(); else generation++ }, { immediate: true })
|
2026-08-21 11:20:09 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
<template>
|
2026-10-19 17:56:31 +08:00
|
|
|
|
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="loading || !canCreate" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
|
|
|
|
|
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
|
|
|
|
|
<p v-if="pending || recovery.pending.value" role="status">原请求结果尚待核对,请勿更改选择、版本或重新建单。</p><el-button v-if="pending?.rejected" :disabled="saving" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
|
|
|
|
|
|
<el-form label-position="top" class="density-form"><div class="create-grid"><el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen"><el-radio value="cargo">先选货建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item><el-form-item label="起运仓" required><el-select v-model="warehouseId" :disabled="frozen || Boolean(draft)" placeholder="请选择起运仓" @change="search"><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-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></div>
|
|
|
|
|
|
<p v-if="mode === 'cargo'">当前编辑:{{ draft?.containers.find(row => row.draftContainerId === activeContainerId)?.sequenceNo ? `装车组 ${draft.containers.find(row => row.draftContainerId === activeContainerId)!.sequenceNo}` : '请先选择装车组' }};每组车辆、司机和时间独立保存。</p><DispatchResourceFields v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen" /><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen" placeholder="请输入本趟备注(选填)" /></el-form-item>
|
|
|
|
|
|
</el-form>
|
|
|
|
|
|
<p v-if="mode === 'empty'">空单可先保存,之后添加同仓各客户货物;空单不能核平、装车或发车。</p>
|
|
|
|
|
|
<template v-else><div class="density-actions"><el-select :model-value="draft?.draftToken" :disabled="frozen" placeholder="显式恢复已有草稿" @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 :disabled="frozen || !warehouseId || Boolean(draft)" @click="draftAction('CREATE')">新建草稿</el-button><el-select v-model="capacity" :disabled="frozen" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select><el-button :disabled="frozen || !draft" @click="draftAction('CONTAINER')">添加装车组</el-button><el-button :disabled="frozen || !draft" @click="draftAction('RELEASE')">释放本草稿</el-button></div>
|
|
|
|
|
|
<div class="density-actions"><el-input v-model="query.orderNo" :disabled="frozen" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen" clearable placeholder="全部来源"><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || !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" @click="warehouseTab = tab.value">{{ tab.label }}</button></div><div class="cargo-workspace"><el-table :data="visibleCandidates" border height="420"><el-table-column label="操作" width="95" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || !activeContainerId || row.blockReasons.length > 0" @click="draftAction('LOCK', row as Candidate, activeContainerId)">加入本组</el-button></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" /><el-table-column label="来源" min-width="130"><template #default="{ row }">{{ ({ INBOUND_PLAN: '计划托盘', RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}<br>{{ row.selection.relabelNo || row.planNo || '—' }}</template></el-table-column><el-table-column prop="orderNo" label="订单号" min-width="170" /><el-table-column prop="inboundCode" label="入仓码" min-width="170" /><el-table-column prop="palletNo" label="托盘码" min-width="170" /><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150"><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³<br>仅供预排参考</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" /><el-table-column label="资格" min-width="160"><template #default="{ row }">{{ row.blockReasons.join(';') || '可排' }}</template></el-table-column></el-table>
|
|
|
|
|
|
<section><article v-for="container in draft?.containers || []" :key="container.draftContainerId" class="draft-container"><el-radio v-model="activeContainerId" :disabled="frozen" :value="container.draftContainerId">装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位</el-radio><el-button link :disabled="frozen" @click="draftAction('DELETE', undefined, container.draftContainerId)">移除本组</el-button><ul><li v-for="key in container.sourceKeys" :key="key">{{ draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复' }}<el-button link :disabled="frozen || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></article><p v-if="!draft?.containers.length">新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p></section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-08-21 11:20:09 +08:00
|
|
|
|
</AppDialog>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
2026-10-19 17:56:31 +08:00
|
|
|
|
.create-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
|
|
|
|
|
.create-grid :deep(.el-select), .create-grid :deep(.el-date-editor) { width: 100%; }
|
|
|
|
|
|
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); gap: var(--admin-space-5); }
|
|
|
|
|
|
.draft-container { padding: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
|
2026-08-21 11:20:09 +08:00
|
|
|
|
</style>
|