Files
admin-web/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue
T

417 lines
31 KiB
Vue
Raw Normal View History

2026-08-10 15:36:53 +08:00
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
2026-09-03 16:48:25 +08:00
import { ArrowLeft, ListChecks, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
2026-08-10 15:36:53 +08:00
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'
2026-09-03 21:25:56 +08:00
import LineClampText from '@/shared/components/LineClampText.vue'
2026-08-10 15:36:53 +08:00
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
2026-09-01 20:07:40 +08:00
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
getSmartLoadingPlanByBooking, listSmartLoadingCandidates,
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
2026-08-10 15:36:53 +08:00
} from '../api/platformContainerLoading'
2026-08-24 18:07:38 +08:00
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
2026-08-31 10:48:23 +08:00
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
2026-09-03 15:00:58 +08:00
import { resolveSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
2026-08-10 15:36:53 +08:00
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const containerId = computed(() => String(route.params.containerId || ''))
const container = ref<ContainerBookingResource | null>(null)
const plan = ref<SmartLoadingPlan | null>(null)
const candidates = ref<SmartLoadingCandidate[]>([])
const loading = ref(false)
const mutating = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
2026-08-10 15:36:53 +08:00
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
2026-08-31 10:48:23 +08:00
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
2026-08-10 15:36:53 +08:00
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
2026-08-17 16:45:46 +08:00
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
2026-08-21 17:07:37 +08:00
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
2026-09-03 15:00:58 +08:00
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
for (const member of members) {
2026-08-30 17:43:03 +08:00
const current = orders.get(member.orderId) || { label: member.orderNo || '订单编码待补充', inboundCodes: [] }
const inboundCode = member.inboundCode || '入仓码暂缺'
if (!current.inboundCodes.includes(inboundCode)) current.inboundCodes.push(inboundCode)
orders.set(member.orderId, current)
}
return [...orders.values()].map((order) => `${order.label}:${order.inboundCodes.join('、')}`)
}
2026-08-31 10:48:23 +08:00
function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
return group.inboundFragmentCount === group.memberOrderCount
? `${group.memberOrderCount} 票`
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
2026-09-03 15:00:58 +08:00
return candidates.value.map((candidate) => {
2026-09-03 21:25:56 +08:00
const members: Array<{ inboundId: string | null; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length
2026-09-03 15:00:58 +08:00
? candidate.groupMembers
: [candidate]
return {
...candidate,
2026-09-03 21:25:56 +08:00
memberInboundIds: members.map((member) => member.inboundId).filter((id): id is string => Boolean(id)),
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(members),
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
2026-09-03 15:00:58 +08:00
inboundFragmentCount: Number(candidate.memberCount || members.length),
}
})
})
2026-08-31 10:48:23 +08:00
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
const grouped = new Map<string, SmartLoadingInboundItem[]>()
for (const row of inboundRows) {
const group = planGroupsByInboundId.value.get(row.inboundId)
if (!group) {
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
continue
}
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
}
for (const rows of grouped.values()) {
const first = rows[0]
const group = planGroupsByInboundId.value.get(first.inboundId)!
result.push({
...first,
id: `coload:${group.groupId}`,
inboundCode: rows.map((row) => row.inboundCode).filter(Boolean).join('、'),
customerName: [...new Set(rows.map((row) => row.customerName).filter(Boolean))].join('、'),
skuCode: [...new Set(rows.map((row) => row.skuCode).filter(Boolean))].join('、'),
productNameZh: [...new Set(rows.map((row) => row.productNameZh).filter(Boolean))].join('、'),
packageCount: rows.reduce((sum, row) => sum + row.packageCount, 0),
productQuantity: rows.reduce((sum, row) => sum + Number(row.productQuantity || 0), 0),
grossWeightKg: rows.reduce((sum, row) => sum + Number(row.grossWeightKg || 0), 0),
volumeCbm: rows.reduce((sum, row) => sum + Number(row.volumeCbm || 0), 0),
pickedPackageCount: rows.reduce((sum, row) => sum + row.pickedPackageCount, 0),
minPackageCount: rows.reduce((sum, row) => sum + row.minPackageCount, 0),
canAdjust: false,
canRemove: rows.every((row) => row.canRemove),
sourceItems: rows.flatMap((row) => row.sourceItems),
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
coLoadGroup: group,
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(rows),
})
}
return result
})
2026-08-10 15:36:53 +08:00
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))
2026-09-03 21:50:28 +08:00
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.5)
const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.5)
2026-08-10 15:36:53 +08:00
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
2026-08-11 20:02:38 +08:00
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
2026-08-10 15:36:53 +08:00
const itemColumns: DataTableColumn[] = [
2026-09-03 16:48:25 +08:00
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
2026-08-10 15:36:53 +08:00
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
2026-08-14 19:59:32 +08:00
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
2026-09-03 21:25:56 +08:00
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
2026-08-10 15:36:53 +08:00
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
{ key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' },
{ key: 'volumeCbm', label: '体积', width: 120, slotName: 'volume' },
{ key: 'grossWeightKg', label: '毛重', width: 130, slotName: 'weight' },
]
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
2026-08-21 17:07:37 +08:00
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
2026-09-03 21:25:56 +08:00
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
2026-08-21 17:07:37 +08:00
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, showOverflowTooltip: true },
2026-08-10 15:36:53 +08:00
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
2026-08-21 17:07:37 +08:00
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 110, slotName: 'cartonIdentityStatus' },
{ key: 'averageUnitVolumeCbm', label: '平均单箱体积', width: 145, slotName: 'averageUnitVolume' },
2026-08-21 16:28:59 +08:00
{ key: 'unitGrossWeightKg', label: '平均单箱毛重', width: 140, slotName: 'unitGrossWeight' },
2026-08-10 15:36:53 +08:00
]
2026-08-11 20:02:38 +08:00
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
2026-08-24 18:07:38 +08:00
function itemRow(source: object) { return source as SmartLoadingInboundItem }
2026-08-10 15:36:53 +08:00
function explainCandidate(source: object) {
const candidate = candidateRow(source)
2026-08-21 17:07:37 +08:00
const volume = Number(candidate.averageUnitVolumeCbm || 0)
2026-08-21 16:28:59 +08:00
const weight = Number(candidate.unitGrossWeightKg || 0)
2026-08-10 15:36:53 +08:00
const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
2026-09-03 21:50:28 +08:00
return `按 150% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
2026-08-10 15:36:53 +08:00
}
function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
if (!value) return '待生成'
const cabinetNo = String(value.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetNo) ? '待生成' : cabinetNo || '待生成'
}
2026-08-14 19:09:54 +08:00
function displayContainerNo(value: ContainerBookingResource | null | undefined) {
const containerNo = String(value?.containerNo || '').trim()
return containerNo && !/^(?:MAN|SPLIT)-/i.test(containerNo) ? containerNo : '待补录'
2026-08-14 19:09:54 +08:00
}
2026-08-17 16:45:46 +08:00
function syncDrafts(preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
2026-08-24 18:07:38 +08:00
for (const item of displayedPlanItems.value) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
2026-08-17 16:45:46 +08:00
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { plan.value = next; noDraft.value = false; syncDrafts(preserved) }
2026-08-31 10:48:23 +08:00
async function acceptPlanWithGroups(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
acceptPlan(next, preserved)
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(next)
syncDrafts(preserved)
}
2026-08-10 15:36:53 +08:00
async function load() {
loading.value = true; loadError.value = null; noDraft.value = false
planGroupsByInboundId.value = new Map()
2026-08-10 15:36:53 +08:00
try {
container.value = await getContainer(containerId.value)
2026-08-17 16:45:46 +08:00
if (container.value.planStatus === 'COMPLETED') {
plan.value = null
candidates.value = []
return
}
2026-08-31 10:48:23 +08:00
try { await acceptPlanWithGroups(await getSmartLoadingPlanByBooking(containerId.value)) }
2026-08-10 15:36:53 +08:00
catch (error) {
const detail = getApiErrorDetails(error)
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
else throw error
}
2026-09-03 15:00:58 +08:00
const channelGroup = resolveSmartLoadingChannelGroup(container.value.channelCode, container.value.channelName)
candidates.value = plan.value?.canAssign && channelGroup
? (await listSmartLoadingCandidates({ channelGroupCodes: [channelGroup.code], warehouseId: container.value.warehouseId, page: 1, size: 200 })).records
2026-08-17 16:45:46 +08:00
: []
2026-08-10 15:36:53 +08:00
} catch (error) { loadError.value = getApiErrorDetails(error, '智能排柜工作台加载失败') }
finally { loading.value = false }
}
function openOperationalList() {
if (!container.value || container.value.planStatus === 'EDITING') return
void router.push({
name: container.value.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
query: { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
})
}
function openAssign(source: object) {
const candidate = candidateRow(source)
if (candidateBlocked(candidate)) return
selectedCandidate.value = candidate
assignForm.packageCount = candidate.coLoadGroupId ? Number(candidate.groupTotalBoxes || candidate.availablePackageCount) : Math.min(candidate.availablePackageCount, 1)
assignOpen.value = true
}
2026-08-10 15:36:53 +08:00
async function assign() {
2026-09-03 21:25:56 +08:00
const currentPlan = plan.value
const selected = selectedCandidate.value
const inboundId = selected?.inboundId
if (!currentPlan || !selected || !inboundId || assignForm.packageCount < 1) return
if (selected.coLoadGroupId && (selected.blockReason || selected.locked)) return
2026-08-10 15:36:53 +08:00
mutating.value = true
mutationError.value = null
try {
const next = selected.coLoadGroupId
2026-09-03 21:25:56 +08:00
? await assignSmartLoadingGroup(currentPlan.id, inboundId, currentPlan.version)
: await assignSmartLoadingItem(currentPlan.id, { inboundId, packageCount: assignForm.packageCount, expectedVersion: currentPlan.version })
2026-08-31 10:48:23 +08:00
await acceptPlanWithGroups(next)
assignOpen.value = false
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '货物已分配')
} catch (error) { await handleMutationError(error, '分配失败') }
2026-08-10 15:36:53 +08:00
finally { mutating.value = false }
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
return Boolean(candidate.blockReason) || candidate.locked === true
2026-09-03 21:25:56 +08:00
|| !candidate.inboundId
2026-09-03 15:00:58 +08:00
|| candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId))
|| candidate.availablePackageCount < 1
}
2026-08-10 15:36:53 +08:00
async function recommend() {
if (!plan.value) return
mutating.value = true
mutationError.value = null
2026-08-22 10:02:13 +08:00
try {
try {
2026-08-31 10:48:23 +08:00
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
2026-08-22 10:02:13 +08:00
} catch (error) {
const details = getApiErrorDetails(error, '智能推荐失败')
if (details.code !== 'A0409' || !/(版本|并发|已被修改|已变化)/.test(details.message)) throw error
await load()
if (!plan.value) throw error
2026-08-31 10:48:23 +08:00
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
2026-08-22 10:02:13 +08:00
}
2026-09-01 20:07:40 +08:00
ElMessage.success('智能建议已生成,请人工核对明细')
2026-08-22 10:02:13 +08:00
}
catch (error) { await handleMutationError(error, '智能推荐失败') }
2026-08-10 15:36:53 +08:00
finally { mutating.value = false }
}
async function adjust(source: object) {
const item = itemRow(source)
2026-09-04 12:18:39 +08:00
if (!plan.value || mutating.value) return
2026-08-10 15:36:53 +08:00
const count = Number(quantityDrafts[item.id])
2026-09-04 12:18:39 +08:00
if (count === item.packageCount) return
2026-08-17 16:45:46 +08:00
if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`); return }
2026-08-10 15:36:53 +08:00
mutating.value = true
mutationError.value = null
2026-08-31 10:48:23 +08:00
try { await acceptPlanWithGroups(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
catch (error) { await handleMutationError(error, '调整失败') }
2026-08-10 15:36:53 +08:00
finally { mutating.value = false }
}
async function remove(source: object) {
const item = itemRow(source)
2026-08-17 16:45:46 +08:00
if (!plan.value || !item.canRemove) return
const row = source as SmartLoadingPlanRow
const message = row.coLoadGroup
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
: `确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`
await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
2026-08-10 15:36:53 +08:00
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
2026-08-24 18:07:38 +08:00
try {
if (row.coLoadGroup) {
2026-08-31 10:48:23 +08:00
await acceptPlanWithGroups(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
await load()
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
return
}
2026-08-24 18:07:38 +08:00
let current = plan.value!
for (const original of item.sourceItems) {
const currentItem = current.items.find((value) => value.id === original.id)
if (!currentItem) continue
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
2026-08-31 10:48:23 +08:00
await acceptPlanWithGroups(current)
2026-08-24 18:07:38 +08:00
}
await load()
ElMessage.success('该入仓码已从装柜方案移除')
}
catch (error) { await handleMutationError(error, '移除失败', true) }
2026-08-10 15:36:53 +08:00
finally { mutating.value = false }
})
}
async function handleMutationError(error: unknown, fallback: string, pickingConflict = false) {
2026-08-10 15:36:53 +08:00
const details = getApiErrorDetails(error, fallback)
if (details.code === 'A0409') {
const preserved = { ...quantityDrafts }
await load()
2026-08-17 16:45:46 +08:00
syncDrafts(preserved)
}
const pickingMessage = pickingConflict && details.code === 'A0409' && details.message.includes('拣货')
2026-08-11 11:52:30 +08:00
? '该订单在拣货中不许删除'
: null
2026-08-22 10:02:13 +08:00
mutationError.value = { ...details, message: pickingMessage || details.message || (details.code === 'A0409' ? '计划或 PDA 明细已变化,已刷新最新版本并保留本次输入,请重新操作' : details.message) }
2026-08-10 15:36:53 +08:00
ElMessage.error(mutationError.value.message)
}
onMounted(load)
</script>
<template>
<AdminPage>
<section class="business-page workbench">
2026-08-10 16:24:54 +08:00
<section class="surface-panel workbench-command-bar" aria-label="排柜作业摘要">
<div class="workbench-identities">
<div class="workbench-identity">
<span>柜次号</span>
<strong>{{ loading ? '正在加载' : displayCabinetNo(container) }}</strong>
</div>
<div class="workbench-identity">
<span>集装箱号</span>
<strong>{{ loading ? '正在加载' : displayContainerNo(container) }}</strong>
</div>
2026-08-10 16:24:54 +08:00
</div>
<div class="header-actions">
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
2026-08-17 16:45:46 +08:00
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
2026-08-10 16:24:54 +08:00
</div>
</section>
2026-08-10 15:36:53 +08:00
<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="load"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<section v-if="container?.planStatus === 'COMPLETED'" class="readonly-status-card" aria-label="装柜完成状态">
<el-tag type="success">装柜完成</el-tag>
<strong>该柜次已完成装柜</strong>
<p>智能装柜方案已锁定,不能继续调整。请进入装柜清单查看完整记录。</p>
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看装柜清单</el-button>
</section>
<template v-else>
2026-08-14 19:09:54 +08:00
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div></section>
2026-09-03 21:50:28 +08:00
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 150% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 150% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
2026-09-01 20:07:40 +08:00
<el-empty v-if="noDraft" description="该柜次缺少自动生成的预装计划,请刷新后重试" />
2026-08-10 15:36:53 +08:00
<template v-else-if="plan">
2026-08-17 16:45:46 +08:00
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
2026-08-24 18:07:38 +08:00
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
2026-08-14 19:09:54 +08:00
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
2026-09-03 16:48:25 +08:00
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
2026-08-14 19:59:32 +08:00
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
2026-09-03 21:25:56 +08:00
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
2026-09-04 10:12:41 +08:00
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></div><LineClampText v-else content="普通单票" /></template>
2026-08-10 15:36:53 +08:00
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
2026-09-04 12:18:39 +08:00
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :controls="false" :disabled="mutating || !canAssign || !editable || !row.canAdjust" @blur="adjust(row)" @keyup.enter="adjust(row)" /><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
2026-08-10 15:36:53 +08:00
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
</DataTable>
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
2026-09-03 21:50:28 +08:00
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
2026-09-03 21:25:56 +08:00
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
2026-09-04 10:12:41 +08:00
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
2026-08-21 17:07:37 +08:00
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
2026-08-10 15:36:53 +08:00
</DataTable>
</template>
</template>
2026-08-10 15:36:53 +08:00
</template>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ 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><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
2026-08-10 15:36:53 +08:00
</section>
</AdminPage>
</template>
<style scoped>
.workbench { display: grid; gap: 16px; min-width: 0; }
2026-08-10 16:24:54 +08:00
.workbench-command-bar,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
.workbench-command-bar { justify-content: space-between; gap: 16px; padding: 14px 16px; }
.workbench-identities { display: flex; min-width: 0; align-items: center; gap: 32px; }
2026-08-10 16:24:54 +08:00
.workbench-identity { display: grid; min-width: 0; gap: 3px; }
.workbench-identity span { color: var(--cc-muted); font-size: 11px; }
.workbench-identity strong { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
.workbench h2,.workbench h2 + p { margin: 0; }
.workbench h2 + p,.workbench small { display: block; margin-top: 4px; color: var(--cc-muted); }
2026-08-10 15:36:53 +08:00
.header-actions,.row-actions { flex-wrap: wrap; gap: 8px; }
.header-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
2026-08-14 19:59:32 +08:00
.summary-band { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
2026-08-10 15:36:53 +08:00
.summary-band>div { display: grid; gap: 5px; padding: 14px; background: #fff; }
.summary-band span { color: var(--cc-muted); font-size: 12px; }
.capacity-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.readonly-status-card { display: grid; justify-items: start; gap: 10px; padding: 24px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
2026-08-10 15:36:53 +08:00
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
.row-actions { min-width: 150px; }
2026-09-03 16:48:25 +08:00
.package-count-editor { display: grid; min-width: 0; gap: 4px; }
2026-08-27 21:48:15 +08:00
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
2026-08-10 15:36:53 +08:00
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
2026-09-04 10:12:41 +08:00
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 3px; }
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; color: var(--cc-muted); font-size: 11px; }
.candidate-group .candidate-block { color: var(--el-color-danger); }
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
2026-08-10 15:36:53 +08:00
</style>