2026-08-10 17:42:17 +08:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
|
|
import { Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
|
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
|
|
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
|
|
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
|
|
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
import {
|
|
|
|
|
adjustSmartLoadingItem,
|
|
|
|
|
assignSmartLoadingItem,
|
|
|
|
|
createSmartLoadingPlan,
|
|
|
|
|
getSmartLoadingPlanByBooking,
|
|
|
|
|
listContainers,
|
|
|
|
|
listSmartLoadingCandidates,
|
|
|
|
|
pushSmartLoadingPreload,
|
|
|
|
|
recommendSmartLoading,
|
|
|
|
|
removeSmartLoadingItem,
|
|
|
|
|
} from '../api/platformContainerLoading'
|
|
|
|
|
import type {
|
|
|
|
|
ContainerBookingResource,
|
|
|
|
|
ContainerPlanStatus,
|
|
|
|
|
SmartLoadingCandidate,
|
|
|
|
|
SmartLoadingItem,
|
|
|
|
|
SmartLoadingPlan,
|
|
|
|
|
} from '../types'
|
|
|
|
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
|
|
|
|
const containers = ref<ContainerBookingResource[]>([])
|
|
|
|
|
const selectedContainerId = ref('')
|
|
|
|
|
const plan = ref<SmartLoadingPlan | null>(null)
|
|
|
|
|
const candidates = ref<SmartLoadingCandidate[]>([])
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const selectionLoading = ref(false)
|
|
|
|
|
const mutating = ref(false)
|
|
|
|
|
const loadError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
const selectionError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
const mutationError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
const noDraft = ref(false)
|
|
|
|
|
const assignOpen = ref(false)
|
|
|
|
|
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
|
|
|
|
|
const assignForm = reactive({ packageCount: 1 })
|
|
|
|
|
const quantityDrafts = reactive<Record<string, number>>({})
|
|
|
|
|
const query = ref<QueryModel>({ orderKeyword: '', containerKeyword: '', warehouseId: '', status: '' })
|
|
|
|
|
const appliedQuery = ref<QueryModel>({ ...query.value })
|
|
|
|
|
let selectionRequest = 0
|
|
|
|
|
|
|
|
|
|
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
|
|
|
|
|
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
|
|
|
|
|
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
|
|
|
|
|
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
|
|
|
|
|
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
|
|
|
|
|
const editable = computed(() => plan.value?.status === 'EDITING')
|
|
|
|
|
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
|
|
|
|
|
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
|
|
|
|
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
|
|
|
|
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
|
|
|
|
|
const volumeLimit = computed(() => Number(selectedContainer.value?.capacityCbm || 0) * 1.1)
|
|
|
|
|
const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.1)
|
|
|
|
|
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
|
|
|
|
|
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
|
|
|
|
|
|
|
|
|
|
const warehouseOptions = computed(() => {
|
|
|
|
|
const values = new Map<string, string>()
|
|
|
|
|
for (const item of containers.value) values.set(item.warehouseId, item.warehouseName || `仓库 ${item.warehouseId}`)
|
|
|
|
|
return [...values].map(([value, label]) => ({ value, label }))
|
|
|
|
|
})
|
|
|
|
|
const queryFields = computed<QueryField[]>(() => [
|
|
|
|
|
{ key: 'orderKeyword', type: 'input', label: '订单或货物', placeholder: '订单号、入库单、客户、货物', width: 250 },
|
|
|
|
|
{ key: 'containerKeyword', type: 'input', label: '柜次', placeholder: '柜次号、集装箱号、目的仓', width: 230 },
|
|
|
|
|
{ key: 'warehouseId', type: 'select', label: '装柜仓库', options: warehouseOptions.value, width: 190 },
|
|
|
|
|
{ key: 'status', type: 'select', label: '柜次状态', width: 160, options: [
|
|
|
|
|
{ label: '可编辑', value: 'EDITING' },
|
|
|
|
|
{ label: '已推送预装', value: 'LOADING' },
|
|
|
|
|
{ label: '已完成', value: 'COMPLETED' },
|
|
|
|
|
] },
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const filteredContainers = computed(() => {
|
|
|
|
|
const keyword = String(appliedQuery.value.containerKeyword || '').trim().toLowerCase()
|
|
|
|
|
const warehouseId = String(appliedQuery.value.warehouseId || '')
|
|
|
|
|
const status = String(appliedQuery.value.status || '')
|
|
|
|
|
return containers.value.filter((item) => {
|
|
|
|
|
if (warehouseId && item.warehouseId !== warehouseId) return false
|
|
|
|
|
if (status && item.planStatus !== status) return false
|
|
|
|
|
if (!keyword) return true
|
|
|
|
|
return [item.cabinetNo, item.containerNo, item.bookingNo, item.destinationWarehouseCode, item.destinationWarehouseName]
|
|
|
|
|
.some((value) => String(value || '').toLowerCase().includes(keyword))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
const filteredCandidates = computed(() => {
|
|
|
|
|
const keyword = String(appliedQuery.value.orderKeyword || '').trim().toLowerCase()
|
|
|
|
|
if (!keyword) return candidates.value
|
|
|
|
|
return candidates.value.filter((item) => [item.orderNo, item.inboundCode, item.customerName, item.goodsName]
|
|
|
|
|
.some((value) => String(value || '').toLowerCase().includes(keyword)))
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-10 18:07:33 +08:00
|
|
|
function preferredContainerId() {
|
|
|
|
|
const visible = filteredContainers.value
|
|
|
|
|
if (String(appliedQuery.value.status || '').trim()) return visible[0]?.id || ''
|
|
|
|
|
return visible.find((item) => item.planStatus === 'EDITING')?.id || visible[0]?.id || ''
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 17:42:17 +08:00
|
|
|
const candidateColumns: DataTableColumn[] = [
|
|
|
|
|
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
|
|
|
|
{ key: 'order', label: '订单/入库单', minWidth: 170, slotName: 'order' },
|
|
|
|
|
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 150 },
|
|
|
|
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
|
|
|
|
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
|
|
|
|
|
{ key: 'unitVolumeCbm', label: '单箱体积', width: 105, slotName: 'unitVolume' },
|
|
|
|
|
{ key: 'unitWeightKg', label: '单箱重量', width: 105, slotName: 'unitWeight' },
|
|
|
|
|
{ key: 'destination', label: '目的仓', minWidth: 130, slotName: 'destination' },
|
|
|
|
|
]
|
|
|
|
|
const itemColumns: DataTableColumn[] = [
|
|
|
|
|
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
|
|
|
|
{ key: 'order', label: '订单/入库单', minWidth: 170, slotName: 'order' },
|
|
|
|
|
{ key: 'goods', label: '货物', minWidth: 160, slotName: 'goods' },
|
|
|
|
|
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
|
|
|
|
|
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
|
|
|
|
|
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
|
|
|
|
|
{ key: 'destination', label: '目的仓', minWidth: 130, slotName: 'destination' },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
function formatNumber(value: number, digits = 3) {
|
|
|
|
|
return Number.isFinite(value) ? value.toFixed(digits) : '0.000'
|
|
|
|
|
}
|
|
|
|
|
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
|
|
|
|
function itemRow(source: object) { return source as SmartLoadingItem }
|
|
|
|
|
function statusLabel(status: ContainerPlanStatus) {
|
|
|
|
|
return ({ EDITING: '可编辑', LOADING: '已推送预装', COMPLETED: '已完成' } as const)[status]
|
|
|
|
|
}
|
|
|
|
|
function statusType(status: ContainerPlanStatus) {
|
|
|
|
|
return status === 'EDITING' ? 'primary' : status === 'LOADING' ? 'warning' : 'success'
|
|
|
|
|
}
|
|
|
|
|
function routeMatches(source: object) {
|
|
|
|
|
const candidate = candidateRow(source)
|
|
|
|
|
const target = selectedContainer.value?.destinationWarehouseCode
|
|
|
|
|
return !target || target === candidate.destinationWarehouseCode
|
|
|
|
|
}
|
|
|
|
|
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
|
|
|
|
|
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
|
|
|
|
for (const item of next.items || []) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
|
|
|
|
|
}
|
|
|
|
|
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
|
|
|
|
|
plan.value = next
|
|
|
|
|
noDraft.value = false
|
|
|
|
|
syncDrafts(next, preserved)
|
|
|
|
|
const container = selectedContainer.value
|
|
|
|
|
if (container) {
|
|
|
|
|
container.planId = next.id
|
|
|
|
|
container.planStatus = next.status
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function clearSelectionData() {
|
|
|
|
|
plan.value = null
|
|
|
|
|
candidates.value = []
|
|
|
|
|
noDraft.value = false
|
|
|
|
|
selectionError.value = null
|
|
|
|
|
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadSelectedData(preserved: Record<string, number> = {}) {
|
|
|
|
|
const container = selectedContainer.value
|
|
|
|
|
const request = ++selectionRequest
|
|
|
|
|
clearSelectionData()
|
|
|
|
|
if (!container) return
|
|
|
|
|
selectionLoading.value = true
|
|
|
|
|
try {
|
|
|
|
|
const [candidateResult, planResult] = await Promise.all([
|
|
|
|
|
listSmartLoadingCandidates(container.warehouseId),
|
|
|
|
|
getSmartLoadingPlanByBooking(container.id).then((value) => ({ value, missing: false })).catch((error) => {
|
|
|
|
|
const details = getApiErrorDetails(error)
|
|
|
|
|
if (details.code === 'A0404') return { value: null, missing: true }
|
|
|
|
|
throw error
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
if (request !== selectionRequest) return
|
|
|
|
|
candidates.value = candidateResult
|
|
|
|
|
if (planResult.value) acceptPlan(planResult.value, preserved)
|
|
|
|
|
else noDraft.value = planResult.missing
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (request === selectionRequest) selectionError.value = getApiErrorDetails(error, '柜次装柜数据加载失败')
|
|
|
|
|
} finally {
|
|
|
|
|
if (request === selectionRequest) selectionLoading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
loading.value = true
|
|
|
|
|
loadError.value = null
|
|
|
|
|
try {
|
|
|
|
|
const firstPage = await listContainers({ page: 1, size: 100 })
|
|
|
|
|
const remainingPages = firstPage.pages > 1
|
|
|
|
|
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
|
|
|
|
|
: []
|
|
|
|
|
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
|
2026-08-10 18:07:33 +08:00
|
|
|
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = preferredContainerId()
|
2026-08-10 17:42:17 +08:00
|
|
|
await loadSelectedData()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
loadError.value = getApiErrorDetails(error, '智能装柜工作台加载失败')
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
async function selectContainer(containerId: string) {
|
|
|
|
|
if (selectedContainerId.value === containerId) return
|
|
|
|
|
selectedContainerId.value = containerId
|
|
|
|
|
mutationError.value = null
|
|
|
|
|
await loadSelectedData()
|
|
|
|
|
}
|
|
|
|
|
async function applyFilters(model: QueryModel) {
|
|
|
|
|
appliedQuery.value = { ...model }
|
|
|
|
|
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) {
|
2026-08-10 18:07:33 +08:00
|
|
|
selectedContainerId.value = preferredContainerId()
|
2026-08-10 17:42:17 +08:00
|
|
|
await loadSelectedData()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createPlan() {
|
|
|
|
|
const container = selectedContainer.value
|
|
|
|
|
if (!container) return
|
|
|
|
|
await mutate(async () => acceptPlan(await createSmartLoadingPlan(container.id, null)), '创建装柜计划失败')
|
|
|
|
|
}
|
|
|
|
|
function openAssign(source: object) {
|
|
|
|
|
const candidate = candidateRow(source)
|
|
|
|
|
selectedCandidate.value = candidate
|
|
|
|
|
assignForm.packageCount = Math.min(candidate.availablePackageCount, 1)
|
|
|
|
|
assignOpen.value = true
|
|
|
|
|
}
|
|
|
|
|
async function assign() {
|
|
|
|
|
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
|
|
|
|
|
await mutate(async () => {
|
|
|
|
|
acceptPlan(await assignSmartLoadingItem(plan.value!.id, {
|
|
|
|
|
inboundId: selectedCandidate.value!.inboundId,
|
|
|
|
|
inboundDetailId: selectedCandidate.value!.inboundDetailId,
|
|
|
|
|
packageCount: assignForm.packageCount,
|
|
|
|
|
expectedVersion: plan.value!.version,
|
|
|
|
|
}))
|
|
|
|
|
assignOpen.value = false
|
|
|
|
|
ElMessage.success('货物已加入当前柜次')
|
|
|
|
|
}, '分配货物失败', { preserveAssign: true })
|
|
|
|
|
}
|
|
|
|
|
async function recommend() {
|
|
|
|
|
if (!plan.value) return
|
|
|
|
|
await mutate(async () => {
|
|
|
|
|
acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version))
|
|
|
|
|
ElMessage.success('智能推荐已生成')
|
|
|
|
|
}, '智能推荐失败')
|
|
|
|
|
}
|
|
|
|
|
async function adjust(source: object) {
|
|
|
|
|
const item = itemRow(source)
|
|
|
|
|
if (!plan.value) return
|
|
|
|
|
const count = Number(quantityDrafts[item.id])
|
|
|
|
|
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
|
|
|
|
|
await mutate(async () => {
|
|
|
|
|
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
|
|
|
|
|
ElMessage.success('箱数已调整')
|
|
|
|
|
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
|
|
|
|
|
}
|
|
|
|
|
async function remove(source: object) {
|
|
|
|
|
const item = itemRow(source)
|
|
|
|
|
if (!plan.value) return
|
|
|
|
|
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.orderNo || item.id}”吗?`, '移除装柜明细', {
|
|
|
|
|
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
|
|
|
|
|
}).catch(() => false)
|
|
|
|
|
if (!confirmed || !plan.value) return
|
|
|
|
|
await mutate(async () => {
|
|
|
|
|
acceptPlan(await removeSmartLoadingItem(plan.value!.id, item.id, plan.value!.version, item.version))
|
|
|
|
|
ElMessage.success('装柜明细已移除')
|
|
|
|
|
}, '移除装柜明细失败')
|
|
|
|
|
}
|
|
|
|
|
async function pushPreload() {
|
|
|
|
|
if (!plan.value) return
|
|
|
|
|
const confirmed = await ElMessageBox.confirm('推送后当前方案进入装柜流程,不能继续调整。确认推送预装清单?', '推送预装清单', {
|
|
|
|
|
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
|
|
|
|
|
}).catch(() => false)
|
|
|
|
|
if (!confirmed || !plan.value) return
|
|
|
|
|
await mutate(async () => {
|
|
|
|
|
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
|
|
|
|
|
ElMessage.success('已推送预装清单')
|
|
|
|
|
}, '推送预装清单失败')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function mutate(
|
|
|
|
|
action: () => Promise<void>,
|
|
|
|
|
fallback: string,
|
|
|
|
|
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean } = {},
|
|
|
|
|
) {
|
|
|
|
|
mutating.value = true
|
|
|
|
|
mutationError.value = null
|
|
|
|
|
try {
|
|
|
|
|
await action()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const details = getApiErrorDetails(error, fallback)
|
|
|
|
|
if (details.code === 'A0409') {
|
|
|
|
|
await loadSelectedData(recovery.preserveDrafts)
|
|
|
|
|
mutationError.value = { ...details, message: '方案已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
|
|
|
|
|
if (recovery.preserveAssign) assignOpen.value = true
|
|
|
|
|
ElMessage.warning(mutationError.value.message)
|
|
|
|
|
} else {
|
|
|
|
|
mutationError.value = details
|
|
|
|
|
ElMessage.error(details.message)
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
mutating.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(load)
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<AdminPage>
|
|
|
|
|
<section class="global-loading-page">
|
|
|
|
|
<QueryToolbar
|
|
|
|
|
v-model="query"
|
|
|
|
|
ariaLabel="智能装柜筛选"
|
|
|
|
|
:fields="queryFields"
|
|
|
|
|
preference-key="global-smart-loading-query"
|
|
|
|
|
:search-on-reset="false"
|
|
|
|
|
@search="applyFilters"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<LoadErrorState v-if="loadError" title="智能装柜工作台加载失败" :message="loadError.message" :loading="loading" @retry="load">
|
|
|
|
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
|
|
|
|
</LoadErrorState>
|
|
|
|
|
<template v-else>
|
|
|
|
|
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新当前柜次" @retry="loadSelectedData()">
|
|
|
|
|
<template #details><ApiErrorMeta :details="mutationError" /></template>
|
|
|
|
|
</LoadErrorState>
|
|
|
|
|
|
|
|
|
|
<section class="summary-band" aria-label="当前装柜汇总">
|
|
|
|
|
<div><span>候选货物</span><strong>{{ filteredCandidates.length }}</strong></div>
|
|
|
|
|
<div><span>可见柜次</span><strong>{{ filteredContainers.length }}</strong></div>
|
|
|
|
|
<div><span>当前已装箱数</span><strong>{{ totalPackages }}</strong></div>
|
|
|
|
|
<div><span>当前总体积</span><strong>{{ formatNumber(totalVolume) }} m³</strong></div>
|
|
|
|
|
<div><span>当前总重量</span><strong>{{ formatNumber(totalWeight, 1) }} kg</strong></div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="dispatch-grid" aria-label="智能装柜调度台">
|
|
|
|
|
<section class="dispatch-pane candidate-pane">
|
|
|
|
|
<header class="pane-header"><div><strong>候选订单与货物</strong><small>{{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
|
|
|
|
|
<DataTable
|
|
|
|
|
:data="filteredCandidates"
|
|
|
|
|
:columns="candidateColumns"
|
|
|
|
|
:loading="loading || selectionLoading || mutating"
|
|
|
|
|
preference-key="global-smart-loading-candidates"
|
|
|
|
|
:empty-text="selectedContainer ? '当前仓库暂无可装货物' : '暂无可用柜次'"
|
|
|
|
|
>
|
|
|
|
|
<template #actions="{ row }">
|
|
|
|
|
<el-button
|
|
|
|
|
v-if="canAssign && editable"
|
|
|
|
|
link
|
|
|
|
|
type="primary"
|
|
|
|
|
:disabled="!routeMatches(row) || assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1"
|
|
|
|
|
@click="openAssign(row)"
|
|
|
|
|
><Plus :size="15" />加入</el-button>
|
|
|
|
|
</template>
|
|
|
|
|
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
|
|
|
|
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
|
|
|
|
|
<template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0), 1) }} kg</template>
|
|
|
|
|
<template #destination="{ row }">
|
|
|
|
|
<el-tag :type="routeMatches(row) ? 'success' : 'danger'">{{ routeMatches(row) ? '匹配' : '不匹配' }}</el-tag>
|
|
|
|
|
<small>{{ row.destinationWarehouseName || row.destinationWarehouseCode || '未设置' }}</small>
|
|
|
|
|
</template>
|
|
|
|
|
</DataTable>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
|
|
|
|
|
<header class="pane-header"><div><strong>货柜</strong><small>可用、装柜中与已完成</small></div></header>
|
|
|
|
|
<div v-loading="loading" class="container-list">
|
|
|
|
|
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
|
|
|
|
|
<button
|
|
|
|
|
v-for="item in filteredContainers"
|
|
|
|
|
:key="item.id"
|
|
|
|
|
type="button"
|
|
|
|
|
class="container-option"
|
|
|
|
|
:class="{ selected: item.id === selectedContainerId }"
|
|
|
|
|
@click="selectContainer(item.id)"
|
|
|
|
|
>
|
|
|
|
|
<span class="container-option__top"><strong>{{ item.cabinetNo }}</strong><el-tag size="small" :type="statusType(item.planStatus)">{{ statusLabel(item.planStatus) }}</el-tag></span>
|
|
|
|
|
<span>{{ item.containerNo || '集装箱号待录入' }} · {{ item.containerType }}</span>
|
|
|
|
|
<span>{{ item.warehouseName || '仓库未设置' }}</span>
|
|
|
|
|
<span>目的仓 {{ item.destinationWarehouseName || item.destinationWarehouseCode || '不限' }}</span>
|
|
|
|
|
<span v-if="item.id === selectedContainerId && plan">体积 {{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</span>
|
|
|
|
|
<span v-else>容量 {{ formatNumber(Number(item.capacityCbm || 0)) }} m³ · {{ formatNumber(Number(item.maxWeightKg || 0), 0) }} kg</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
<section class="dispatch-pane plan-pane">
|
|
|
|
|
<header class="pane-header plan-header">
|
|
|
|
|
<div><strong>{{ selectedContainer?.cabinetNo || '当前柜次' }} 装柜方案</strong><small>{{ plan ? `${plan.planNo} · 版本 ${plan.version}` : '尚未创建方案' }}</small></div>
|
|
|
|
|
<div class="plan-actions">
|
|
|
|
|
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
|
|
|
|
|
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
|
|
|
|
<el-button v-if="canPush && editable" type="primary" :disabled="!plan?.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<LoadErrorState v-if="selectionError" title="当前柜次加载失败" :message="selectionError.message" compact @retry="loadSelectedData()">
|
|
|
|
|
<template #details><ApiErrorMeta :details="selectionError" /></template>
|
|
|
|
|
</LoadErrorState>
|
|
|
|
|
<el-empty v-else-if="!selectedContainer" description="请选择一个货柜" />
|
|
|
|
|
<el-empty v-else-if="noDraft" description="当前货柜尚未创建装柜方案">
|
|
|
|
|
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
|
|
|
|
|
</el-empty>
|
|
|
|
|
<template v-else-if="plan">
|
|
|
|
|
<div class="capacity-grid">
|
|
|
|
|
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(volumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
|
|
|
|
|
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(weightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
|
|
|
|
|
</div>
|
|
|
|
|
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" preference-key="global-smart-loading-items" empty-text="当前柜次暂无已装货物">
|
|
|
|
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" link type="primary" :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable" link type="danger" @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
|
|
|
|
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
|
|
|
|
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
|
|
|
|
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
|
|
|
|
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
|
|
|
|
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
|
|
|
|
<template #destination="{ row }">{{ row.destinationWarehouseName || row.destinationWarehouseCode || '—' }}</template>
|
|
|
|
|
</DataTable>
|
|
|
|
|
</template>
|
|
|
|
|
</section>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
|
|
|
|
|
<div v-if="selectedCandidate" class="assign-dialog">
|
|
|
|
|
<p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p>
|
|
|
|
|
<p>{{ selectedCandidate.orderNo || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p>
|
|
|
|
|
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
|
|
|
|
|
</div>
|
|
|
|
|
</AppDialog>
|
|
|
|
|
</template>
|
|
|
|
|
</section>
|
|
|
|
|
</AdminPage>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
|
|
|
|
|
.summary-band { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
|
|
|
|
.summary-band > div { display: grid; gap: 4px; padding: 12px 14px; background: #fff; }
|
|
|
|
|
.summary-band span,.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
.summary-band strong { font-size: 16px; }
|
|
|
|
|
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) 270px minmax(460px, 1.35fr); min-height: 620px; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
|
|
|
|
|
.dispatch-pane { display: flex; min-width: 0; flex-direction: column; }
|
|
|
|
|
.dispatch-pane + .dispatch-pane { border-left: 1px solid var(--cc-border); }
|
|
|
|
|
.pane-header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; }
|
|
|
|
|
.pane-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
|
|
|
|
|
.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }
|
|
|
|
|
.candidate-pane :deep(.el-table),.plan-pane :deep(.el-table) { height: 100%; }
|
|
|
|
|
.container-pane { background: #fafafa; }
|
|
|
|
|
.container-list { display: grid; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
|
|
|
|
|
.container-option { display: grid; width: 100%; min-height: 118px; gap: 5px; padding: 10px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); text-align: left; cursor: pointer; }
|
|
|
|
|
.container-option:hover,.container-option:focus-visible { border-color: var(--el-color-primary); outline: none; }
|
|
|
|
|
.container-option.selected { border-color: var(--el-color-primary); box-shadow: inset 3px 0 0 var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
|
|
|
|
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text) !important; }
|
|
|
|
|
.plan-header { flex-wrap: wrap; }
|
|
|
|
|
.plan-actions,.row-actions,.meter-title { display: flex; align-items: center; gap: 8px; }
|
|
|
|
|
.plan-actions,.row-actions { flex-wrap: wrap; }
|
|
|
|
|
.plan-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
|
|
|
|
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
|
|
|
|
|
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
|
|
|
|
|
.plan-pane :deep(.el-input-number) { width: 120px; }
|
|
|
|
|
.assign-dialog p { margin: 0 0 10px; }
|
|
|
|
|
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
|
|
|
|
@media (max-width: 1180px) {
|
|
|
|
|
.dispatch-grid { grid-template-columns: 1fr; overflow: visible; }
|
|
|
|
|
.dispatch-pane + .dispatch-pane { border-top: 1px solid var(--cc-border); border-left: 0; }
|
|
|
|
|
.container-list { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); max-height: 360px; }
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 720px) {
|
|
|
|
|
.summary-band,.capacity-grid { grid-template-columns: 1fr; }
|
|
|
|
|
.pane-header,.plan-actions,.meter-title { align-items: stretch; flex-direction: column; }
|
|
|
|
|
.plan-actions :deep(.el-button) { width: 100%; min-height: 40px; margin-left: 0; }
|
|
|
|
|
.container-list { grid-template-columns: 1fr; }
|
|
|
|
|
}
|
|
|
|
|
</style>
|