This commit is contained in:
2026-08-10 15:36:53 +08:00
parent 5d97c7517a
commit f6556c57b5
54 changed files with 4107 additions and 170 deletions
@@ -0,0 +1,377 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Check, 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 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,
createSmartLoadingPlan,
deleteSmartLoadingItem,
getContainerBooking,
getSmartLoadingPlanByBooking,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
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 pushOpen = 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 canCreate = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:create'))
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 canPush = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:push'))
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 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.orderNo, 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.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.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 createPlan() {
await runAction('create', () => createSmartLoadingPlan(props.bookingId), '装柜草稿已创建')
}
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
}
async function confirmPush() {
if (!plan.value) return
const success = await runAction('push', () => pushSmartLoadingPreload(plan.value!.id, plan.value!.version), '装柜计划已推送预装')
if (success) pushOpen.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-labelledby="smart-loading-title">
<header class="smart-loading__heading">
<div>
<h3 id="smart-loading-title">智能排柜</h3>
<p>{{ container?.cabinetNo || container?.containerNo || `柜次 ${bookingId}` }} · {{ container?.warehouseName || '仓库待加载' }}</p>
</div>
<div class="smart-loading__actions">
<el-button circle :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button>
<el-button v-if="!plan" type="primary" :disabled="!canCreate || loading" :loading="action === 'create'" @click="createPlan"><Plus :size="15" />创建装柜草稿</el-button>
<el-button v-if="plan" :disabled="!canAssign || !editable || Boolean(action)" :loading="action === 'recommend'" @click="recommend"><Sparkles :size="15" />生成智能建议</el-button>
<el-button v-if="plan" type="primary" :disabled="!canPush || !editable || !plan.items.length || !container?.containerNo || Boolean(action)" @click="pushOpen = true"><Check :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>{{ 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">
<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 link type="primary" :disabled="!canAssign || !editable || Boolean(action)" @click="openAdjust(row as SmartLoadingPlanItem)">调整</el-button>
<el-button link 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).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingPlanItem).inboundCode || '—' }}</small></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>
<QueryToolbar v-model="filters" ariaLabel="候选货物筛选" :fields="queryFields" @search="applyFilters" @reset="applyFilters" />
<DataTable :data="filteredCandidates" :columns="candidateColumns" :loading="loading" empty-text="当前柜次仓库暂无可用候选货物">
<template #actions="{ row }"><el-button link type="primary" :disabled="!plan || !canAssign || !editable || fittingCount(row as SmartLoadingCandidate) <= 0 || Boolean(action)" @click="openAssign(row as SmartLoadingCandidate)">加入</el-button></template>
<template #priority="{ $index }"><span class="priority">{{ Number($index) + 1 }}</span></template>
<template #order="{ row }"><strong>{{ (row as SmartLoadingCandidate).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingCandidate).inboundCode || '—' }}</small></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>
</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?.orderNo || 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?.orderNo || 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?.orderNo || selectedItem?.inboundCode }} 的 {{ selectedItem?.packageCount || 0 }} 箱货物。此操作会写入审计历史。</p>
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="pushOpen" title="推送预装清单" confirm-type="primary" confirm-text="确认推送" :confirm-loading="action === 'push'" @confirm="confirmPush">
<p>推送后装柜计划将进入装柜中,当前草稿明细不可再增删调整。请确认柜号和货物分配无误。</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__heading h3, .smart-loading__section-title h4 { margin: 0; font-size: 15px; }
.smart-loading__heading p, .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; gap: 10px; padding-top: 4px; }
.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; } }
</style>
@@ -0,0 +1,97 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import UnifiedOrderWorkflowPanel from './UnifiedOrderWorkflowPanel.vue'
import {
getOutboundDispatch,
getPreload,
smartSplit,
} from '../api'
vi.mock('../api', async (importOriginal) => {
const original = await importOriginal<typeof import('../api')>()
return {
...original,
getOutboundDispatch: vi.fn(),
getPreload: vi.fn(),
smartSplit: vi.fn(),
}
})
function preload(cargoReference: string, version = 3) {
return {
id: '2086085512165634050', inboundId: '2086085512165634051', tenantId: '2086085512165634052',
status: 'PENDING', version,
items: [{ containerCode: 'CAB-01', cargoReference, packageCount: 2, volumeCbm: 1.25, remark: null }],
remark: null, createdAt: null, updatedAt: null,
}
}
function mountPanel(permissions: string[]) {
const pinia = createPinia()
setActivePinia(pinia)
useAuthStore().permissions = permissions
return mount(UnifiedOrderWorkflowPanel, {
props: {
tenantId: '2086085512165634052', inboundId: '2086085512165634051', orderId: '2086085512165634053',
mainStatus: 'WAIT_SPLIT', inboundVersion: 4,
},
global: { plugins: [pinia, ElementPlus] },
})
}
describe('UnifiedOrderWorkflowPanel preload editor', () => {
beforeEach(() => {
vi.mocked(getOutboundDispatch).mockReset().mockResolvedValue(null as never)
vi.mocked(getPreload).mockReset().mockResolvedValue(preload('REF-OLD'))
vi.mocked(smartSplit).mockReset()
})
it('renders the formal preload table and keeps the operation column first', async () => {
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split'])
await flushPromises()
expect(getPreload).toHaveBeenCalledWith('2086085512165634052', '2086085512165634051')
expect(wrapper.text()).toContain('入库预装清单')
expect(wrapper.text()).toContain('REF-OLD')
const headers = wrapper.findAll('.el-table__header-wrapper th').map((node) => node.text())
expect(headers[0]).toContain('操作')
expect(wrapper.find('button[aria-label="编辑第 1 条预装明细"]').exists()).toBe(true)
})
it('does not query or expose editing controls without permission', async () => {
const wrapper = mountPanel([])
await flushPromises()
expect(getPreload).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('当前账号无预装清单查询权限')
expect(wrapper.find('button[aria-label="编辑第 1 条预装明细"]').exists()).toBe(false)
})
it('blocks an empty list before calling the real endpoint', async () => {
vi.mocked(getPreload).mockResolvedValue({ ...preload('REF-OLD'), items: [] })
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split'])
await flushPromises()
const save = wrapper.findAll('button').find((button) => button.text().includes('保存分柜清单'))
expect(save).toBeTruthy()
await save!.trigger('click')
expect(smartSplit).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请至少添加一条预装明细')
})
it('refreshes the version but preserves submitted rows after a conflict', async () => {
vi.mocked(getPreload)
.mockResolvedValueOnce(preload('REF-OLD', 3))
.mockResolvedValueOnce(preload('REF-SERVER', 4))
vi.mocked(smartSplit).mockRejectedValue(new Error('数据已被其他人修改,请刷新后重试'))
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split'])
await flushPromises()
const save = wrapper.findAll('button').find((button) => button.text().includes('保存分柜清单'))
await save!.trigger('click')
await flushPromises()
expect(getPreload).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('系统已刷新最新数据,请核对后重新提交')
expect(wrapper.text()).toContain('REF-OLD')
expect(wrapper.text()).not.toContain('REF-SERVER')
})
})
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RefreshCw, Save, Check, Play, Camera } from '@lucide/vue'
import { RefreshCw, Save, Check, Play, Camera, Plus, Pencil, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
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 SmartLoadingWorkbench from '@/modules/unified-orders/workflows/components/SmartLoadingWorkbench.vue'
import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from '@/modules/unified-orders/workflows/preloadForm'
import {
completeLoading,
completePicking,
@@ -52,7 +58,14 @@ const splitRemark = ref('')
const busy = ref('')
const loading = ref(false)
const error = ref('')
const errorDetails = ref<ApiErrorDetails | null>(null)
const preloadLoadError = ref<ApiErrorDetails | null>(null)
const notice = ref('')
const preloadDialogOpen = ref(false)
const preloadDialogMode = ref<'create' | 'edit'>('create')
const editingPreloadIndex = ref<number | null>(null)
const preloadDraft = ref<PreloadItem>(itemTemplate())
const preloadDraftError = ref('')
const evidenceTypes: Array<{ type: EvidenceType; label: string }> = [
{ type: 'PHOTO_STEP_1', label: '空仓照' },
@@ -78,18 +91,62 @@ const canEvidence = computed(() => !props.readOnly && access.can('platform:first
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
const canLoadingComplete = computed(() => !props.readOnly && !isLoaded.value && access.can('platform:firstleg:inbound:loading:complete'))
const preloadColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 132, fixed: 'left', slotName: 'actions' },
{ key: 'containerCode', prop: 'containerCode', label: '柜号', minWidth: 150 },
{ key: 'cargoReference', prop: 'cargoReference', label: '货物引用', minWidth: 180 },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 90 },
{ key: 'volumeCbm', label: '体积(m³)', width: 110, slotName: 'volume' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
]
function itemTemplate(): PreloadItem {
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
}
function addItem() {
preloadItems.value.push(itemTemplate())
preloadDialogMode.value = 'create'
editingPreloadIndex.value = null
preloadDraft.value = itemTemplate()
preloadDraftError.value = ''
preloadDialogOpen.value = true
}
function editItem(index: number) {
preloadDialogMode.value = 'edit'
editingPreloadIndex.value = index
preloadDraft.value = { ...preloadItems.value[index] }
preloadDraftError.value = ''
preloadDialogOpen.value = true
}
function saveItem() {
const validation = validatePreloadItem(preloadDraft.value)
if (!validation.valid) {
preloadDraftError.value = validation.message
return
}
const next = sanitizePreloadItem(preloadDraft.value)
if (preloadDialogMode.value === 'edit' && editingPreloadIndex.value !== null) {
preloadItems.value.splice(editingPreloadIndex.value, 1, next)
} else {
preloadItems.value.push(next)
}
preloadDialogOpen.value = false
}
function removeItem(index: number) {
preloadItems.value.splice(index, 1)
}
function preloadStatusLabel(status?: string | null) {
return ({ PENDING: '待提交', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[status || ''] || '尚未生成'
}
function formatVolume(value: number | null) {
return value === null || !Number.isFinite(Number(value)) ? '—' : Number(value).toFixed(3)
}
function evidence(type: EvidenceType) {
return evidences.value.find((item) => item.evidenceType === type && item.status === 'ACTIVE')
}
@@ -102,9 +159,10 @@ function gateItemName(code: string, fallback: string) {
return code === 'LOADING_EVIDENCE_COMPLETE' ? '五类装柜证据与锁码完整' : fallback
}
async function run<T>(action: string, operation: () => Promise<T>, message: string) {
async function run<T>(action: string, operation: () => Promise<T>, message: string, restoreAfterConflict?: () => void) {
busy.value = action
error.value = ''
errorDetails.value = null
notice.value = ''
try {
const result = await operation()
@@ -112,7 +170,14 @@ async function run<T>(action: string, operation: () => Promise<T>, message: stri
emit('updated')
return result
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '操作失败,请重试'
const details = getApiErrorDetails(cause, '操作失败,请重试')
error.value = details.message
errorDetails.value = details
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
await refresh()
restoreAfterConflict?.()
notice.value = '系统已刷新最新数据,请核对后重新提交'
}
return null
} finally {
busy.value = ''
@@ -123,6 +188,8 @@ async function loadPreload() {
if (!canPreloadView.value || !props.inboundId) return
loading.value = true
error.value = ''
errorDetails.value = null
preloadLoadError.value = null
try {
preload.value = await getPreload(props.tenantId, props.inboundId)
preloadItems.value = preload.value.items?.map((item) => ({ ...item })) || []
@@ -131,7 +198,9 @@ async function loadPreload() {
preloadItems.value = []
const missing = cause instanceof ApiResponseError
&& (cause.status === 404 || cause.code === 'A0404' || cause.code === 'NOT_FOUND')
error.value = missing ? '' : cause instanceof Error ? cause.message : '预装清单加载失败,请重试'
if (!missing) {
preloadLoadError.value = getApiErrorDetails(cause, '预装清单加载失败,请重试')
}
} finally {
loading.value = false
}
@@ -140,6 +209,7 @@ async function loadPreload() {
async function loadLoading() {
loading.value = true
error.value = ''
errorDetails.value = null
const messages: string[] = []
if (canPickingView.value) {
try {
@@ -157,26 +227,41 @@ async function loadLoading() {
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
}
}
error.value = messages.join(';')
loading.value = false
}
async function loadDispatch() {
try {
dispatch.value = await getOutboundDispatch(props.tenantId, props.orderId || undefined, props.inboundId)
} catch {
dispatch.value = null
}
error.value = messages.join(';')
loading.value = false
}
async function refresh() {
await loadDispatch()
if (isSplit.value) await loadPreload()
if (isLoading.value) await loadLoading()
}
async function runSmartSplit() {
const validation = validatePreloadItems(preloadItems.value)
if (!validation.valid) {
error.value = validation.message
errorDetails.value = null
return
}
const submittedItems = preloadItems.value.map((item) => sanitizePreloadItem(item))
const submittedRemark = splitRemark.value
const result = await run('smart-split', () => smartSplit(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
items: preloadItems.value,
remark: splitRemark.value || undefined,
}), '智能分柜已提交')
items: submittedItems,
remark: submittedRemark.trim() || undefined,
}), '分柜清单已保存', () => {
preloadItems.value = submittedItems.map((item) => ({ ...item }))
splitRemark.value = submittedRemark
})
if (result) {
preload.value = result.preloadPlan
preloadItems.value = result.preloadPlan?.items?.map((item) => ({ ...item })) || []
@@ -317,6 +402,7 @@ onMounted(() => { void refresh() })
<el-alert v-if="error" class="workflow-alert" type="error" :closable="false" show-icon>
<template #title>{{ error }}</template>
<ApiErrorMeta v-if="errorDetails" :details="errorDetails" />
<el-button link type="danger" @click="refresh">重试</el-button>
</el-alert>
<el-alert v-if="notice" class="workflow-alert" type="success" :closable="false" show-icon>{{ notice }}</el-alert>
@@ -336,25 +422,41 @@ onMounted(() => { void refresh() })
<template v-if="isSplit">
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>预装清单</span><el-tag>{{ preload?.status || '待确认' }}</el-tag></div></template>
<el-empty v-if="!preload && !loading" description="暂无预装清单,可直接生成分柜方案" />
<div class="preload-items" role="group" aria-label="预装清单明细">
<div v-for="(item, index) in preloadItems" :key="index" class="preload-row">
<el-input v-model="item.containerCode" :disabled="props.readOnly" aria-label="柜号" placeholder="柜号" />
<el-input v-model="item.cargoReference" :disabled="props.readOnly" aria-label="货物引用" placeholder="货物引用" />
<el-input-number v-model="item.packageCount" :disabled="props.readOnly" aria-label="件数" :min="1" controls-position="right" />
<el-input-number v-model="item.volumeCbm" :disabled="props.readOnly" aria-label="体积" :min="0" :precision="3" controls-position="right" />
<el-button v-if="!props.readOnly" text type="danger" aria-label="删除预装项" @click="removeItem(index)">删除</el-button>
</div>
<el-button v-if="!props.readOnly" plain @click="addItem">新增预装项</el-button>
<el-input v-model="splitRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="分柜备注" placeholder="分柜备注(可选)" />
</div>
<template #header><div class="card-title"><span>入库预装清单</span><el-tag>{{ preloadStatusLabel(preload?.status) }}</el-tag></div></template>
<el-alert v-if="!canPreloadView" type="warning" :closable="false" show-icon>当前账号无预装清单查询权限。</el-alert>
<LoadErrorState v-else-if="preloadLoadError" title="预装清单暂时无法加载" :message="preloadLoadError.message" :loading="loading" @retry="loadPreload">
<template #details><ApiErrorMeta :details="preloadLoadError" /></template>
</LoadErrorState>
<template v-else>
<DataTable :data="preloadItems" :columns="preloadColumns" :loading="loading" empty-text="暂无预装明细,请先新增">
<template #actions="{ $index }">
<el-button link type="primary" :disabled="!canSplit || Boolean(busy)" :aria-label="`编辑第 ${Number($index) + 1} 条预装明细`" @click="editItem(Number($index))"><Pencil :size="14" />编辑</el-button>
<el-button link type="danger" :disabled="!canSplit || Boolean(busy)" :aria-label="`删除第 ${Number($index) + 1} 条预装明细`" @click="removeItem(Number($index))"><Trash2 :size="14" />删除</el-button>
</template>
<template #volume="{ row }">{{ formatVolume((row as PreloadItem).volumeCbm) }}</template>
<template #toolbar-start><div><strong>明细</strong><p>入库级清单由人工维护;柜次候选和智能建议在下方柜次工作台加载。</p></div></template>
<template #toolbar-actions><el-button :disabled="!canSplit || Boolean(busy)" @click="addItem"><Plus :size="15" />新增明细</el-button></template>
</DataTable>
<el-input v-model="splitRemark" :disabled="!canSplit || Boolean(busy)" class="workflow-remark" type="textarea" :rows="2" maxlength="1000" show-word-limit aria-label="分柜备注" placeholder="分柜备注(可选)" />
</template>
<div class="workflow-actions">
<el-button :disabled="!canSplit || Boolean(busy)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />智能分柜/保存调整</el-button>
<el-button :disabled="!canPreload || !preload || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
<el-button :disabled="!canSplit || Boolean(busy) || Boolean(preloadLoadError)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />保存分柜清单</el-button>
<el-button :disabled="!canPreload || !preload || preload.status !== 'PENDING' || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
<el-button type="primary" :disabled="!canPreload || !preload || preload?.status !== 'CONFIRMING' || Boolean(busy)" :loading="busy === 'preload-confirm'" @click="finishPreloadConfirmation"><Check :size="15" />确认预装清单</el-button>
</div>
</el-card>
<el-card v-if="dispatch?.containerBookingId" shadow="never" class="workflow-card">
<SmartLoadingWorkbench
:booking-id="String(dispatch.containerBookingId)"
:highlight-inbound-id="props.inboundId"
:read-only="props.readOnly"
@updated="refresh"
/>
</el-card>
<el-alert v-else type="info" :closable="false" show-icon>
当前订单尚未关联柜次装柜计划;请先在货柜智能排柜工作台选择柜次并分配货物。
</el-alert>
</template>
<template v-if="isLoading">
@@ -401,6 +503,29 @@ onMounted(() => { void refresh() })
<el-alert v-if="isLoaded" class="workflow-alert" type="success" :closable="false" show-icon>
已装柜完成。国内端不提供在线冲正、回退或推进派送完成,后续状态仅接受海外/尾程可信回传。
</el-alert>
<AppDialog
v-model="preloadDialogOpen"
:title="preloadDialogMode === 'create' ? '新增预装明细' : '编辑预装明细'"
confirm-text="保存明细"
:confirm-disabled="Boolean(busy)"
@confirm="saveItem"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="柜号" required :error="preloadDraftError === '柜号不能为空' ? preloadDraftError : ''">
<el-input v-model="preloadDraft.containerCode" maxlength="64" placeholder="请输入柜号" @input="preloadDraftError = ''" />
</el-form-item>
<el-form-item label="货物引用" required :error="preloadDraftError === '货物引用不能为空' ? preloadDraftError : ''">
<el-input v-model="preloadDraft.cargoReference" maxlength="128" placeholder="订单号、入仓码或业务引用" @input="preloadDraftError = ''" />
</el-form-item>
<div class="preload-dialog-grid">
<el-form-item label="箱数" required><el-input-number v-model="preloadDraft.packageCount" :min="1" :step="1" step-strictly controls-position="right" @change="preloadDraftError = ''" /></el-form-item>
<el-form-item label="体积(m³)"><el-input-number v-model="preloadDraft.volumeCbm" :min="0" :precision="3" controls-position="right" @change="preloadDraftError = ''" /></el-form-item>
</div>
<el-form-item label="明细备注"><el-input v-model="preloadDraft.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
<el-alert v-if="preloadDraftError" type="error" :closable="false" show-icon>{{ preloadDraftError }}</el-alert>
</el-form>
</AppDialog>
</section>
</template>
@@ -412,8 +537,7 @@ onMounted(() => { void refresh() })
.workflow-heading p { margin: 4px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
.workflow-card { border-radius: 12px; }
.card-title { justify-content: space-between; }
.preload-items { display: grid; gap: 10px; }
.preload-row { display: grid; grid-template-columns: 1fr 1fr 120px 120px auto; gap: 8px; align-items: center; }
.preload-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.workflow-actions { margin-top: 14px; }
.workflow-remark { margin-top: 10px; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
@@ -430,7 +554,8 @@ onMounted(() => { void refresh() })
.workflow-alert { margin-bottom: 0; }
.workflow-hint { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
@media (max-width: 640px) {
.preload-row, .evidence-grid { grid-template-columns: 1fr; }
.preload-dialog-grid, .evidence-grid { grid-template-columns: 1fr; }
.preload-dialog-grid { gap: 0; }
.evidence-item, .gate-grid { grid-template-columns: 1fr; }
.workflow-actions > .el-button { flex: 1 1 150px; }
}