2026-08-26 11:10:48 +08:00
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
2026-08-13 11:57:44 +08:00
|
|
|
import ElementPlus from 'element-plus'
|
|
|
|
|
import ContainerOverviewPanel from './ContainerOverviewPanel.vue'
|
|
|
|
|
import type { ContainerOverview } from '../types'
|
2026-08-26 11:10:48 +08:00
|
|
|
import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
|
|
|
|
|
|
|
|
|
|
vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() }))
|
2026-08-13 11:57:44 +08:00
|
|
|
|
|
|
|
|
const overview: ContainerOverview = {
|
|
|
|
|
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', version: 2 },
|
|
|
|
|
loadingPlanId: '9',
|
2026-08-14 14:27:03 +08:00
|
|
|
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
|
2026-08-13 11:57:44 +08:00
|
|
|
documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }],
|
|
|
|
|
exception: { active: true, reason: '码头查验', markedBy: '13', markedByName: '操作员', markedAt: '2026-08-13T11:00:00' },
|
|
|
|
|
exceptionHistory: [{ id: '14', action: 'MARK', reason: '码头查验', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T11:00:00' }],
|
|
|
|
|
timeline: [{ eventType: 'TRANSPORT_UPDATE', detail: '更新预计到港时间', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T12:00:00' }],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('ContainerOverviewPanel', () => {
|
2026-08-26 11:10:48 +08:00
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks()
|
|
|
|
|
vi.mocked(downloadContainerPdaPhoto).mockResolvedValue(new Blob(['photo']))
|
|
|
|
|
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:container-photo') })
|
|
|
|
|
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => {
|
|
|
|
|
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
|
|
|
|
await flushPromises()
|
2026-08-13 11:57:44 +08:00
|
|
|
expect(wrapper.text()).toContain('DO 资料')
|
|
|
|
|
expect(wrapper.text()).toContain('do.pdf')
|
|
|
|
|
expect(wrapper.text()).toContain('PDA 作业进度')
|
2026-08-14 14:27:03 +08:00
|
|
|
expect(wrapper.text()).toContain('待人工复核')
|
|
|
|
|
expect(wrapper.text()).toContain('空柜照片')
|
|
|
|
|
expect(wrapper.text()).not.toContain('PHOTO_STEP_EMPTY_CONTAINER')
|
2026-08-13 11:57:44 +08:00
|
|
|
expect(wrapper.text()).toContain('一项待复核')
|
|
|
|
|
expect(wrapper.text()).toContain('码头查验')
|
2026-08-26 13:58:39 +08:00
|
|
|
expect(wrapper.text()).not.toContain('更新预计到港时间')
|
2026-08-13 11:57:44 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('emits stable document types and does not expose file ids in visible text', async () => {
|
2026-08-26 11:10:48 +08:00
|
|
|
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
2026-08-13 11:57:44 +08:00
|
|
|
const updateButton = wrapper.findAll('button').find((button) => button.text().includes('更新'))!
|
|
|
|
|
await updateButton.trigger('click')
|
|
|
|
|
expect(wrapper.emitted('uploadDocument')?.[0]).toEqual(['DELIVERY_ORDER'])
|
|
|
|
|
expect(wrapper.text()).not.toContain('9007199254740999')
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-28 16:54:02 +08:00
|
|
|
it('renders three compact non-customs document rows with the operation column first', () => {
|
2026-08-26 13:00:51 +08:00
|
|
|
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
|
|
|
|
const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text())
|
|
|
|
|
expect(headers).toEqual(['操作', '资料类型', '文件名', '上传人', '上传时间', '状态'])
|
2026-08-28 16:54:02 +08:00
|
|
|
expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(3)
|
2026-08-26 13:00:51 +08:00
|
|
|
expect(wrapper.findAll('.document-item')).toHaveLength(0)
|
|
|
|
|
expect(wrapper.find('.document-table-wrap').exists()).toBe(true)
|
|
|
|
|
const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))!
|
|
|
|
|
expect(deliveryRow.text()).toContain('do.pdf')
|
|
|
|
|
expect(deliveryRow.text()).toContain('操作员')
|
|
|
|
|
expect(deliveryRow.text()).toContain('2026-08-13 10:00')
|
|
|
|
|
expect(deliveryRow.text()).toContain('已上传')
|
|
|
|
|
expect(deliveryRow.find('.document-file-name').attributes('title')).toBe('do.pdf')
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-26 13:48:55 +08:00
|
|
|
it('renders one compact normal exception section with a single-line empty history', () => {
|
|
|
|
|
const normal = { ...overview, exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [] }
|
|
|
|
|
const wrapper = mount(ContainerOverviewPanel, { props: { overview: normal, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
|
|
|
|
const section = wrapper.find('.exception-management')
|
|
|
|
|
expect(section.text()).toContain('异常管理')
|
|
|
|
|
expect(section.text()).toContain('正常')
|
|
|
|
|
expect(section.text()).toContain('标记异常')
|
|
|
|
|
expect(section.text()).toContain('暂无异常处理记录')
|
|
|
|
|
expect(section.find('.el-empty').exists()).toBe(false)
|
|
|
|
|
expect(wrapper.findAll('[aria-label="异常管理"]')).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps current exception summary and multiple history rows together without mixing transport logs', () => {
|
|
|
|
|
const histories = [
|
|
|
|
|
...overview.exceptionHistory,
|
|
|
|
|
{ id: '15', action: 'CANCEL', reason: '查验解除', operatorId: '16', operatorName: '复核员', occurredAt: '2026-08-13T11:30:00' },
|
|
|
|
|
]
|
|
|
|
|
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, exceptionHistory: histories }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
|
|
|
|
const section = wrapper.find('.exception-management')
|
|
|
|
|
expect(section.text()).toContain('异常')
|
|
|
|
|
expect(section.text()).toContain('码头查验')
|
|
|
|
|
expect(section.text()).toContain('操作员 · 2026-08-13 11:00')
|
|
|
|
|
expect(section.findAll('.exception-history li')).toHaveLength(2)
|
|
|
|
|
expect(section.text()).toContain('取消异常')
|
|
|
|
|
expect(section.text()).toContain('复核员')
|
|
|
|
|
expect(section.text()).toContain('查验解除')
|
|
|
|
|
expect(section.text()).not.toContain('更新预计到港时间')
|
2026-08-26 13:58:39 +08:00
|
|
|
expect(wrapper.find('[aria-label="运输轨迹和操作日志"]').exists()).toBe(false)
|
2026-08-26 13:48:55 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('emits the unchanged manage action for mark and cancel buttons', async () => {
|
|
|
|
|
let wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null } }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
|
|
|
|
await wrapper.find('.exception-management button').trigger('click')
|
|
|
|
|
expect(wrapper.emitted('manageException')).toHaveLength(1)
|
|
|
|
|
wrapper.unmount()
|
|
|
|
|
wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
|
|
|
|
await wrapper.find('.exception-management button').trigger('click')
|
|
|
|
|
expect(wrapper.emitted('manageException')).toHaveLength(1)
|
|
|
|
|
expect(wrapper.find('.exception-management button').text()).toContain('取消异常')
|
|
|
|
|
wrapper.unmount()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-26 11:10:48 +08:00
|
|
|
it('loads protected photos into direct thumbnails, opens them and revokes object URLs', async () => {
|
|
|
|
|
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
|
|
|
|
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download')
|
|
|
|
|
expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true)
|
|
|
|
|
await wrapper.find('button[aria-label="预览装柜照片 empty.jpg"]').trigger('click')
|
|
|
|
|
expect(open).toHaveBeenCalledWith('blob:container-photo', '_blank', 'noopener,noreferrer')
|
|
|
|
|
wrapper.unmount()
|
|
|
|
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo')
|
2026-08-13 11:57:44 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-26 11:10:48 +08:00
|
|
|
it('revokes old thumbnail URLs when the overview photos change', async () => {
|
|
|
|
|
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
|
|
|
|
await flushPromises()
|
|
|
|
|
const changed = { ...overview.pda!.photos[0], evidenceId: '22', fileId: '25', fileName: 'next.jpg', downloadPath: '/api/platform/v1/containers/7/pda/photos/25/download' }
|
|
|
|
|
await wrapper.setProps({ overview: { ...overview, pda: { ...overview.pda!, photos: [changed] } } })
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo')
|
|
|
|
|
expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/25/download')
|
|
|
|
|
wrapper.unmount()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('isolates one thumbnail failure without blocking other photos', async () => {
|
|
|
|
|
const second = { ...overview.pda!.photos[0], evidenceId: '21', fileId: '24', fileName: 'seal.jpg', evidenceType: 'SEAL', downloadPath: '/api/platform/v1/containers/7/pda/photos/24/download' }
|
|
|
|
|
vi.mocked(downloadContainerPdaPhoto).mockResolvedValueOnce(new Blob(['photo'])).mockRejectedValueOnce(new Error('photo unavailable'))
|
|
|
|
|
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: { ...overview.pda!, photos: [...overview.pda!.photos, second] } }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true)
|
|
|
|
|
expect(wrapper.text()).toContain('照片加载失败')
|
|
|
|
|
wrapper.unmount()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-26 13:58:39 +08:00
|
|
|
it('delegates warehouse events to the unified fulfillment timeline without exposing technical keys here', () => {
|
2026-08-26 11:10:48 +08:00
|
|
|
const longKey = 'pda-submit-1000001-2092438843630391297-2092437496092790785--0c642e80891d76a4-m9428-76'
|
|
|
|
|
const timeline = [
|
|
|
|
|
{ eventType: 'PDA_CLAIM', detail: `claim-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' },
|
|
|
|
|
{ eventType: 'PDA_BATCH', detail: longKey, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' },
|
|
|
|
|
{ eventType: 'PDA_COMPLETE_LOADING', detail: `complete-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:29:00' },
|
|
|
|
|
]
|
|
|
|
|
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, timeline }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
2026-08-26 13:58:39 +08:00
|
|
|
expect(wrapper.text()).not.toContain('领取 PDA 任务')
|
|
|
|
|
expect(wrapper.text()).not.toContain('提交拣货结果')
|
|
|
|
|
expect(wrapper.text()).not.toContain('完成装柜')
|
2026-08-26 11:10:48 +08:00
|
|
|
expect(wrapper.text()).not.toContain(longKey)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders a compact empty photo state without exposing credentials', () => {
|
2026-08-13 11:57:44 +08:00
|
|
|
const withoutPhotos = { ...overview, pda: overview.pda ? { ...overview.pda, photos: [] } : null }
|
2026-08-26 11:10:48 +08:00
|
|
|
const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageCustoms: false, canManageException: false }, global: { plugins: [ElementPlus] } })
|
2026-08-13 11:57:44 +08:00
|
|
|
expect(wrapper.text()).toContain('暂无可查看的有效照片')
|
|
|
|
|
expect(wrapper.text()).not.toContain('Authorization')
|
|
|
|
|
})
|
2026-08-26 11:10:48 +08:00
|
|
|
|
2026-08-13 11:57:44 +08:00
|
|
|
})
|