import ElementPlus, { ElMessageBox } from 'element-plus' import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import AppDialog from '@/shared/components/AppDialog.vue' import LineClampText from '@/shared/components/LineClampText.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar from '@/shared/components/QueryToolbar.vue' import { ApiResponseError } from '@/shared/api/http' import { overseasWarehousePermissions as permission } from '../permissions' import type { CargoDiscardDetail, CargoDiscardSummary } from '../api/cargoDiscards' import CargoDiscardsView from './CargoDiscardsView.vue' const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn(), review: vi.fn(), photo: vi.fn(), statistics: vi.fn() })) const can = vi.hoisted(() => vi.fn((_code: string) => true)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers })) vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo, getCargoDiscardStatistics: api.statistics })) enableAutoUnmount(afterEach) afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() }) function summary(overrides: Partial = {}): CargoDiscardSummary { return { id: '9007199254740993', customerId: '9007199254740994', discardNo: 'CD-001', inboundCodes: 'INBOUND-1', overseasWarehouseName: '洛杉矶仓', outboundNo: 'OUT-1', status: 'PENDING_REVIEW', totalPackages: 2, totalFee: 20, currency: 'USD', ...overrides } } function detail(): CargoDiscardDetail { return { summary: summary(), remark: '废弃原因与备注', items: [{ id: '9007199254740995', inboundCode: 'INBOUND-1', skuCode: 'SKU-LONG-ORIGINAL-001', productName: '原始完整品名'.repeat(10), discardPackages: 2, cartonMarks: ['MARK-001', 'MARK-002'] }], histories: [{ toStatus: 'PENDING_REVIEW', createdAt: '2026-10-20T09:00:00', remark: '已提交申请' }], photos: [] } } function photo(fileId = '9007199254740996') { return { fileId, palletNo: 'PALLET-1', fileName: '废弃现场.png', createdAt: '2026-10-20T09:00:00', submittedAt: '2026-10-20T09:01:00' } } beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true) vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) api.customers.mockResolvedValue({ records: [{ id: '9007199254740994', tenantName: '客户一' }] }) api.statistics.mockResolvedValue({ orderCount: 1, skuCount: 1, requestedPackages: 2, actualPackages: null, requestedPieces: null, actualPieces: null, palletCount: null }) api.list.mockResolvedValue({ records: [summary()], total: 1 }) api.detail.mockImplementation(async () => detail()); api.review.mockResolvedValue(detail()) api.photo.mockResolvedValue(new Blob(['image'], { type: 'image/png' })) URL.createObjectURL = vi.fn().mockReturnValue('blob:discard-photo'); URL.revokeObjectURL = vi.fn() }) async function page() { const wrapper = mount(CargoDiscardsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper } function row(wrapper: VueWrapper, no = 'CD-001') { return wrapper.findAll('.el-table__body tr').filter(item => item.text().includes(no)).at(-1)! } async function open() { const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } } function confirmation() { return new DOMWrapper(Array.from(document.querySelectorAll('.el-message-box')).at(-1)!) } async function accept() { await confirmation().get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises() } function errorState(wrapper: Pick, title: string) { return wrapper.findAllComponents(LoadErrorState).find(item => item.props('title') === title)! } async function retry(state: VueWrapper) { await state.get('.load-error-state__actions button').trigger('click'); await flushPromises() } describe('native cargo discard detail and approval', () => { it('preserves all readonly facts, full item values and history through fullscreen', async () => { const { wrapper, dialog } = await open() expect(api.statistics).not.toHaveBeenCalled() expect(wrapper.text()).not.toContain('全筛选') expect(wrapper.text()).not.toContain('统计未加载') expect(dialog.props('title')).toBe('废弃单详情 · CD-001'); expect(api.detail).toHaveBeenCalledExactlyOnceWith('CD-001', '9007199254740994') const facts = dialog.get('[aria-label="废弃单基础资料"]') for (const value of ['CD-001', 'OUT-1', '洛杉矶仓', 'INBOUND-1', '待审核', '废弃原因与备注']) expect(facts.text()).toContain(value) expect(facts.findAll('input, textarea, select')).toHaveLength(0) const clamps = dialog.findAllComponents(LineClampText).map(item => item.props('content')) expect(clamps).toContain('原始完整品名'.repeat(10)); expect(clamps).toContain('MARK-001、MARK-002'); expect(clamps).toContain('SKU-LONG-ORIGINAL-001') expect(dialog.get('[aria-label="废弃状态流转"]').text()).toContain('已提交申请') await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true) expect(dialog.findAll('input, textarea')).toHaveLength(0); expect(api.review).not.toHaveBeenCalled() }) it('locks reading and retries the original business number and customer scope after failure', async () => { let reject!: (error: Error) => void api.detail.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail })) const { dialog } = await open() expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false) expect(dialog.get('[role="status"]').text()).toContain('正在读取'); expect(dialog.find('[aria-label="废弃单基础资料"]').exists()).toBe(false) reject(new ApiResponseError('读取失败', 'A0503', 503, null, 'discard-detail-request')); await flushPromises() expect(dialog.text()).toContain('discard-detail-request'); await retry(errorState(dialog, '废弃单详情加载失败')) expect(api.detail.mock.calls).toEqual([['CD-001', '9007199254740994'], ['CD-001', '9007199254740994']]) expect(dialog.get('[aria-label="废弃单基础资料"]').text()).toContain('CD-001') }) it('confirms approval with the business number and sends the original APPROVE request only once', async () => { let resolve!: (value: CargoDiscardDetail) => void api.review.mockReturnValueOnce(new Promise(done => { resolve = done })) const wrapper = await page(); const button = row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')! await button.trigger('click'); await flushPromises() expect(confirmation().text()).toContain('CD-001'); expect(confirmation().text()).toContain('推送 PDA') expect(confirmation().classes()).toContain('admin-density-confirm'); expect(api.review).not.toHaveBeenCalled() await accept(); await button.trigger('click'); await flushPromises() expect(api.review).toHaveBeenCalledExactlyOnceWith('CD-001', 'APPROVE', undefined, '9007199254740994') expect(row(wrapper).findAll('button').every(item => item.attributes('disabled') !== undefined)).toBe(true) resolve(detail()); await flushPromises(); expect(api.list).toHaveBeenCalledTimes(2) }) it('preserves the list and diagnostics on approval failure, and ignores native cancel and close', async () => { api.review.mockRejectedValueOnce(new ApiResponseError('审核未确认', 'A0503', 503, null, 'discard-review-request')) const wrapper = await page() const approve = () => row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')! await approve().trigger('click'); await flushPromises(); await accept() expect(wrapper.text()).toContain('discard-review-request'); expect(row(wrapper).text()).toContain('CD-001') await approve().trigger('click'); await flushPromises(); await confirmation().get('.el-message-box__btns .el-button').trigger('click'); await flushPromises() expect(api.review).toHaveBeenCalledOnce() await approve().trigger('click'); await flushPromises(); await confirmation().get('.el-message-box__headerbtn').trigger('click'); await flushPromises() expect(api.review).toHaveBeenCalledOnce(); expect(errorState(wrapper, '审核失败')).toBeUndefined() }) it('keeps list access readonly, checks approval status and rechecks permission after confirmation', async () => { can.mockImplementation(code => code === permission.discardList) const readonly = await page(); expect(row(readonly).findAll('button').map(item => item.text())).toEqual(['详情']) can.mockReturnValue(true); api.list.mockResolvedValueOnce({ records: [summary({ status: 'APPROVED' })], total: 1 }) const approved = await page(); expect(row(approved).findAll('button').map(item => item.text())).toEqual(['详情']) const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')!.trigger('click'); await flushPromises() can.mockImplementation(code => code !== permission.discardApprove); await accept(); expect(api.review).not.toHaveBeenCalled() }) it('downloads each photo in its explicit scope and keeps native viewer order, size and cleanup', async () => { const result = detail(); result.photos = [photo(), photo('9007199254740997')] const first = new Blob(['first'], { type: 'image/png' }); const second = new Blob(['second'], { type: 'image/png' }) let resolve!: (value: Blob) => void api.detail.mockResolvedValueOnce(result) api.photo.mockReturnValueOnce(new Promise(done => { resolve = done })).mockResolvedValueOnce(second) vi.mocked(URL.createObjectURL).mockImplementation(blob => blob === first ? 'blob:first' : 'blob:second') const { dialog } = await open(); expect(dialog.text()).toContain('照片加载中') resolve(first); await flushPromises() expect(api.photo.mock.calls).toEqual([['CD-001', '9007199254740996', '9007199254740994'], ['CD-001', '9007199254740997', '9007199254740994']]) const images = dialog.findAllComponents({ name: 'ElImage' }); expect(images).toHaveLength(2) expect(images[0]!.props()).toMatchObject({ src: 'blob:first', previewSrcList: ['blob:first', 'blob:second'], initialIndex: 0, previewTeleported: true, fit: 'cover' }) expect(images[1]!.props('initialIndex')).toBe(1) await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises() expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:first'); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:second') }) it('shows per-photo diagnostics and retries the same file, business number and customer', async () => { const result = detail(); result.photos = [photo()]; api.detail.mockResolvedValueOnce(result) api.photo.mockRejectedValueOnce(new ApiResponseError('图片失败', 'A0503', 503, null, 'discard-photo-request')) const { dialog } = await open(); expect(dialog.text()).toContain('discard-photo-request') await retry(errorState(dialog, '废弃照片加载失败')) expect(api.photo.mock.calls).toEqual([['CD-001', '9007199254740996', '9007199254740994'], ['CD-001', '9007199254740996', '9007199254740994']]) expect(dialog.findComponent({ name: 'ElImage' }).exists()).toBe(true) }) it('ignores late photos after closing and switching to another business number', async () => { const old = detail(); old.photos = [photo()]; api.detail.mockResolvedValueOnce(old) let resolve!: (value: Blob) => void api.photo.mockReturnValueOnce(new Promise(done => { resolve = done })) const { wrapper, dialog } = await open(); await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises() const next = detail(); next.summary = summary({ discardNo: 'CD-002', customerId: 'CUSTOMER-2' }); next.photos = [photo()] api.list.mockResolvedValueOnce({ records: [next.summary], total: 1 }); api.detail.mockResolvedValueOnce(next) wrapper.findComponent(QueryToolbar).vm.$emit('reset', { customerId: '', discardNo: '', inboundCode: '', status: '' }); await flushPromises() await row(wrapper, 'CD-002').findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises() expect(api.photo).toHaveBeenLastCalledWith('CD-002', '9007199254740996', 'CUSTOMER-2') resolve(new Blob(['old'], { type: 'image/png' })); await flushPromises() expect(URL.createObjectURL).toHaveBeenCalledOnce(); expect(dialog.props('title')).toBe('废弃单详情 · CD-002') }) it('ignores a detail response after unmounting before it can start photo downloads', async () => { let resolve!: (value: CargoDiscardDetail) => void api.detail.mockReturnValueOnce(new Promise(done => { resolve = done })) const { wrapper } = await open(); wrapper.unmount() const result = detail(); result.photos = [photo()]; resolve(result); await flushPromises() expect(api.photo).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled() }) it('keeps empty evidence explicit and never infers all inventory from missing carton marks', async () => { const result = detail(); result.items[0]!.cartonMarks = []; result.histories = []; result.summary.status = null api.list.mockResolvedValueOnce({ records: [summary({ status: null })], total: 1 }); api.detail.mockResolvedValueOnce(result) const { wrapper, dialog } = await open() expect(wrapper.text()).not.toContain('undefined'); expect(dialog.text()).not.toContain('undefined') expect(dialog.text()).toContain('暂无已提交的废弃现场照片'); expect(dialog.text()).toContain('暂无状态流转记录') expect(dialog.text()).not.toContain('全部库存'); expect(dialog.text()).not.toContain('历史记录') expect(dialog.findAllComponents(LineClampText).some(item => item.props('content') === '')).toBe(true) expect(dialog.text()).not.toContain('9007199254740995') }) it('blocks photo requests with a missing customer scope instead of sending an undefined identifier', async () => { const result = detail(); result.summary.customerId = null; result.photos = [photo()]; api.detail.mockResolvedValueOnce(result) const { dialog } = await open(); expect(dialog.text()).toContain('照片缺少客户范围') expect(api.photo).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled() }) it('keeps customer and list failures independent and retries each original query', async () => { api.customers.mockRejectedValueOnce(new ApiResponseError('客户失败', 'A0503', 503, null, 'discard-customer-request')) api.list.mockRejectedValueOnce(new ApiResponseError('列表失败', 'A0503', 503, null, 'discard-list-request')) const wrapper = await page(); expect(wrapper.text()).toContain('discard-customer-request'); expect(wrapper.text()).toContain('discard-list-request') await retry(errorState(wrapper, '废弃单列表加载失败')); expect(row(wrapper).text()).toContain('CD-001') await retry(errorState(wrapper, '客户列表加载失败')) expect(api.customers).toHaveBeenLastCalledWith({ page: 1, size: 200, status: 'ACTIVE' }) expect(api.list).toHaveBeenLastCalledWith({ page: 1, size: 10, customerId: undefined, discardNo: undefined, inboundCode: undefined, status: undefined }) }) it('ignores a stale list response after a newer query has completed', async () => { let resolve!: (value: { records: CargoDiscardSummary[]; total: number }) => void api.list.mockReturnValueOnce(new Promise(done => { resolve = done })) const wrapper = await page(); api.list.mockResolvedValueOnce({ records: [summary({ discardNo: 'CD-LATEST' })], total: 1 }) wrapper.findComponent(QueryToolbar).vm.$emit('search', { customerId: '', discardNo: 'CD-LATEST', inboundCode: '', status: '' }); await flushPromises() resolve({ records: [summary({ discardNo: 'CD-OLD' })], total: 1 }); await flushPromises() expect(wrapper.text()).toContain('CD-LATEST'); expect(wrapper.text()).not.toContain('CD-OLD') }) })