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

916 lines
38 KiB
Vue
Raw Normal View History

2026-08-21 11:20:09 +08:00
<script setup lang="ts">
import { physicalWeightLb, totalPhysicalWeightLb } from '../dispatchPhysicalWeight'
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-09-14 20:03:27 +08:00
import { GripVertical, Plus } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
2026-08-21 17:20:40 +08:00
import { useRouter } from 'vue-router'
2026-08-21 11:20:09 +08:00
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'
2026-08-28 14:33:57 +08:00
import { formatOverseasDateTime } from '../lastMilePresentation'
2026-08-21 11:20:09 +08:00
import type { InboundOrderListItem } from '../types'
2026-09-15 12:17:20 +08:00
import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs'
import OrderReferenceText from './OrderReferenceText.vue'
2026-09-18 11:41:27 +08:00
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
2026-08-21 11:20:09 +08:00
2026-09-13 21:10:05 +08:00
interface LoadingContainer {
id: string
capacity: number
maxWeightLb: number
vehicleLabel: string
palletIds: string[]
}
2026-08-21 17:20:40 +08:00
2026-09-13 21:10:05 +08:00
const props = defineProps<{
modelValue: boolean
orders: InboundOrderListItem[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: []
}>()
2026-08-21 17:20:40 +08:00
const router = useRouter()
2026-08-21 11:20:09 +08:00
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))
2026-08-21 11:20:09 +08:00
const mutationError = ref<ApiErrorDetails | null>(null)
const candidates = ref<DispatchPalletCandidateDto[]>([])
2026-08-21 17:52:07 +08:00
const palletSnapshots = ref<Record<string, DispatchPalletCandidateDto>>({})
2026-08-21 17:20:40 +08:00
const activeOrderKey = ref('')
const containers = ref<LoadingContainer[]>([])
const busyPalletIds = ref<string[]>([])
const draftToken = ref('')
2026-08-25 10:34:27 +08:00
const vehicleDialogOpen = ref(false)
const selectedVehicle = ref('')
2026-09-13 21:10:05 +08:00
const query = reactive({
orderNo: '',
orderType: '',
2026-09-17 13:36:31 +08:00
sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED' | 'RELABEL',
2026-09-13 21:10:05 +08:00
fbaWarehouseCode: '',
})
2026-09-18 11:41:27 +08:00
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
2026-08-21 11:20:09 +08:00
2026-08-21 17:20:40 +08:00
const orderTypeOptions = [
{ value: 'FIRST_LEG', label: '头程订单' },
2026-08-22 12:24:15 +08:00
{ value: 'RELABEL_APPLICATION', label: '换标下单' },
{ value: 'RETURN_RELABEL', label: '退货换标' },
2026-08-21 17:20:40 +08:00
{ value: 'LOCAL_LOOSEBOARD', label: '本土散板' },
2026-08-22 12:24:15 +08:00
{ value: 'WAREHOUSE_TRANSFER', label: '仓储转运' },
2026-08-21 17:20:40 +08:00
]
2026-08-21 11:20:09 +08:00
2026-08-21 17:20:40 +08:00
const planIds = computed(() => [...new Set(props.orders.map((item) => item.planId).filter(Boolean))])
2026-09-13 21:10:05 +08:00
const visibleCandidates = computed(() =>
candidates.value.filter((item) => {
const typeMatches = !query.orderType || String(item.orderType || '') === query.orderType
if (!typeMatches) return false
2026-09-18 11:41:27 +08:00
return matchesSmartDispatchWarehouseTab(item, warehouseTab.value)
2026-09-13 21:10:05 +08:00
}),
)
2026-08-21 17:20:40 +08:00
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)
2026-08-21 17:20:40 +08:00
groups.set(key, [...(groups.get(key) || []), item])
}
2026-09-13 21:10:05 +08:00
return [...groups.entries()].map(([key, pallets]) => ({
key,
first: pallets[0]!,
pallets,
}))
2026-08-21 17:20:40 +08:00
})
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)))
2026-08-21 17:52:07 +08:00
const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)).filter((item): item is DispatchPalletCandidateDto => Boolean(item)))
2026-08-21 17:20:40 +08:00
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))
2026-09-15 12:17:20 +08:00
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
2026-09-18 11:41:27 +08:00
}
2026-08-21 17:20:40 +08:00
2026-09-13 21:10:05 +08:00
function palletId(item: DispatchPalletCandidateDto) {
if (item.sourceStage === 'RELABEL') return String(item.warehousePalletId || '')
if (item.sourceType === 'RETURN_RECEIPT') return item.returnReceiptId ? `RETURN:${item.returnReceiptId}` : ''
2026-09-13 21:10:05 +08:00
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)
}
2026-09-17 13:36:31 +08:00
function privateWarehouse(item: DispatchPalletCandidateDto) {
if (item.destinationType !== 'PRIVATE') return '—'
return [item.deliveryAddress, item.postalCode].map((value) => String(value || '').trim()).filter(Boolean).join(' · ') || '—'
}
2026-09-13 21:10:05 +08:00
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)}`
}
2026-08-21 17:20:40 +08:00
function ensureDraftToken() {
if (!draftToken.value.trim()) draftToken.value = makeDraftToken()
return draftToken.value
2026-08-21 11:20:09 +08:00
}
2026-08-21 17:52:07 +08:00
function rememberPallets(items: DispatchPalletCandidateDto[]) {
2026-09-13 21:10:05 +08:00
palletSnapshots.value = {
...palletSnapshots.value,
...Object.fromEntries(items.map((item) => [palletId(item), item])),
}
2026-08-21 17:52:07 +08:00
}
async function hydrateDraftPallets() {
2026-09-13 21:10:05 +08:00
const restored = await listDispatchCandidates({
draftToken: ensureDraftToken(),
})
2026-08-21 17:52:07 +08:00
rememberPallets(restored)
}
2026-08-21 11:20:09 +08:00
async function load() {
if (draftBlocked.value) return
2026-09-13 21:10:05 +08:00
loading.value = true
loadError.value = null
2026-08-21 11:20:09 +08:00
try {
2026-09-13 21:10:05 +08:00
candidates.value = await listDispatchCandidates({
2026-09-17 13:36:31 +08:00
planIds: query.sourceStage !== 'RELABEL' && planIds.value.length ? planIds.value.join(',') : undefined,
2026-09-13 21:10:05 +08:00
orderNo: query.orderNo.trim() || undefined,
fbaWarehouseCode: query.fbaWarehouseCode.trim() || undefined,
sourceStage: query.sourceStage || undefined,
draftToken: ensureDraftToken(),
})
2026-08-21 17:52:07 +08:00
rememberPallets(candidates.value)
if (unresolvedAssignedIds.value.length) await hydrateDraftPallets()
if (unresolvedAssignedIds.value.length) throw new Error('草稿中部分已选来源尚未恢复,请重试加载;原选择已保留')
2026-08-21 17:20:40 +08:00
activeOrderKey.value = orderGroups.value[0]?.key || ''
2026-08-21 11:20:09 +08:00
} catch (error) {
2026-09-13 21:10:05 +08:00
candidates.value = []
activeOrderKey.value = ''
2026-08-21 17:20:40 +08:00
loadError.value = getApiErrorDetails(error, '可排车入仓单加载失败,请重试')
2026-09-13 21:10:05 +08:00
} finally {
loading.value = false
}
}
function reset() {
query.orderNo = ''
query.orderType = ''
query.sourceStage = 'RECEIVED'
query.fbaWarehouseCode = ''
warehouseTab.value = 'ALL'
void load()
2026-08-21 11:20:09 +08:00
}
2026-09-13 21:10:05 +08:00
function addContainer() {
if (saving.value || draftBlocked.value) return
2026-09-13 21:10:05 +08:00
selectedVehicle.value = ''
vehicleDialogOpen.value = true
}
2026-08-25 10:34:27 +08:00
async function confirmAddContainer() {
if (saving.value || draftBlocked.value) return
2026-08-25 10:34:27 +08:00
const spec = selectedVehicleSpec.value
2026-09-13 21:10:05 +08:00
if (!spec) {
ElMessage.warning('请选择车型')
return
}
2026-08-21 17:20:40 +08:00
try {
2026-08-25 10:34:27 +08:00
const created = await createDispatchDraftContainer(ensureDraftToken(), spec.capacity)
2026-09-13 21:10:05 +08:00
containers.value.push({
id: created.id,
capacity: created.capacity,
maxWeightLb: spec.maxWeightLb,
vehicleLabel: spec.label,
palletIds: created.palletIds || [],
})
2026-08-25 10:34:27 +08:00
vehicleDialogOpen.value = false
2026-09-13 21:10:05 +08:00
} catch (error) {
mutationError.value = getApiErrorDetails(error, '添加装车车型失败,请重试')
}
2026-08-21 17:20:40 +08:00
}
async function movePallet(item: DispatchPalletCandidateDto, containerId: string) {
const id = palletId(item)
if (saving.value || draftBlocked.value || busyPalletIds.value.includes(id)) return
2026-08-21 17:20:40 +08:00
const target = containers.value.find((container) => container.id === containerId)
if (!target || target.palletIds.includes(id)) return
2026-09-13 21:10:05 +08:00
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`)
}
2026-08-21 17:20:40 +08:00
const anchor = assigned.value[0]
2026-09-13 21:10:05 +08:00
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)
}
2026-08-21 17:20:40 +08:00
}
2026-08-21 17:52:07 +08:00
async function removePallet(id: string) {
if (saving.value || draftBlocked.value || busyPalletIds.value.includes(id)) return
2026-08-21 17:20:40 +08:00
const owner = containerFor(id)
if (!owner) return
2026-09-13 21:10:05 +08:00
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)
}
2026-08-21 17:20:40 +08:00
}
async function deleteContainer(container: LoadingContainer) {
if (saving.value || draftBlocked.value) return
2026-08-21 17:20:40 +08:00
for (const id of [...container.palletIds]) {
2026-08-21 17:52:07 +08:00
await removePallet(id)
2026-08-21 17:20:40 +08:00
}
2026-09-13 21:10:05 +08:00
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, '删除装车车型失败,请重试')
}
2026-09-13 21:10:05 +08:00
}
}
function dragStart(event: DragEvent, item: DispatchPalletCandidateDto) {
event.dataTransfer?.setData('text/plain', palletId(item))
2026-08-21 17:20:40 +08:00
}
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
2026-08-21 17:20:40 +08:00
emit('update:modelValue', value)
}
2026-08-21 11:20:09 +08:00
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
}
2026-09-13 21:10:05 +08:00
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
}
2026-10-14 13:53:15 +08:00
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
}
2026-09-13 21:10:05 +08:00
if (assigned.value.some((item) => !String(item.overseasWarehouseId || '').trim())) {
ElMessage.warning('所选托盘中存在尚未关联海外仓的托盘')
return
}
const relabelOrders = [...new Set(assigned.value.filter((item) => item.sourceStage === 'RELABEL')
2026-09-17 13:36:31 +08:00
.map((item) => String(item.orderRefs || '').trim()).filter(Boolean))]
if (assigned.value.some((item) => item.sourceStage === 'RELABEL' && !String(item.orderRefs || '').trim())) {
ElMessage.warning('换标来源单号缺失,请刷新后重新选择')
return
}
2026-09-13 21:10:05 +08:00
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
}
2026-09-13 21:10:05 +08:00
saving.value = true
mutationError.value = null
2026-08-21 11:20:09 +08:00
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('预排车返回的分组结果与原草稿不一致,原草稿已保留,请核对保存结果')
}
}
2026-09-13 21:10:05 +08:00
emit('update:modelValue', false)
emit('created')
draftLoaded.value = false
draftToken.value = ''
containers.value = []
palletSnapshots.value = {}
2026-09-13 21:10:05 +08:00
ElMessage.success('排车明细已同步到预排列表')
2026-08-21 17:20:40 +08:00
await router.push({ name: 'overseas-dispatches' })
2026-09-13 21:10:05 +08:00
} catch (error) {
mutationError.value = getApiErrorDetails(error, '预排车创建失败,请重试')
} finally {
saving.value = false
}
2026-08-21 11:20:09 +08:00
}
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()
}
2026-09-13 21:10:05 +08:00
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()
2026-09-13 21:10:05 +08:00
},
{ immediate: true },
)
2026-08-21 17:20:40 +08:00
watch([warehouseTab, () => query.orderType], () => {
activeOrderKey.value = orderGroups.value[0]?.key || ''
2026-08-21 11:20:09 +08:00
})
</script>
<template>
<OperationErrorToast :details="mutationError" />
2026-09-14 20:03:27 +08:00
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" @update:model-value="close($event)">
2026-08-21 11:20:09 +08:00
<section class="dispatch-query" aria-label="智能排车筛选">
2026-09-17 16:34:36 +08:00
<el-input v-model="query.orderNo" clearable :placeholder="query.sourceStage === 'RELABEL' ? '换标单号 / 原入仓码' : '订单号 / 换标单号 / 原入仓码'" @keyup.enter="load" />
2026-08-21 17:20:40 +08:00
<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>
2026-09-17 13:36:31 +08:00
<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>
2026-08-21 17:20:40 +08:00
<el-input v-model="query.fbaWarehouseCode" clearable placeholder="FBA仓库" @keyup.enter="load" />
2026-08-21 11:20:09 +08:00
<el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button :disabled="loading" @click="reset">重置</el-button>
</section>
2026-08-21 17:20:40 +08:00
<p v-if="query.sourceStage === 'RELABEL'" class="dispatch-source-hint">仅展示客户已确认、卡车派送且尚未排车的换标单。必须一次选择本申请全部应排托盘并放入同一装车容器。换标选择仅保留在当前页面,尚未锁定;刷新页面后需重新选择,提交时由服务端重新校验。</p>
2026-09-17 13:36:31 +08:00
2026-09-14 20:03:27 +08:00
<nav class="warehouse-tabs density-tabs" aria-label="海外仓筛选">
2026-09-13 21:10:05 +08:00
<button
2026-09-14 20:03:27 +08:00
v-for="tab in [
2026-09-13 21:10:05 +08:00
{ value: 'ALL', label: '全部' },
{ value: 'LAX', label: '洛杉矶' },
{ value: 'NYC', label: '纽约' },
]"
2026-09-14 20:03:27 +08:00
:key="tab.value"
type="button"
:class="['warehouse-tab', { active: warehouseTab === tab.value }]"
2026-09-18 11:41:27 +08:00
@click="warehouseTab = tab.value as SmartDispatchWarehouseTab"
2026-09-14 20:03:27 +08:00
>
2026-09-13 21:10:05 +08:00
{{ tab.label }}
</button>
2026-08-21 17:20:40 +08:00
</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"
2026-09-14 20:03:27 +08:00
><template #details><ApiErrorMeta :details="loadError" /></template
2026-09-13 21:10:05 +08:00
></LoadErrorState>
<div v-else v-loading="draftLoading" class="dispatch-workbench">
2026-08-21 17:20:40 +08:00
<section class="order-pane" aria-label="可排车入仓单">
2026-09-13 21:10:05 +08:00
<header>
<div>
<h3>
出库单列表 <span>({{ orderGroups.length }}单)</span>
</h3>
2026-09-14 20:03:27 +08:00
<div class="order-type-tabs density-tabs">
2026-09-13 21:10:05 +08:00
<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>
2026-08-21 17:20:40 +08:00
<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 || ''">
2026-09-13 21:10:05 +08:00
<el-table-column label="订单号" min-width="170"
2026-09-14 20:03:27 +08:00
><template #default="{ row }"><OrderReferenceText :value="text(row.first.orderNo)" /></template
2026-09-13 21:10:05 +08:00
></el-table-column>
<el-table-column label="换标单号" min-width="180"
2026-09-14 20:03:27 +08:00
><template #default="{ row }">{{ row.first.sourceStage === 'RELABEL' ? text(row.first.orderRefs) : '—' }}</template
2026-09-13 21:10:05 +08:00
></el-table-column>
<el-table-column label="原入仓码" min-width="150"
2026-09-14 20:03:27 +08:00
><template #default="{ row }">{{ text(row.first.inboundCode) }}</template
2026-09-13 21:10:05 +08:00
></el-table-column>
<el-table-column label="订单类型" width="100"
2026-09-14 20:03:27 +08:00
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
2026-09-13 21:10:05 +08:00
>
<el-table-column label="重量(lb)" width="100" align="right"
><template #default="{ row }">{{ formatWeightLb(orderWeightLb(row.pallets)) }}</template></el-table-column
2026-09-13 21:10:05 +08:00
>
<el-table-column label="托盘数量" width="90" align="right"
2026-09-14 20:03:27 +08:00
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
2026-09-13 21:10:05 +08:00
>
<el-table-column label="目的地FBA" width="110"
2026-09-14 20:03:27 +08:00
><template #default="{ row }">{{ text(row.first.fbaWarehouseCode) }}</template></el-table-column
2026-09-17 13:36:31 +08:00
>
<el-table-column label="私人仓" min-width="220" show-overflow-tooltip
><template #default="{ row }">{{ privateWarehouse(row.first) }}</template></el-table-column
2026-09-13 21:10:05 +08:00
>
<el-table-column label="体积(m³)" width="100" align="right"
2026-09-14 20:03:27 +08:00
><template #default="{ row }">{{
2026-09-13 21:10:05 +08:00
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"
2026-09-14 20:03:27 +08:00
><template #default="{ row }">{{ text(row.first.containerNo) }}</template></el-table-column
2026-09-13 21:10:05 +08:00
>
<el-table-column label="预约送仓时间" min-width="145"
2026-09-14 20:03:27 +08:00
><template #default="{ row }">{{ dateTime(row.first.appointmentAt) }}</template></el-table-column
2026-09-13 21:10:05 +08:00
>
<el-table-column label="预计装车时间" min-width="145"
2026-09-14 20:03:27 +08:00
><template #default="{ row }">{{ dateTime(row.first.expectedLoadingAt) }}</template></el-table-column
2026-09-13 21:10:05 +08:00
>
2026-08-21 17:20:40 +08:00
</el-table>
</div>
2026-08-21 11:20:09 +08:00
</section>
2026-08-21 17:20:40 +08:00
<section class="detail-pane" aria-label="入仓单托盘和箱号详情">
2026-09-13 21:10:05 +08:00
<header>
<h3>
托盘明细
<span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span>
</h3>
<span class="detail-order-reference">订单号:<OrderReferenceText :value="text(activePallets[0]?.orderNo)" /></span>
</header>
2026-08-21 17:20:40 +08:00
<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" />
2026-09-13 21:10:05 +08:00
<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>
2026-09-13 21:10:05 +08:00
<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>
2026-08-21 17:20:40 +08:00
<el-dropdown v-else trigger="click" :disabled="!containers.length || busyPalletIds.includes(palletId(item))" @command="movePallet(item, String($event))">
2026-09-14 20:03:27 +08:00
<el-button class="density-action" data-tone="primary" :loading="busyPalletIds.includes(palletId(item))">移入 </el-button>
2026-09-13 21:10:05 +08:00
<template #dropdown
2026-09-14 20:03:27 +08:00
><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
2026-09-13 21:10:05 +08:00
></template
>
2026-08-21 17:20:40 +08:00
</el-dropdown>
2026-08-21 11:20:09 +08:00
</div>
2026-08-21 17:20:40 +08:00
<el-empty v-if="!activePallets.length" :image-size="64" description="点击左侧入仓单查看托盘和箱号" />
</div>
</section>
<aside class="container-pane" aria-label="装车托盘容器">
2026-09-13 21:10:05 +08:00
<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>
2026-08-21 17:20:40 +08:00
<div class="container-list">
2026-09-13 21:10:05 +08:00
<div
2026-09-14 20:03:27 +08:00
v-for="(container, index) in containers"
:key="container.id"
class="loading-container"
:class="{
'loading-container--overweight': containerOverweight(container),
2026-09-13 21:10:05 +08:00
}"
2026-09-14 20:03:27 +08:00
@dragover.prevent
@drop.prevent="dropInto($event, container.id)"
>
2026-09-13 21:10:05 +08:00
<div class="container-head">
<strong>{{ container.vehicleLabel }}</strong
><span
2026-09-14 20:03:27 +08:00
>托数 <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>
2026-09-13 21:10:05 +08:00
</div>
2026-08-21 17:20:40 +08:00
<div class="container-body">
2026-09-13 21:10:05 +08:00
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet">
<span
2026-09-14 20:03:27 +08:00
><strong>{{ text(palletSnapshot(id)?.palletNo || '托盘号待补充') }}</strong
2026-09-13 21:10:05 +08:00
><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>
2026-09-13 21:10:05 +08:00
</div>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘;换标选择提交时校验,其他来源移入后锁定</span>
2026-08-21 17:20:40 +08:00
</div>
</div>
<el-empty v-if="!containers.length" :image-size="64" description="请先添加装车托盘" />
2026-08-21 11:20:09 +08:00
</div>
</aside>
</div>
2026-08-21 17:20:40 +08:00
2026-09-14 20:03:27 +08:00
<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>
2026-09-14 20:03:27 +08:00
</footer></template>
2026-08-21 11:20:09 +08:00
</AppDialog>
2026-09-14 20:03:27 +08:00
<el-dialog class="admin-density-dialog" v-model="vehicleDialogOpen" title="选择车型" width="360px" append-to-body>
2026-08-25 10:34:27 +08:00
<el-radio-group v-model="selectedVehicle" class="vehicle-options">
2026-09-15 12:17:20 +08:00
<el-radio v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.value">{{ spec.label }} · 限重 {{ spec.maxWeightLb }}LB</el-radio>
2026-08-25 10:34:27 +08:00
</el-radio-group>
<template #footer><el-button @click="vehicleDialogOpen = false">取消</el-button><el-button type="primary" @click="confirmAddContainer">确定</el-button></template>
</el-dialog>
2026-08-21 11:20:09 +08:00
</template>
<style scoped>
2026-09-13 21:10:05 +08:00
.dispatch-query {
display: flex;
align-items: center;
2026-09-14 20:03:27 +08:00
gap: var(--admin-space-5);
padding: var(--admin-space-5) var(--admin-space-5);
2026-09-13 21:10:05 +08:00
border: 1px solid #e6eaf2;
2026-09-14 20:03:27 +08:00
border-radius: var(--admin-radius);
2026-09-13 21:10:05 +08:00
background: #f7f8fb;
}
.dispatch-query :deep(.el-input),
.dispatch-query :deep(.el-select) {
width: 190px;
}
.warehouse-tabs {
2026-09-14 20:03:27 +08:00
margin-top: var(--admin-space-5);
2026-09-13 21:10:05 +08:00
}
.dispatch-workbench {
display: grid;
grid-template-columns: minmax(550px, 1.5fr) minmax(330px, 0.85fr) minmax(390px, 1fr);
2026-09-14 20:03:27 +08:00
gap: var(--admin-space-5);
margin-top: var(--admin-space-5);
2026-09-13 21:10:05 +08:00
}
.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;
2026-09-14 20:03:27 +08:00
gap: var(--admin-space-4);
2026-09-13 21:10:05 +08:00
}
.detail-order-reference {
display: flex;
min-width: 0;
max-width: 240px;
align-items: baseline;
gap: 3px;
}
h3,
p {
margin: 0;
}
h3 {
2026-09-14 20:03:27 +08:00
font-size: var(--admin-font-section);
2026-09-13 21:10:05 +08:00
}
h3 span,
header > span,
p {
color: var(--cc-muted);
2026-09-14 20:03:27 +08:00
font-size: var(--admin-font-caption);
2026-09-13 21:10:05 +08:00
font-weight: 400;
}
.order-type-tabs {
2026-09-14 20:03:27 +08:00
margin-top: var(--admin-space-4);
2026-09-13 21:10:05 +08:00
}
.order-table-wrap {
min-height: 520px;
}
.detail-pane,
.container-pane {
border-left: 1px solid var(--cc-border);
2026-09-14 20:03:27 +08:00
padding-left: var(--admin-space-5);
2026-09-13 21:10:05 +08:00
}
.pallet-list,
.container-list {
height: 520px;
overflow-y: auto;
border: 1px solid var(--cc-border);
2026-09-14 20:03:27 +08:00
border-radius: var(--admin-radius);
2026-09-13 21:10:05 +08:00
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;
2026-09-14 20:03:27 +08:00
gap: var(--admin-space-1);
2026-09-13 21:10:05 +08:00
}
.pallet-copy > span,
.carton-marks > span {
color: var(--cc-muted);
2026-09-14 20:03:27 +08:00
font-size: var(--admin-font-caption);
2026-09-13 21:10:05 +08:00
}
.carton-marks {
display: flex;
min-width: 0;
flex-direction: column;
2026-09-14 20:03:27 +08:00
gap: var(--admin-space-1);
2026-09-13 21:10:05 +08:00
}
.carton-mark-line {
display: flex;
min-width: 0;
align-items: center;
2026-09-14 20:03:27 +08:00
gap: var(--admin-space-2);
2026-09-13 21:10:05 +08:00
color: var(--cc-muted);
2026-09-14 20:03:27 +08:00
font-size: var(--admin-font-caption);
2026-09-13 21:10:05 +08:00
}
.carton-mark-line > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.container-pane > .el-input {
2026-09-14 20:03:27 +08:00
margin-bottom: var(--admin-space-4);
2026-09-13 21:10:05 +08:00
}
.container-list {
height: 460px;
2026-09-14 20:03:27 +08:00
padding: var(--admin-space-3);
2026-09-13 21:10:05 +08:00
}
.loading-container {
2026-09-14 20:03:27 +08:00
margin-bottom: var(--admin-space-4);
2026-09-13 21:10:05 +08:00
border: 1px dashed #bcc6d5;
2026-09-14 20:03:27 +08:00
border-radius: var(--admin-radius);
2026-09-13 21:10:05 +08:00
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 {
2026-09-14 20:03:27 +08:00
margin-left: var(--admin-space-1);
2026-09-13 21:10:05 +08:00
color: var(--el-color-danger);
font-style: normal;
font-weight: 700;
}
.container-head span {
color: var(--cc-muted);
2026-09-14 20:03:27 +08:00
font-size: var(--admin-font-caption);
2026-09-13 21:10:05 +08:00
}
.container-head b {
color: var(--el-color-primary);
}
.container-body {
min-height: 76px;
2026-09-14 20:03:27 +08:00
padding: var(--admin-space-3);
2026-09-13 21:10:05 +08:00
}
.drop-hint {
display: grid;
min-height: 60px;
place-items: center;
color: var(--cc-muted);
2026-09-14 20:03:27 +08:00
font-size: var(--admin-font-caption);
2026-09-13 21:10:05 +08:00
}
.loaded-pallet {
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
2026-09-14 20:03:27 +08:00
gap: var(--admin-space-3);
2026-09-13 21:10:05 +08:00
border-bottom: 1px solid var(--cc-border);
}
.loaded-pallet > span {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
color: var(--cc-muted);
2026-09-14 20:03:27 +08:00
font-size: var(--admin-font-caption);
2026-09-13 21:10:05 +08:00
}
.loaded-pallet strong {
color: var(--cc-text);
2026-09-14 20:03:27 +08:00
font-size: var(--admin-font-body);
2026-09-13 21:10:05 +08:00
}
.dialog-footer > span {
color: var(--cc-muted);
2026-09-14 20:03:27 +08:00
font-size: var(--admin-font-caption);
2026-09-13 21:10:05 +08:00
}
.vehicle-options {
display: grid;
2026-09-14 20:03:27 +08:00
gap: var(--admin-space-5);
2026-09-13 21:10:05 +08:00
}
@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;
}
}
2026-09-14 20:03:27 +08:00
.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; }
2026-08-21 11:20:09 +08:00
</style>