This commit is contained in:
2026-08-17 18:32:41 +08:00
parent d14d0dcb61
commit 822b285312
17 changed files with 133 additions and 53 deletions
@@ -187,14 +187,15 @@ describe('ContainersView', () => {
expect(source).not.toContain('@reset=')
})
it('uses the frozen prototype modal contract for create and edit while retaining the detail drawer', () => {
it('uses centered dialogs for container detail, create and edit operations', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('<AppDrawer v-model="detailOpen"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog"')
expect(source).toContain('<AppDialog v-model="detailOpen" class="container-detail-dialog"')
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog"')
expect(source).toContain('max-height: 90vh')
expect(source).toContain('overflow-y: auto')
expect(source).toContain('grid-template-columns: minmax(0,1fr)')
expect(source).not.toContain('AppDrawer')
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
})
@@ -13,7 +13,6 @@ import { uploadLoadingDeclaration } from '@/modules/loading-lists'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -614,10 +613,10 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
</section>
</ListPageLayout>
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
<AppDialog v-model="detailOpen" class="container-detail-dialog" title="货柜详情" width="min(820px, calc(100vw - 28px))" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="真实订舱">{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '待录入' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
</AppDrawer>
</AppDialog>
<AppDialog v-model="clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
@@ -81,6 +81,23 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).not.toContain('ORD-2')
})
it('uses the top-level container channel snapshot without expecting a nested snapshot', async () => {
vi.mocked(listContainers).mockResolvedValue({
records: [{ ...container, channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁' }],
page: 1,
size: 100,
total: 1,
pages: 1,
})
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsName: '鞋类', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('纽约EXX海铁')
expect(wrapper.text()).not.toContain('缺少渠道快照')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
})
it('shows one user-facing container number and hides internal manual or split references', async () => {
vi.mocked(listContainers).mockResolvedValue({
records: [{ ...container, cabinetNo: 'CAB-20260814-01', containerNo: 'SPLIT-814022117898chain1' }],
@@ -249,7 +266,34 @@ describe('GlobalSmartLoadingView', () => {
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('CB-3')
expect(wrapper.text()).not.toContain('装柜完成')
expect(wrapper.text()).toContain('当前柜次已完成装柜,不可继续编辑')
expect(wrapper.text()).not.toContain('缺少渠道快照')
})
it('isolates a missing-channel row without hiding another valid container', async () => {
const missingChannel = { ...container, id: '9007199254740997', cabinetNo: 'CB-MISSING', channelCode: null, channelName: null }
vi.mocked(listContainers).mockResolvedValue({ records: [missingChannel, container], page: 1, size: 100, total: 2, pages: 1 })
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findAll('.container-option')).toHaveLength(1)
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.text()).not.toContain('CB-MISSING')
expect(wrapper.text()).not.toContain('缺少渠道快照')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
})
it('matches candidate goods by their own formal top-level channel code', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-MATCH', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '匹配企业', goodsName: '匹配货物', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 },
{ accountId: '2', inboundId: '8', inboundDetailId: '9', orderId: '10', orderNo: 'ORD-3', inboundCode: 'IN-OTHER', warehouseId: '8', channelCode: 'OTHER', channelName: '其他渠道', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '其他企业', goodsName: '其他货物', availablePackageCount: 8, unitWeightKg: 10, unitVolumeCbm: 0.2 },
])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('IN-MATCH')
expect(wrapper.text()).not.toContain('IN-OTHER')
expect(wrapper.text()).not.toContain('其他货物')
})
it('refreshes the latest plan after A0409 without losing the selected container', async () => {
@@ -60,10 +60,13 @@ const canAssign = computed(() => access.can('platform:firstleg:smart-loading:ass
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 orchestratableContainers = computed(() => containers.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)))
const completedContainerCount = computed(() => containers.value.filter((item) => item.planStatus === 'COMPLETED').length)
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
const channelTabs = computed(() => {
const channels = new Map<string, { code: string; name: string }>()
for (const item of containers.value) {
if (!['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus) || !item.channelCode) continue
for (const item of orchestratableContainers.value) {
if (!item.channelCode) continue
if (!channels.has(item.channelCode)) channels.set(item.channelCode, {
code: item.channelCode,
name: item.channelName || item.channelCode,
@@ -98,7 +101,9 @@ const filteredContainers = computed(() => containers.value.filter((item) => (
const filteredCandidates = computed(() => {
const customerId = String(appliedQuery.value.customerId || '')
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
const channelCode = selectedContainer.value?.channelCode || activeChannelCode.value
return candidates.value.filter((item) => {
if (!channelCode || item.channelCode !== channelCode) return false
if (customerId && item.accountId !== customerId) return false
return !inboundCode || String(item.inboundCode || '').toLowerCase().includes(inboundCode)
})
@@ -404,7 +409,8 @@ onMounted(() => { void load(); void loadCustomers() })
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
</dl>
</div>
<el-alert v-else-if="containers.length" type="warning" :closable="false" show-icon title="当前柜次缺少渠道快照,暂时不能参与智能装柜" />
<el-alert v-else-if="missingChannelContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少渠道快照,已隔离;请补齐后再参与智能装柜`" />
<el-alert v-else-if="completedContainerCount" type="success" :closable="false" show-icon title="当前柜次已完成装柜,不可继续编辑" />
<QueryToolbar
v-model="query"
ariaLabel="智能装柜筛选"