From 4088e36f7432c3f2786b3d354ad31d64ce75e2de Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 12:36:01 +0800 Subject: [PATCH] fix(admin): compact container departure gates --- .../pages/ContainerDetailView.test.ts | 104 ++++++++++++++++-- .../pages/ContainerDetailView.vue | 46 ++++++-- 2 files changed, 133 insertions(+), 17 deletions(-) diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 790c6a8f..6289c2dd 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -29,7 +29,7 @@ const overview = { const outboundDetail = { aggregate: { containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111', - loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4, + loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', truckAppointmentStatus: 'ARRIVED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4, inbounds: [{ orderId: '11', orderNo: 'O-1', inboundId: '2092438843630391501', inboundCode: 'IN-1', tenantId: '22', inboundStatus: 'LOADED' }], gate: { passed: true, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }] }, }, @@ -100,8 +100,14 @@ describe('ContainerDetailView', () => { expect(getContainerOutboundDispatch).toHaveBeenCalledOnce() expect(getContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297') expect(wrapper.text()).toContain('国内出仓门禁') - expect(wrapper.text()).toContain('报关协同完成') - expect(wrapper.text()).toContain('IN-1') + expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1') + expect(wrapper.find('.gate-summary').text()).toContain('已通过 1') + expect(wrapper.find('.gate-summary').text()).toContain('阻断 0') + expect(wrapper.find('.gate-list').text()).not.toContain('报关协同完成') + await wrapper.find('.gate-summary button').trigger('click') + expect(wrapper.find('.gate-list').text()).toContain('报关协同完成') + expect(wrapper.find('.gate-list').text()).toContain('覆盖 1 个入库码') + expect(wrapper.find('.gate-list').text()).toContain('IN-1') expect(wrapper.text()).toContain('确认离仓') wrapper.unmount() }) @@ -142,14 +148,98 @@ describe('ContainerDetailView', () => { ...outboundDetail, aggregate: { ...outboundDetail.aggregate, - gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' }] } }] }, + gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: false, actual: 'EXPIRED', requirement: 'VALID' }] } }] }, }, } as never) const wrapper = mountView(); await flushPromises() - expect(wrapper.text()).toContain('关联拖车已离仓') - expect(wrapper.text()).toContain('ARRIVED') - expect(wrapper.text()).toContain('DEPARTED或COMPLETED') + expect(wrapper.text()).toContain('船期有效且未过期') + expect(wrapper.text()).toContain('EXPIRED') + expect(wrapper.text()).toContain('VALID') expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false) wrapper.unmount() }) + + it('deduplicates identical gates across inbounds and shows compact coverage counts', async () => { + const blocked = { code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' } + const passed = { code: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: true, actual: 'VALID', requirement: 'VALID' } + vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({ + ...outboundDetail, + aggregate: { + ...outboundDetail.aggregate, + gate: { + passed: false, + checkedAt: '2026-08-26T12:00:00', + inbounds: [ + { inboundId: '101', inboundCode: 'IN-101', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } }, + { inboundId: '102', inboundCode: 'IN-102', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } }, + ], + }, + }, + } as never) + const wrapper = mountView(); await flushPromises() + expect(wrapper.find('.gate-summary').text()).toContain('总门禁 2') + expect(wrapper.find('.gate-summary').text()).toContain('已通过 1') + expect(wrapper.find('.gate-summary').text()).toContain('阻断 1') + expect(wrapper.findAll('.gate-row')).toHaveLength(1) + expect(wrapper.find('.gate-row').text()).toContain('影响 2 个入库码') + expect(wrapper.find('.gate-row').text()).toContain('IN-101') + expect(wrapper.find('.gate-row').text()).toContain('IN-102') + await wrapper.find('.gate-summary button').trigger('click') + expect(wrapper.findAll('.gate-row')).toHaveLength(2) + expect(wrapper.findAll('.gate-row')[1]?.text()).toContain('覆盖 2 个入库码') + wrapper.unmount() + }) + + it('keeps different facts for the same gate code separate and labels the affected inbound', async () => { + vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({ + ...outboundDetail, + aggregate: { + ...outboundDetail.aggregate, + gate: { + passed: false, + checkedAt: '2026-08-26T12:00:00', + inbounds: [ + { inboundId: '201', inboundCode: 'IN-MISSING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } }, + { inboundId: '202', inboundCode: 'IN-PENDING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'PENDING', requirement: 'COMPLETED' }] } }, + { inboundId: '203', inboundCode: 'IN-DONE', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }, + ], + }, + }, + } as never) + const wrapper = mountView(); await flushPromises() + expect(wrapper.findAll('.gate-row')).toHaveLength(2) + expect(wrapper.find('.gate-list').text()).toContain('MISSING') + expect(wrapper.find('.gate-list').text()).toContain('IN-MISSING') + expect(wrapper.find('.gate-list').text()).toContain('PENDING') + expect(wrapper.find('.gate-list').text()).toContain('IN-PENDING') + await wrapper.find('.gate-summary button').trigger('click') + expect(wrapper.findAll('.gate-row')).toHaveLength(3) + expect(wrapper.find('.gate-list').text()).toContain('IN-DONE') + wrapper.unmount() + }) + + it('keeps truck status as reference only and never counts the legacy truck gate as blocking', async () => { + vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({ + ...outboundDetail, + aggregate: { + ...outboundDetail.aggregate, + truckAppointmentStatus: 'ARRIVED', + gate: { + passed: false, + checkedAt: '2026-08-26T12:00:00', + inbounds: [{ inboundId: '301', inboundCode: 'IN-301', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [ + { code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' }, + { code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }, + ] } }], + }, + }, + } as never) + const wrapper = mountView(); await flushPromises() + expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1') + expect(wrapper.find('.gate-summary').text()).toContain('阻断 0') + expect(wrapper.find('.gate-summary').text()).toContain('拖车状态(参考):已到仓') + expect(wrapper.text()).not.toContain('关联拖车已离仓') + expect(wrapper.find('.outbound-panel__actions button').text()).toContain('确认离仓') + wrapper.unmount() + }) }) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 796cda0d..58f81bf7 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -13,7 +13,7 @@ import StatusBadge from '@/shared/components/StatusBadge.vue' import { CarrierScheduleQueryPanel } from '@/modules/port-schedules' import { uploadLoadingDeclaration } from '@/modules/loading-lists' import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' -import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types' +import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types' import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue' import { cancelContainerException, @@ -62,17 +62,37 @@ const outboundLoading = ref(false) const outboundConfirming = ref(false) const outboundDetail = ref(null) const outboundError = ref(null) +const showPassedGates = ref(false) const carrierRefreshKey = ref(0) const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null) const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value]) const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情') const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常') -const outboundGateRows = computed(() => (outboundDetail.value?.aggregate.gate.inbounds || []).flatMap((inbound) => - (inbound.gate.items || []).map((item) => ({ ...item, inboundId: inbound.inboundId, inboundCode: inbound.inboundCode })))) +type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] } +const outboundGateRows = computed(() => { + const grouped = new Map() + for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) { + for (const item of inbound.gate.items || []) { + if (item.code === 'TRUCK_DEPARTED') continue + const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name]) + const existing = grouped.get(key) + if (existing) { + if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode) + } else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] }) + } + } + return [...grouped.values()] +}) +const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed)) +const outboundGateSummary = computed(() => ({ + total: outboundGateRows.value.length, + passed: outboundGateRows.value.filter((row) => row.passed).length, + blocked: outboundGateRows.value.filter((row) => !row.passed).length, +})) const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value && outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH' - && outboundDetail.value.aggregate.gate.passed) + && outboundGateSummary.value.blocked === 0) function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' } function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' } @@ -90,7 +110,7 @@ async function load() { async function loadOutboundDispatch() { if (!containerId.value || !canViewOutboundDispatch.value) return - outboundLoading.value = true; outboundError.value = null + outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) } catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') } finally { outboundLoading.value = false } @@ -123,6 +143,9 @@ async function confirmDeparture() { function outboundStatusText(status?: string | null) { return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record)[status || ''] || status || '—' } +function truckReferenceText(status?: string | null) { + return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record)[status || ''] || status || '未关联' +} function openDocument(type: string) { if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return } @@ -211,14 +234,17 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
-

国内出仓门禁

按整柜核验全部入库绑定,仅确认一次离仓。

{{ outboundDetail.aggregate.gate.passed ? '门禁通过' : '门禁阻断' }}确认离仓
+

国内出仓门禁

按整柜核验全部入库绑定,仅确认一次离仓。

{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}确认离仓
@@ -233,5 +259,5 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op