Files
admin-web/src/modules/unified-orders/workflows/components/SmartLoadingWorkbench.vue
T
2026-09-01 20:07:40 +08:00

369 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Pencil, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem,
assignSmartLoadingItem,
deleteSmartLoadingItem,
getContainerBooking,
getSmartLoadingPlanByBooking,
listSmartLoadingCandidates,
recommendSmartLoading,
type ContainerBooking,
type SmartLoadingCandidate,
type SmartLoadingPlan,
type SmartLoadingPlanItem,
} from '@/modules/unified-orders/workflows/api'
const props = defineProps<{
bookingId: string
highlightInboundId?: string | number | null
readOnly?: boolean
}>()
const emit = defineEmits<{ updated: [] }>()
const access = useAccessPreviewStore()
const container = ref<ContainerBooking | null>(null)
const plan = ref<SmartLoadingPlan | null>(null)
const candidates = ref<SmartLoadingCandidate[]>([])
const loading = ref(false)
const action = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const conflictRefreshed = ref(false)
const assignOpen = ref(false)
const adjustOpen = ref(false)
const removeOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const selectedItem = ref<SmartLoadingPlanItem | null>(null)
const packageCount = ref(1)
const filters = ref<QueryModel>({ keyword: '', eligibility: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value })
const canView = computed(() => access.can('platform:firstleg:smart-loading:list'))
const canAssign = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:delete'))
const editable = computed(() => plan.value?.status === 'EDITING')
const usedVolume = computed(() => plan.value?.items.reduce((sum, item) => sum + item.volumeCbm, 0) ?? 0)
const usedWeight = computed(() => plan.value?.items.reduce((sum, item) => sum + item.grossWeightKg, 0) ?? 0)
const totalPackages = computed(() => plan.value?.items.reduce((sum, item) => sum + item.packageCount, 0) ?? 0)
const volumeRate = computed(() => utilization(usedVolume.value, container.value?.capacityCbm))
const weightRate = computed(() => utilization(usedWeight.value, container.value?.maxWeightKg))
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '候选货物', placeholder: '入仓码、客户或品名', props: { 'aria-label': '候选货物关键词' } },
{
key: 'eligibility', type: 'select', label: '可装状态', placeholder: '全部状态',
options: [{ label: '可加入', value: 'ELIGIBLE' }, { label: '存在冲突', value: 'CONFLICT' }],
},
]
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'priority', label: '推荐顺序', width: 92, slotName: 'priority' },
{ key: 'order', label: '订单/入仓码', minWidth: 180, slotName: 'order' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'goodsName', prop: 'goodsName', label: '品名', minWidth: 160 },
{ key: 'destination', label: '目的仓', minWidth: 150, slotName: 'destination' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
{ key: 'unitVolumeCbm', label: '单箱体积(m³)', width: 120, slotName: 'unitVolume' },
{ key: 'unitWeightKg', label: '单箱重量(kg)', width: 120, slotName: 'unitWeight' },
{ key: 'explanation', label: '建议与冲突说明', minWidth: 270, slotName: 'explanation' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'loadSequence', prop: 'loadSequence', label: '装柜顺序', width: 100 },
{ key: 'order', label: '订单/入仓码', minWidth: 180, slotName: 'order' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'productNameZh', prop: 'productNameZh', label: '品名', minWidth: 160 },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 130 },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 90 },
{ key: 'grossWeightKg', label: '毛重(kg)', width: 110, slotName: 'weight' },
{ key: 'volumeCbm', label: '体积(m³)', width: 110, slotName: 'volume' },
{ key: 'destination', label: '目的仓', minWidth: 150, slotName: 'destination' },
]
function utilization(used: number, capacity: number | null | undefined) {
return capacity && capacity > 0 ? Math.round(used / capacity * 1000) / 10 : null
}
function formatNumber(value: number, precision = 3) {
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: precision }) : '—'
}
function planStatusLabel(status?: string | null) {
return ({ EDITING: '预装柜中', LOADING: '拣货装柜中', COMPLETED: '装柜完成' } as Record<string, string>)[status || '']
|| (status ? '未知状态' : '尚未创建')
}
function routeMatches(candidate: SmartLoadingCandidate) {
const target = container.value?.destinationWarehouseCode
return !target || candidate.destinationWarehouseCode === target
}
function fittingCount(candidate: SmartLoadingCandidate) {
if (!routeMatches(candidate) || candidate.availablePackageCount <= 0) return 0
const volumeLeft = (container.value?.capacityCbm ?? 0) - usedVolume.value
const weightLeft = (container.value?.maxWeightKg ?? 0) - usedWeight.value
if (!container.value?.capacityCbm || !container.value.maxWeightKg) return 0
const byVolume = candidate.unitVolumeCbm > 0 ? Math.floor(Math.max(volumeLeft, 0) / candidate.unitVolumeCbm) : candidate.availablePackageCount
const byWeight = candidate.unitWeightKg > 0 ? Math.floor(Math.max(weightLeft, 0) / candidate.unitWeightKg) : candidate.availablePackageCount
return Math.max(0, Math.min(candidate.availablePackageCount, byVolume, byWeight))
}
function candidateExplanation(candidate: SmartLoadingCandidate) {
if (!routeMatches(candidate)) return `不可装:目的仓与柜次 ${container.value?.destinationWarehouseName || container.value?.destinationWarehouseCode || '目的仓'} 不一致`
if (!container.value?.capacityCbm || !container.value.maxWeightKg) return '不可装:柜次容量或最大承重尚未配置'
const count = fittingCount(candidate)
if (count <= 0) return '不可装:剩余容积或承重不足'
return `可装 ${count} 箱;同仓同目的仓,按入库完成时间先进先出推荐`
}
const filteredCandidates = computed(() => {
const keyword = String(appliedFilters.value.keyword || '').trim().toLowerCase()
const eligibility = String(appliedFilters.value.eligibility || '')
return candidates.value.filter((candidate) => {
const searchable = [candidate.inboundCode, candidate.customerName, candidate.goodsName].join(' ').toLowerCase()
if (keyword && !searchable.includes(keyword)) return false
const eligible = fittingCount(candidate) > 0
return !eligibility || (eligibility === 'ELIGIBLE' ? eligible : !eligible)
})
})
function isConflict(error: unknown) {
const details = getApiErrorDetails(error)
return error instanceof ApiResponseError && error.code === 'A0409'
|| details.code === 'A0409'
|| /版本|并发|已被其他人修改/.test(details.message)
}
async function load() {
if (!canView.value || !props.bookingId) return
loading.value = true
loadError.value = null
try {
const nextContainer = await getContainerBooking(props.bookingId)
container.value = nextContainer
try {
plan.value = await getSmartLoadingPlanByBooking(props.bookingId)
} catch (error) {
const details = getApiErrorDetails(error)
if (error instanceof ApiResponseError && ['A0404', 'NOT_FOUND'].includes(error.code)) plan.value = null
else throw Object.assign(error instanceof Error ? error : new Error(details.message), { details })
}
candidates.value = nextContainer.warehouseId ? await listSmartLoadingCandidates(nextContainer.id, nextContainer.warehouseId) : []
} catch (error) {
container.value = null
plan.value = null
candidates.value = []
loadError.value = getApiErrorDetails(error, '智能排柜数据加载失败,请稍后重试')
} finally {
loading.value = false
}
}
async function runAction(name: string, operation: () => Promise<SmartLoadingPlan>, success: string) {
action.value = name
actionError.value = null
conflictRefreshed.value = false
try {
plan.value = await operation()
ElMessage.success(success)
emit('updated')
if (container.value?.warehouseId) candidates.value = await listSmartLoadingCandidates(container.value.id, container.value.warehouseId)
return true
} catch (error) {
actionError.value = getApiErrorDetails(error, `${success}失败,请重试`)
if (isConflict(error)) {
await load()
conflictRefreshed.value = true
}
return false
} finally {
action.value = ''
}
}
async function recommend() {
if (!plan.value) return
await runAction('recommend', () => recommendSmartLoading(plan.value!.id, plan.value!.version), '智能建议已生成')
}
function openAssign(candidate: SmartLoadingCandidate) {
selectedCandidate.value = candidate
packageCount.value = Math.max(1, fittingCount(candidate))
assignOpen.value = true
}
async function confirmAssign() {
if (!plan.value || !selectedCandidate.value) return
const candidate = selectedCandidate.value
const success = await runAction('assign', () => assignSmartLoadingItem(plan.value!.id, {
inboundId: candidate.inboundId,
inboundDetailId: candidate.inboundDetailId,
packageCount: packageCount.value,
expectedVersion: plan.value!.version,
}), '候选货物已加入装柜计划')
if (success) assignOpen.value = false
}
function openAdjust(item: SmartLoadingPlanItem) {
selectedItem.value = item
packageCount.value = item.packageCount
adjustOpen.value = true
}
async function confirmAdjust() {
if (!plan.value || !selectedItem.value) return
const item = selectedItem.value
const success = await runAction('adjust', () => adjustSmartLoadingItem(plan.value!.id, item.id, {
packageCount: packageCount.value,
expectedPlanVersion: plan.value!.version,
expectedItemVersion: item.version,
}), '装柜箱数已调整')
if (success) adjustOpen.value = false
}
function openRemove(item: SmartLoadingPlanItem) {
selectedItem.value = item
removeOpen.value = true
}
async function confirmRemove() {
if (!plan.value || !selectedItem.value) return
const item = selectedItem.value
const success = await runAction('remove', () => deleteSmartLoadingItem(plan.value!.id, item.id, {
expectedPlanVersion: plan.value!.version,
expectedItemVersion: item.version,
}), '装柜明细已移除')
if (success) removeOpen.value = false
}
function applyFilters(model: QueryModel) {
appliedFilters.value = { keyword: model.keyword || '', eligibility: model.eligibility || '' }
}
watch(() => [props.bookingId, access.permissionSignature], () => { void load() })
onMounted(() => { void load() })
</script>
<template>
<section class="smart-loading" aria-label="智能排柜工作台">
<header class="smart-loading__heading">
<div class="smart-loading__context"><span>当前柜次</span><strong>{{ container?.cabinetNo || container?.containerNo || '柜次信息待补充' }}</strong><small>{{ container?.warehouseName || '仓库待加载' }}</small></div>
<div class="smart-loading__actions">
<el-button circle :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button>
<el-tag v-if="!plan" type="danger">自动预装计划缺失</el-tag>
<el-button v-if="plan" :disabled="!canAssign || !editable || Boolean(action)" :loading="action === 'recommend'" @click="recommend"><Sparkles :size="15" />生成智能建议</el-button>
</div>
</header>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>当前账号无智能排柜查询权限。</el-alert>
<LoadErrorState v-else-if="loadError" title="智能排柜暂时无法加载" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else>
<el-alert v-if="actionError" class="smart-loading__error" type="error" :closable="false" show-icon>
<template #title>{{ actionError.message }}</template>
<p v-if="conflictRefreshed">系统已刷新最新计划,当前输入仍保留,请核对后重新提交。</p>
<ApiErrorMeta :details="actionError" />
</el-alert>
<div v-if="container" class="smart-loading__summary">
<div><span>计划状态</span><strong>{{ planStatusLabel(plan?.status) }}</strong></div>
<div><span>已装箱数</span><strong>{{ totalPackages }}</strong></div>
<div><span>容积</span><strong>{{ formatNumber(usedVolume) }} / {{ formatNumber(container.capacityCbm || 0) }} m³</strong><small>{{ volumeRate == null ? '容量待配置' : `${volumeRate}%` }}</small></div>
<div><span>重量</span><strong>{{ formatNumber(usedWeight) }} / {{ formatNumber(container.maxWeightKg || 0) }} kg</strong><small>{{ weightRate == null ? '承重待配置' : `${weightRate}%` }}</small></div>
</div>
<section v-if="plan" class="smart-loading__section surface-panel table-panel">
<div class="smart-loading__section-title"><h4>当前装柜方案</h4><span>版本 {{ plan.version }} · {{ plan.planNo || '—' }}</span></div>
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading" empty-text="装柜草稿暂无明细">
<template #actions="{ row }">
<el-button size="small" plain type="primary" :disabled="!canAssign || !editable || Boolean(action)" @click="openAdjust(row as SmartLoadingPlanItem)"><Pencil :size="14" />调整</el-button>
<el-button size="small" plain type="danger" :disabled="!canDelete || !editable || Boolean(action)" @click="openRemove(row as SmartLoadingPlanItem)"><Trash2 :size="13" />移除</el-button>
</template>
<template #order="{ row }"><strong>{{ (row as SmartLoadingPlanItem).inboundCode || '—' }}</strong></template>
<template #weight="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).grossWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).volumeCbm) }}</template>
<template #destination="{ row }">{{ (row as SmartLoadingPlanItem).destinationWarehouseName || (row as SmartLoadingPlanItem).destinationWarehouseCode || '—' }}</template>
</DataTable>
</section>
<section class="smart-loading__section">
<div class="smart-loading__section-title"><h4>候选货物</h4><span>后端按入库完成时间排序</span></div>
<ListPageLayout ariaLabel="智能排柜候选货物">
<template #toolbar><QueryToolbar v-model="filters" ariaLabel="候选货物筛选" :fields="queryFields" @search="applyFilters" /></template>
<section class="surface-panel table-panel">
<DataTable :data="filteredCandidates" :columns="candidateColumns" :loading="loading" empty-text="当前柜次仓库暂无可用候选货物">
<template #actions="{ row }"><el-button size="small" plain type="primary" :disabled="!plan || !canAssign || !editable || fittingCount(row as SmartLoadingCandidate) <= 0 || Boolean(action)" @click="openAssign(row as SmartLoadingCandidate)"><Plus :size="14" />加入</el-button></template>
<template #priority="{ $index }"><span class="priority">{{ Number($index) + 1 }}</span></template>
<template #order="{ row }"><strong>{{ (row as SmartLoadingCandidate).inboundCode || '—' }}</strong></template>
<template #destination="{ row }">{{ (row as SmartLoadingCandidate).destinationWarehouseName || (row as SmartLoadingCandidate).destinationWarehouseCode || '—' }}</template>
<template #unitVolume="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitVolumeCbm) }}</template>
<template #unitWeight="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitWeightKg) }}</template>
<template #explanation="{ row }"><el-tag size="small" :type="fittingCount(row as SmartLoadingCandidate) > 0 ? 'success' : 'danger'">{{ fittingCount(row as SmartLoadingCandidate) > 0 ? '可加入' : '冲突' }}</el-tag><span>{{ candidateExplanation(row as SmartLoadingCandidate) }}</span></template>
</DataTable>
</section>
</ListPageLayout>
</section>
</template>
<AppDialog v-model="assignOpen" title="加入装柜方案" :confirm-loading="action === 'assign'" :confirm-disabled="packageCount < 1 || packageCount > (selectedCandidate ? fittingCount(selectedCandidate) : 0)" confirm-text="确认加入" @confirm="confirmAssign">
<p>{{ selectedCandidate?.inboundCode || '入仓码暂缺' }} · 最多可加入 {{ selectedCandidate ? fittingCount(selectedCandidate) : 0 }} 箱</p>
<el-input-number v-model="packageCount" :min="1" :max="selectedCandidate ? fittingCount(selectedCandidate) : 1" controls-position="right" aria-label="加入箱数" />
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="adjustOpen" title="调整装柜箱数" :confirm-loading="action === 'adjust'" :confirm-disabled="packageCount < 1" confirm-text="保存调整" @confirm="confirmAdjust">
<p>{{ selectedItem?.inboundCode || '入仓码暂缺' }} · 当前 {{ selectedItem?.packageCount || 0 }} 箱</p>
<el-input-number v-model="packageCount" :min="1" controls-position="right" aria-label="调整后箱数" />
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="removeOpen" title="移除装柜明细" confirm-type="danger" confirm-text="确认移除" :confirm-loading="action === 'remove'" @confirm="confirmRemove">
<p>将从当前草稿移除 {{ selectedItem?.inboundCode || '该入仓单' }} 的 {{ selectedItem?.packageCount || 0 }} 箱货物。此操作会写入审计历史。</p>
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
</section>
</template>
<style scoped>
.smart-loading { display: grid; gap: 14px; }
.smart-loading__heading, .smart-loading__actions, .smart-loading__section-title { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.smart-loading__heading, .smart-loading__section-title { justify-content: space-between; }
.smart-loading__section-title h4 { margin: 0; font-size: 15px; }
.smart-loading__context { display: grid; min-width: 0; gap: 3px; }
.smart-loading__context span { color: var(--cc-text-secondary, #64748b); font-size: 11px; }
.smart-loading__context strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.smart-loading__section-title span, .smart-loading small { display: block; margin: 3px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.smart-loading__summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.smart-loading__summary > div { min-width: 0; padding: 10px; border: 1px solid var(--cc-border); border-radius: 5px; background: var(--el-fill-color-extra-light); }
.smart-loading__summary span { display: block; margin-bottom: 4px; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.smart-loading__summary strong { display: block; overflow-wrap: anywhere; font-size: 13px; }
.smart-loading__section { display: grid; min-width: 0; gap: 10px; padding-top: 4px; overflow: hidden; }
.smart-loading__error p { margin: 6px 0 0; }
.smart-loading :deep(.el-table__cell small), .smart-loading :deep(.el-table__cell strong) { display: block; }
.smart-loading :deep(.el-table__cell small) { margin-top: 2px; color: var(--cc-text-secondary, #64748b); }
.smart-loading :deep(.el-table__cell .el-tag + span) { margin-left: 6px; }
.priority { display: inline-grid; width: 26px; height: 26px; place-items: center; border-radius: 50%; background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-weight: 700; }
@media (max-width: 760px) {
.smart-loading__summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.smart-loading__actions { width: 100%; }
.smart-loading__actions :deep(.el-button:not(.is-circle)) { flex: 1 1 150px; margin-left: 0; }
}
@media (max-width: 420px) { .smart-loading__summary { grid-template-columns: 1fr; } }
@media (max-width: 360px) { .smart-loading :deep(.el-table__cell .el-button) { width: 100%; min-height: 40px; } }
</style>