import ElementPlus from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus' import { AxiosError } from 'axios' import { createPinia, setActivePinia } from 'pinia' import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import { ApiResponseError } from '@/shared/api/http' import AppDialog from '@/shared/components/AppDialog.vue' import { confirmWeightResult, correctWeightResult, createWeightResult, deleteWeightResult, downloadWeightImage, downloadWeightThumbnail, getWeightWorkspace, getWeightPalletOptions, uploadWeightImage, } from '../api/weightOcr' import type { WeightResultResource, WeightWorkspaceResource } from '../types' import WeightOcrPanel from './WeightOcrPanel.vue' import InboundMeasurementDialog from '../../measurement/InboundMeasurementDialog.vue' vi.mock('../api/weightOcr', async () => { const actual = await vi.importActual('../api/weightOcr') return { ...actual, confirmWeightResult: vi.fn(), correctWeightResult: vi.fn(), createWeightResult: vi.fn(), deleteWeightResult: vi.fn(), downloadWeightImage: vi.fn(), downloadWeightThumbnail: vi.fn(), getWeightWorkspace: vi.fn(), getWeightPalletOptions: vi.fn(), uploadWeightImage: vi.fn(), } }) const pendingWeight: WeightResultResource = { id: '30001', fileId: '40001', palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', palletCapacityCbm: 1.2, palletTareWeightKg: 12, ocrProvider: 'FILE_WEIGHT_OCR', ocrRawText: '285.5', ocrWeightValue: 285.5, ocrWeightUnit: 'KG', confidence: 0.98, adjustedWeightValue: null, adjustedWeightUnit: null, grossWeightKg: 285.5, netWeightKg: null, recognitionStatus: 'PENDING_CONFIRM', adjustmentReason: null, adjustedBy: null, adjustedAt: null, confirmedBy: null, confirmedAt: null, palletSequence: 1, blockId: '70001', slotNo: 1, reservationId: '71001', locationId: '72001', locationCode: 'A01', sharedOverflow: false, slotStatus: 'BOUND', version: 0, } const workspace: WeightWorkspaceResource = { inboundId: '20001', inboundCode: 'RK-20001', processStatus: 'MEASURING', actualGrossWeightKg: null, actualNetWeightKg: null, weightResults: [pendingWeight], palletLocationGuide: { blockId: '70001', reservedSlotCount: 1, actualPalletCount: 1, reservedEmptySlotCount: 0, slots: [{ slotNo: 1, reservationId: '71001', locationId: '72001', locationCode: 'A01', sharedOverflow: false, slotStatus: 'BOUND', boundWeightResultId: '30001', palletSequence: 1, palletId: '50001', palletCode: 'TP-001', }], }, } const confirmedReviewWorkspace: WeightWorkspaceResource = { ...workspace, processStatus: 'MEASURE_PENDING', actualGrossWeightKg: 120, actualNetWeightKg: 108, weightResults: [{ ...pendingWeight, recognitionStatus: 'CONFIRMED', adjustedWeightValue: 120, adjustedWeightUnit: 'KG', grossWeightKg: 120, netWeightKg: 108, version: 1, }], } function palletPage(records = [{ palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12, }]) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } enableAutoUnmount(afterEach) const allWeightPermissions = [ 'platform:firstleg:inbound:measure:view', 'platform:firstleg:inbound:weight:edit', 'platform:firstleg:inbound:weight:confirm', ] function mountPanel(permissions = allWeightPermissions, props: Record = {}) { const pinia = createPinia() setActivePinia(pinia) const auth = useAuthStore() auth.token = 'token' auth.permissions = permissions useAccessPreviewStore().initializeAfterLogin() return mount(WeightOcrPanel, { attachTo: document.body, props: { customerId: '10001', inboundId: '20001', ...props }, global: { plugins: [pinia, ElementPlus] }, }) } async function openWeightDialog(wrapper: VueWrapper) { await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click') await flushPromises() } function dialogWeightInput() { const input = document.body.querySelector('input[aria-label="当前图片确认重量"]') if (!input) throw new Error('weight confirmation input is missing') return new DOMWrapper(input) } function dialogUnit(index: number) { const option = document.body.querySelectorAll('.el-dialog .el-segmented__item').item(index) return new DOMWrapper(option) } function dialogConfirmButton(text = '确认重量') { const button = [...document.body.querySelectorAll('.el-dialog__footer button')] .find((candidate) => candidate.textContent?.trim() === text) if (!button) throw new Error('weight confirmation button is missing') return new DOMWrapper(button) } describe('WeightOcrPanel confirmation behavior', () => { it('keeps the weight confirmation outside its measurement parent with independent fullscreen and cancel state', async () => { const pinia = createPinia() setActivePinia(pinia) const auth = useAuthStore() auth.token = 'token' auth.permissions = allWeightPermissions useAccessPreviewStore().initializeAfterLogin() const wrapper = mount(InboundMeasurementDialog, { attachTo: document.body, props: { modelValue: true, customerId: '10001', inboundId: '20001', inboundCode: 'RK-20001' }, global: { plugins: [pinia, ElementPlus], stubs: { InboundMeasurementPanel: { template: '
' } }, }, }) await flushPromises() const panel = wrapper.getComponent(WeightOcrPanel) await openWeightDialog(panel) const popup = panel.getComponent(AppDialog) const parent = document.querySelector('.inbound-measurement-workspace-dialog')! const child = document.querySelector('input[aria-label="当前图片确认重量"]')!.closest('.el-dialog')! expect(document.body.contains(child)).toBe(true) expect(parent.contains(child)).toBe(false) expect(child.classList.contains('admin-workbench')).toBe(true) await dialogWeightInput().setValue('286.125') await new DOMWrapper(child).get('button[aria-label="展开全屏"]').trigger('click') expect(child.classList.contains('is-fullscreen')).toBe(true) expect(parent.classList.contains('is-fullscreen')).toBe(false) expect(dialogWeightInput().element.value).toBe('286.125') await new DOMWrapper(child).findAll('button').find(button => button.text() === '取消')!.trigger('click') await flushPromises() expect(popup.props('modelValue')).toBe(false) expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true) expect(wrapper.emitted('update:modelValue')).toBeUndefined() await openWeightDialog(panel) expect(popup.props('modelValue')).toBe(true) expect(child.classList.contains('is-fullscreen')).toBe(false) expect(confirmWeightResult).not.toHaveBeenCalled() }) beforeEach(() => { vi.clearAllMocks() document.body.innerHTML = '' vi.mocked(getWeightWorkspace).mockResolvedValue(workspace) vi.mocked(getWeightPalletOptions).mockResolvedValue(palletPage()) vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' })) vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' })) vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:test-image'), revokeObjectURL: vi.fn(), }) vi.mocked(confirmWeightResult).mockResolvedValue({ ...pendingWeight, recognitionStatus: 'CONFIRMED', grossWeightKg: 297.5, netWeightKg: 285.5, version: 1, }) }) it('opens the editable confirmation dialog immediately after upload', async () => { vi.mocked(uploadWeightImage).mockResolvedValue({ fileId: '40002', fileName: 'weight.jpg', contentType: 'image/jpeg', sizeBytes: 100, recognitionStatus: 'SUCCESS', weightValue: 12.5, weightUnit: 'KG', confidence: 0.98, error: null, }) vi.mocked(createWeightResult).mockResolvedValue({ ...pendingWeight, id: '30002', fileId: '40002', ocrWeightValue: 12.5, }) const wrapper = mountPanel() await flushPromises() await wrapper.find('.pallet-picker .el-select').trigger('click') expect(document.body.textContent).toContain('木托盘 · 皮重 12.000 KG · 容量 1.2 m³') await new DOMWrapper(document.body).find('.el-select-dropdown__item').trigger('click') const input = wrapper.get('input[type="file"]') const file = new File(['image'], 'weight.jpg', { type: 'image/jpeg' }) Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }) await input.trigger('change') await flushPromises() expect(uploadWeightImage).toHaveBeenCalledWith('10001', '20001', file) expect(createWeightResult).toHaveBeenCalledWith('10001', '20001', expect.objectContaining({ palletId: '50001' })) expect(document.body.textContent).toContain('确认称重图片') expect(document.body.textContent).toContain('当前托盘皮重') expect(document.body.textContent).toContain('OCR 识别毛重') expect(document.body.textContent).toContain('12.500 KG') expect(document.body.querySelector('input[aria-label="当前图片确认重量"]')).not.toBeNull() }) it('renders thirty compact image cards without technical OCR details', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, weightResults: Array.from({ length: 30 }, (_, index) => ({ ...pendingWeight, id: String(30001 + index), fileId: String(40001 + index), ocrRawText: `raw-${index}`, })), }) const wrapper = mountPanel() await flushPromises() expect(wrapper.findAll('.weight-card')).toHaveLength(30) expect(wrapper.findAll('.weight-thumb')).toHaveLength(30) expect(wrapper.text()).not.toContain('置信度') expect(wrapper.text()).not.toContain('OCR 原文') expect(wrapper.text()).not.toContain('FILE_WEIGHT_OCR') expect(wrapper.text()).not.toContain('raw-0') }) it('renders the server-bound location and status once in the compact card facts', async () => { const wrapper = mountPanel() await flushPromises() const card = wrapper.get('.weight-card') expect(card.text()).toContain('库位A01 · 待上架') expect(card.text()).toContain('待上架') expect(card.find('.weight-card__putaway').exists()).toBe(false) expect(card.get('.weight-card__facts').text()).toContain('库位A01 · 待上架') }) it('keeps whole-inbound reservation guidance out of the panel and shows location on each weight card', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, palletLocationGuide: { blockId: '70001', reservedSlotCount: 3, actualPalletCount: 2, reservedEmptySlotCount: 1, slots: [ { slotNo: 1, reservationId: '71001', locationId: '72001', locationCode: 'A01', sharedOverflow: false, slotStatus: 'BOUND', boundWeightResultId: '30001', palletSequence: 1, palletId: '50001', palletCode: 'TP-001' }, { slotNo: 2, reservationId: '71002', locationId: '72002', locationCode: 'A02', sharedOverflow: false, slotStatus: 'TRANSFERRED', boundWeightResultId: '30002', palletSequence: 2, palletId: '50002', palletCode: 'TP-002' }, { slotNo: 3, reservationId: '71003', locationId: '72003', locationCode: 'A03', sharedOverflow: false, slotStatus: 'RESERVED_EMPTY', boundWeightResultId: null, palletSequence: null, palletId: null, palletCode: null }, ], }, }) const wrapper = mountPanel() await flushPromises() expect(wrapper.find('.putaway-guide').exists()).toBe(false) expect(wrapper.text()).not.toContain('整票上架引导') expect(wrapper.get('.weight-card__facts').text()).toContain('库位A01 · 待上架') }) it('does not render a full-width placeholder when no whole-inbound guide exists', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, palletLocationGuide: null }) const wrapper = mountPanel() await flushPromises() expect(wrapper.text()).not.toContain('尚未分配整票预留库位') expect(wrapper.find('.putaway-guide-empty').exists()).toBe(false) }) it('marks A0 as a temporary overflow location from the server flag', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, weightResults: [{ ...pendingWeight, locationCode: 'A0', sharedOverflow: true }], palletLocationGuide: { ...workspace.palletLocationGuide!, slots: [{ ...workspace.palletLocationGuide!.slots[0]!, locationCode: 'A0', sharedOverflow: true, }], }, }) const wrapper = mountPanel() await flushPromises() expect(wrapper.text()).toContain('A0 临时溢出位') expect(wrapper.get('.weight-card__facts').text()).toContain('库位A0 · 待上架 · A0 临时溢出位') expect(wrapper.find('.putaway-guide').exists()).toBe(false) }) it.each([ ['TRANSFERRED', '已上架'], ['RELEASED', '已释放'], ['RESERVED_EMPTY', '预留空位'], ] as const)('renders the authoritative %s slot status as %s', async (slotStatus, label) => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, weightResults: [{ ...pendingWeight, slotStatus }], }) const wrapper = mountPanel() await flushPromises() expect(wrapper.text()).toContain(label) }) it('shows an explicit unassigned state when the server has not bound a slot', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, weightResults: [{ ...pendingWeight, palletSequence: null, blockId: null, slotNo: null, reservationId: null, locationId: null, locationCode: null, sharedOverflow: null, slotStatus: null, }], }) const wrapper = mountPanel() await flushPromises() expect(wrapper.text()).not.toContain('托盘未分配') expect(wrapper.get('.weight-card__facts').text()).toContain('库位未分配') }) it('deletes a pending image with its optimistic-lock version', async () => { const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) vi.mocked(deleteWeightResult).mockResolvedValue(undefined) vi.mocked(getWeightWorkspace) .mockResolvedValueOnce(workspace) .mockResolvedValueOnce({ ...workspace, weightResults: [], palletLocationGuide: { ...workspace.palletLocationGuide!, actualPalletCount: 0, reservedEmptySlotCount: 1, slots: [{ ...workspace.palletLocationGuide!.slots[0]!, slotStatus: 'RESERVED_EMPTY', boundWeightResultId: null, palletSequence: null, palletId: null, palletCode: null, }], }, }) const wrapper = mountPanel() await flushPromises() await wrapper.get('button[aria-label="删除称重图片 1"]').trigger('click') await flushPromises() expect(deleteWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', 0) expect(confirm).toHaveBeenCalledWith( expect.stringContaining('对应库位仍为本票保留,不会释放给其他订单'), '删除称重图片', expect.any(Object), ) expect(getWeightWorkspace).toHaveBeenCalledTimes(2) expect(wrapper.findAll('.weight-card')).toHaveLength(0) expect(wrapper.text()).toContain('尚未上传称重图片') expect(wrapper.find('.putaway-guide').exists()).toBe(false) }) it('keeps the image and server workspace visible when deletion fails', async () => { vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) const errorMessage = vi.spyOn(ElMessage, 'error') vi.mocked(deleteWeightResult).mockRejectedValueOnce( new ApiResponseError('无权删除当前称重图片', 'A0403', 403), ) const wrapper = mountPanel() await flushPromises() await wrapper.get('button[aria-label="删除称重图片 1"]').trigger('click') await flushPromises() expect(wrapper.findAll('.weight-card')).toHaveLength(1) expect(wrapper.text()).toContain('A01') expect(getWeightWorkspace).toHaveBeenCalledTimes(1) expect(errorMessage).toHaveBeenCalledWith(expect.objectContaining({ message: '当前账号无权访问该数据,请联系管理员确认权限', })) }) it('shows a retryable workspace load failure without fabricating putaway guidance', async () => { vi.mocked(getWeightWorkspace).mockRejectedValueOnce( new ApiResponseError('称重工作区暂不可用', 'A0500', 503), ) const wrapper = mountPanel() await flushPromises() expect(wrapper.text()).toContain('称重数据加载失败') expect(wrapper.text()).toContain('称重工作区暂不可用') expect(wrapper.find('.putaway-guide').exists()).toBe(false) expect(wrapper.find('.putaway-guide-empty').exists()).toBe(false) }) it('shows only the key weights and allows direct confirmation', async () => { const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) const dialogText = document.body.querySelector('.el-dialog')?.textContent || '' expect(dialogText).toContain('当前托盘皮重') expect(dialogText).toContain('12.000 KG') expect(dialogText).toContain('OCR 识别毛重') expect(dialogText).toContain('285.500 KG') expect(dialogText).toContain('确认含托盘毛重') expect(dialogText).toContain('托盘类型') expect(dialogText).toContain('木托盘') expect(dialogText).not.toContain('TP-001') for (const hiddenLabel of ['托盘编码', '托盘容量', '识别状态', '置信度', '服务端毛重', '服务端净重', 'OCR 原文']) { expect(dialogText).not.toContain(hiddenLabel) } expect(dialogConfirmButton().attributes('disabled')).toBeUndefined() await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledTimes(1) expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 }) expect(correctWeightResult).not.toHaveBeenCalled() }) it('emits the latest server total after confirming a weight', async () => { vi.mocked(getWeightWorkspace) .mockResolvedValueOnce(workspace) .mockResolvedValueOnce({ ...workspace, actualGrossWeightKg: 1787.5, actualNetWeightKg: 1747.5, weightResults: [{ ...pendingWeight, recognitionStatus: 'CONFIRMED', grossWeightKg: 297.5, netWeightKg: 285.5, version: 1 }], }) const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) await dialogConfirmButton().trigger('click') await flushPromises() expect(wrapper.emitted('summaryChanged')).toEqual([[1787.5, 1747.5, true]]) expect(wrapper.emitted('changed')).toHaveLength(1) expect(wrapper.text()).toContain('实测毛重') expect(wrapper.text()).toContain('货物重量') expect(wrapper.text()).toContain('1787.500 KG') expect(wrapper.text()).toContain('1747.500 KG') }) it('opens the explicit table correction action and atomically confirms an approved weight during measurement review', async () => { const confirmed = { ...pendingWeight, recognitionStatus: 'CONFIRMED', adjustedWeightValue: 120, adjustedWeightUnit: 'KG', grossWeightKg: 120, netWeightKg: 108, version: 1, } vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, processStatus: 'MEASURE_PENDING', actualGrossWeightKg: 120, actualNetWeightKg: 108, weightResults: [confirmed], }) vi.mocked(confirmWeightResult).mockResolvedValue({ ...confirmed, grossWeightKg: 125, netWeightKg: 113, version: 2 }) const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true, tableMode: true }) await flushPromises() expect(wrapper.find('input[type="file"]').exists()).toBe(false) expect(wrapper.text()).not.toContain('删除图片') expect(wrapper.find('.weight-data-table').exists()).toBe(true) expect(wrapper.find('.weight-grid').exists()).toBe(false) expect(wrapper.get('.weight-data-table thead th').text()).toBe('操作') expect(wrapper.find('.pallet-picker').exists()).toBe(false) await wrapper.get('button[aria-label="修改重量 1"]').trigger('click') await flushPromises() await dialogWeightInput().setValue('125') const reason = document.body.querySelector('textarea[placeholder="请填写本次毛重修正原因"]') expect(reason).not.toBeNull() await new DOMWrapper(reason!).setValue('复核磅秤读数') await dialogConfirmButton('保存重量修正').trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 1, adjustedWeightValue: 125, adjustedWeightUnit: 'KG', adjustmentReason: '复核磅秤读数', }) expect(correctWeightResult).not.toHaveBeenCalled() }) it.each([ { permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:edit')), status: 'MEASURE_PENDING', correctionMode: true }, { permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:confirm')), status: 'MEASURE_PENDING', correctionMode: true }, { permissions: allWeightPermissions, status: 'WAIT_SPLIT', correctionMode: true }, { permissions: allWeightPermissions, status: 'MEASURE_PENDING', correctionMode: false }, ])('keeps table correction unavailable for missing permissions or inactive review: %j', async ({ permissions, status, correctionMode }) => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...confirmedReviewWorkspace, processStatus: status }) const wrapper = mountPanel(permissions, { reviewMode: true, correctionMode, tableMode: true }) await flushPromises() expect(wrapper.find('button[aria-label="修改重量 1"]').exists()).toBe(false) await openWeightDialog(wrapper) expect(dialogWeightInput().attributes('disabled')).toBeDefined() expect(wrapper.getComponent(AppDialog).props('showConfirm')).toBe(false) expect(confirmWeightResult).not.toHaveBeenCalled() }) it('allows cancelling and reopening correction, and preserves the draft for retry after a save fails', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue(confirmedReviewWorkspace) vi.mocked(confirmWeightResult) .mockRejectedValueOnce(new Error('重量写入失败')) .mockResolvedValueOnce({ ...confirmedReviewWorkspace.weightResults[0], grossWeightKg: 125, netWeightKg: 113, version: 2 }) const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true, tableMode: true }) await flushPromises() const correctionButton = wrapper.get('button[aria-label="修改重量 1"]') await correctionButton.trigger('click') await flushPromises() await dialogWeightInput().setValue('125') await new DOMWrapper(document.body.querySelector('#weight-correction-reason')!).setValue('复核磅秤读数') const cancelButton = [...document.body.querySelectorAll('.el-dialog__footer button')].find(button => button.textContent?.trim() === '取消')! await new DOMWrapper(cancelButton).trigger('click') await flushPromises() expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false) expect(confirmWeightResult).not.toHaveBeenCalled() await correctionButton.trigger('click') await flushPromises() expect(Number(dialogWeightInput().element.value)).toBe(125) await dialogConfirmButton('保存重量修正').trigger('click') await flushPromises() expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true) expect(Number(dialogWeightInput().element.value)).toBe(125) expect(dialogConfirmButton('保存重量修正').attributes('disabled')).toBeUndefined() expect(wrapper.emitted('changed')).toBeUndefined() await dialogConfirmButton('保存重量修正').trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledTimes(2) expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false) expect(wrapper.emitted('changed')).toHaveLength(1) }) it('closes the confirmation dialog immediately after a successful confirmation', async () => { const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true) await dialogConfirmButton().trigger('click') await flushPromises() expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false) }) it('requires a pallet selection before opening the file picker', async () => { const wrapper = mountPanel() await flushPromises() const click = vi.spyOn(wrapper.get('input[type="file"]').element as HTMLInputElement, 'click') await wrapper.findAll('button').find((button) => button.text().includes('上传称重图片'))?.trigger('click') expect(click).not.toHaveBeenCalled() }) it('shows an actionable empty state when the warehouse has no pallet options', async () => { vi.mocked(getWeightPalletOptions).mockResolvedValueOnce(palletPage([])) const wrapper = mountPanel() await flushPromises() expect(wrapper.text()).toContain('当前仓库暂无可选托盘') expect(wrapper.findAll('button').some((button) => button.text().includes('重新加载'))).toBe(true) }) it('renders records from the formal paged response without showing a load failure', async () => { vi.mocked(getWeightPalletOptions).mockResolvedValueOnce({ records: [ { palletId: '2092803000000000001', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 }, { palletId: '2092803000000000002', palletCode: 'TP-12', palletType: '塑料托盘', capacityCbm: 1.1, tareWeightKg: 10 }, ], page: 1, size: 20, total: 2, pages: 1, }) const wrapper = mountPanel() await flushPromises() await wrapper.find('.pallet-picker .el-select').trigger('click') await flushPromises() expect(document.body.textContent).toContain('木托盘 · 皮重 12.000 KG · 容量 1.2 m³') expect(document.body.textContent).toContain('塑料托盘 · 皮重 10.000 KG · 容量 1.1 m³') expect(wrapper.text()).not.toContain('可选托盘加载失败') }) it('remote search keyword uses page 1 and size 50', async () => { const wrapper = mountPanel() await flushPromises() vi.useFakeTimers() try { await wrapper.find('.pallet-picker .el-select').trigger('click') await wrapper.find('.pallet-picker input').setValue(' 木托盘 ') await vi.advanceTimersByTimeAsync(350) await flushPromises() expect(getWeightPalletOptions).toHaveBeenLastCalledWith('10001', '20001', expect.objectContaining({ page: 1, size: 50, keyword: '木托盘', signal: expect.any(AbortSignal), })) } finally { vi.useRealTimers() } }) it('shows pallet loading failures without hiding existing weight results', async () => { vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503)) const wrapper = mountPanel() await flushPromises() expect(wrapper.text()).toContain('托盘服务暂不可用') expect(wrapper.findAll('.weight-card')).toHaveLength(1) }) it('shows a network error as a pallet candidate load failure without hiding historical snapshots', async () => { vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new AxiosError('Network Error', 'ERR_NETWORK')) const wrapper = mountPanel() await flushPromises() expect(wrapper.text()).toContain('可选托盘加载失败') expect(wrapper.findAll('.weight-card')).toHaveLength(1) expect(wrapper.text()).toContain('木托盘') expect(wrapper.text()).toContain('12.000 KG') }) it('does not load weight or pallet data without measurement view permission', async () => { const wrapper = mountPanel([]) await flushPromises() expect(wrapper.text()).toContain('缺少入库测量查看权限') expect(getWeightWorkspace).not.toHaveBeenCalled() expect(getWeightPalletOptions).not.toHaveBeenCalled() }) it('keeps historical palletCode snapshots visible for a read-only operator', async () => { const wrapper = mountPanel(['platform:firstleg:inbound:measure:view']) await flushPromises() expect(getWeightWorkspace).toHaveBeenCalledOnce() expect(getWeightPalletOptions).not.toHaveBeenCalled() expect(wrapper.find('.pallet-picker .el-select').exists()).toBe(false) expect(wrapper.text()).toContain('TP-001') expect(wrapper.text()).toContain('木托盘') expect(wrapper.text()).toContain('12.000 KG') }) it('does not request pallet candidates when the inbound status is not editable', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, processStatus: 'MEASURE_PENDING' }) mountPanel() await flushPromises() expect(getWeightPalletOptions).not.toHaveBeenCalled() }) it('virtualizes one thousand pallet candidates instead of rendering one thousand option nodes', async () => { vi.mocked(getWeightPalletOptions).mockResolvedValue(palletPage(Array.from({ length: 1_000 }, (_, index) => ({ palletId: String(60_000 + index), palletCode: `TP-${index}`, palletType: `托盘类型 ${index}`, capacityCbm: 1.2, tareWeightKg: 12, })))) const wrapper = mountPanel() await flushPromises() await wrapper.find('.pallet-picker .el-select').trigger('click') await flushPromises() const renderedOptions = document.body.querySelectorAll('.el-select-dropdown__item') expect(renderedOptions.length).toBeGreaterThan(0) expect(renderedOptions.length).toBeLessThan(100) }) it('aborts an in-flight pallet request and ignores its late result when the panel closes', async () => { let resolveOptions: ((value: Awaited>) => void) | undefined vi.mocked(getWeightPalletOptions).mockImplementationOnce(() => new Promise((resolve) => { resolveOptions = resolve })) const wrapper = mountPanel() await flushPromises() const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2]?.signal wrapper.unmount() resolveOptions?.(palletPage([{ palletId: 'late', palletCode: 'TP-LATE', palletType: '过期托盘', capacityCbm: 1, tareWeightKg: 10, }])) await flushPromises() expect(signal?.aborted).toBe(true) expect(document.body.textContent).not.toContain('过期托盘') }) it('keeps a low-confidence OCR result focused on the editable weight', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, weightResults: [{ ...pendingWeight, ocrRawText: '5360', ocrWeightValue: 5360, confidence: 0.51863 }], }) const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) const dialogText = document.body.textContent || '' expect(dialogText).toContain('OCR 识别毛重') expect(dialogText).toContain('5360.000 KG') expect(dialogText).toContain('确认含托盘毛重') expect(dialogText).not.toContain('置信度') expect(dialogText).not.toContain('OCR 原文') expect(dialogText).not.toContain('OCR 可能误读数字或小数点') expect(dialogConfirmButton().attributes('disabled')).toBeUndefined() await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 }) }) it('sends the adjusted value and a specific audit reason in the same confirmation request', async () => { const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) await dialogWeightInput().setValue('286.125') const expand = document.body.querySelector('button[aria-label="展开全屏"]') expect(expand).not.toBeNull() await new DOMWrapper(expand!).trigger('click') await flushPromises() expect(document.body.querySelector('.el-dialog.is-fullscreen')).not.toBeNull() expect(dialogWeightInput().element.value).toBe('286.125') const collapse = document.body.querySelector('button[aria-label="退出全屏"]') await new DOMWrapper(collapse!).trigger('click') await flushPromises() expect(dialogWeightInput().element.value).toBe('286.125') expect(confirmWeightResult).not.toHaveBeenCalled() await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledTimes(1) expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0, adjustedWeightValue: 286.125, adjustedWeightUnit: 'KG', adjustmentReason: 'OCR 原值 285.500 KG,人工确认 286.125 KG', }) expect(correctWeightResult).not.toHaveBeenCalled() }) it('sends an LB adjustment atomically after switching units', async () => { const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) await dialogWeightInput().setValue('30.125') await dialogUnit(1).trigger('click') await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0, adjustedWeightValue: 30.125, adjustedWeightUnit: 'LB', adjustmentReason: 'OCR 原值 285.500 KG,人工确认 30.125 LB', }) expect(correctWeightResult).not.toHaveBeenCalled() }) it('rejects an LB value that would round to zero kilograms', async () => { const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) await dialogWeightInput().setValue('0.001') await dialogUnit(1).trigger('click') expect(dialogConfirmButton().attributes('disabled')).toBeDefined() expect(confirmWeightResult).not.toHaveBeenCalled() }) it('rejects a positive gross weight that does not exceed the pallet tare', async () => { const warning = vi.spyOn(ElMessage, 'warning') const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) await dialogWeightInput().setValue('0.002') await dialogUnit(1).trigger('click') await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).not.toHaveBeenCalled() expect(warning).toHaveBeenCalledWith('称重毛重必须大于托盘皮重 12.000 KG') }) it('clamps a value outside the safe three-decimal JavaScript range', async () => { const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) const input = dialogWeightInput() await input.setValue('9000000000001') expect((input.element as HTMLInputElement).value).toBe('9000000000000.000') await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0, adjustedWeightValue: 9_000_000_000_000, adjustedWeightUnit: 'KG', adjustmentReason: 'OCR 原值 285.500 KG,人工确认 9000000000000.000 KG', }) }) it('rounds an input with more than three decimals before confirmation', async () => { const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) const input = dialogWeightInput() await input.setValue('13.2345') expect((input.element as HTMLInputElement).value).toBe('13.235') await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0, adjustedWeightValue: 13.235, adjustedWeightUnit: 'KG', adjustmentReason: 'OCR 原值 285.500 KG,人工确认 13.235 KG', }) }) it('confirms an existing corrected value without creating another correction', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, weightResults: [{ ...pendingWeight, adjustedWeightValue: 286.125, adjustedWeightUnit: 'KG', adjustmentReason: '首次修正', version: 1, }], }) const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 1 }) expect(correctWeightResult).not.toHaveBeenCalled() }) it('allows a confirm-only operator to accept an unchanged OCR value', async () => { const wrapper = mountPanel([ 'platform:firstleg:inbound:measure:view', 'platform:firstleg:inbound:weight:confirm', ]) await flushPromises() await openWeightDialog(wrapper) expect(dialogWeightInput().attributes('disabled')).toBeDefined() await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 }) }) it('does not render confirmation for an edit-only operator', async () => { const wrapper = mountPanel([ 'platform:firstleg:inbound:measure:view', 'platform:firstleg:inbound:weight:edit', ]) await flushPromises() await openWeightDialog(wrapper) const confirmButtons = [...document.body.querySelectorAll('.el-dialog__footer button')] .filter((button) => button.textContent?.trim() === '确认重量') expect(confirmButtons).toHaveLength(0) expect(confirmWeightResult).not.toHaveBeenCalled() }) it('supports manual entry when OCR did not return a value', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, weightResults: [{ ...pendingWeight, ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, recognitionStatus: 'NEEDS_MANUAL', }], }) const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) expect(dialogConfirmButton().attributes('disabled')).toBeDefined() await dialogWeightInput().setValue('13.500') await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0, adjustedWeightValue: 13.5, adjustedWeightUnit: 'KG', adjustmentReason: 'OCR 原值 —,人工确认 13.500 KG', }) }) it('normalizes a supported OCR unit alias with surrounding whitespace', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, weightResults: [{ ...pendingWeight, ocrWeightUnit: ' lbs ' }], }) const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 }) }) it('sends an explicit correction when OCR returns an unsupported unit', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, weightResults: [{ ...pendingWeight, ocrWeightUnit: 'G' }], }) const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0, adjustedWeightValue: 285.5, adjustedWeightUnit: 'KG', adjustmentReason: 'OCR 原值 285.500 G,人工确认 285.500 KG', }) }) it('sends an explicit correction when OCR returns a value without a unit', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, weightResults: [{ ...pendingWeight, ocrWeightUnit: null }], }) const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) await dialogConfirmButton().trigger('click') await flushPromises() expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0, adjustedWeightValue: 285.5, adjustedWeightUnit: 'KG', adjustmentReason: 'OCR 原值 285.500(单位缺失),人工确认 285.500 KG', }) }) it('keeps the edited draft available when confirmation fails', async () => { const errorMessage = vi.spyOn(ElMessage, 'error') vi.mocked(confirmWeightResult).mockRejectedValueOnce( new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 409, undefined, 'request-1'), ) const wrapper = mountPanel() await flushPromises() await openWeightDialog(wrapper) const input = dialogWeightInput() await input.setValue('286.125') await dialogConfirmButton().trigger('click') await flushPromises() expect((input.element as HTMLInputElement).value).toBe('286.125') expect(wrapper.text()).not.toContain('操作未完成') expect(errorMessage).toHaveBeenCalledWith(expect.objectContaining({ message: '数据版本已变化,请刷新后重试(请求 ID:request-1)', duration: 5_000, showClose: true, })) expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true) expect(confirmWeightResult).toHaveBeenCalledTimes(1) }) })