import ElementPlus from 'element-plus' import { createPinia, setActivePinia } from 'pinia' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import { adjustContainerFee, createContainerFee, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees' import ContainerFeesPanel from './ContainerFeesPanel.vue' import AppDialog from '@/shared/components/AppDialog.vue' import { createContainerFeeAllocationGroup } from '../api/containerFees' vi.mock('../api/containerFees', async (importOriginal) => { const actual = await importOriginal() return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), getContainerSettlementDetails: vi.fn(), createContainerFee: vi.fn(), createContainerFeeAllocationGroup: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() } }) const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8') const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8') const containerDetail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8') class ResizeObserverStub { observe() {}; unobserve() {}; disconnect() {} } function memoryStorage(): Storage { const values = new Map(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } } async function mountPanel(permissions: string[], customers = [{ accountId: '11', customerName: '客户A' }], containerBookingId = '99') { const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions useAccessPreviewStore().initializeAfterLogin() const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', cabinetNo: 'CAB-9', containerNo: 'CONT-9', containerBookingId, customers, businessReferences: [{ accountId: '11', customerName: '客户A', orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' }] }, global: { plugins: [pinia, ElementPlus] } }) await flushPromises(); return wrapper } beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()) vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([]) vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([]) vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: [], costs: [] }); vi.mocked(createContainerFee).mockReset() vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never) vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' }) vi.mocked(createContainerFeeAllocationGroup).mockReset().mockResolvedValue({} as never) document.body.innerHTML = '' }) describe('ContainerFeesPanel contract', () => { it('连续分摊表保留两个客户归属,合计不符时阻止保存,全屏后按原归属提交', async () => { vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: null, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }]) const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'], [{ accountId: '11', customerName: '客户A' }, { accountId: '22', customerName: '客户B' }]) await wrapper.setProps({ businessReferences: [ { accountId: '11', customerName: '客户A', orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' }, { accountId: '22', customerName: '客户B', orderId: '502', orderNo: 'FL-502', inboundId: null, inboundCode: null }, ] }) const state = (wrapper.vm.$ as unknown as { setupState: { accountId: string; openCreate(): void; save(): Promise; form: { feeItemId: string; amount: number; remark: string } } }).setupState state.accountId = '11' await flushPromises() state.openCreate() await flushPromises() state.form.feeItemId = '7'; state.form.amount = 100; state.form.remark = '分摊测试备注' const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('新增柜次费用'))! const rows = dialog.findAll('table[aria-label="业务归属分摊"] tbody tr') expect(rows).toHaveLength(2) expect(rows[0]!.text()).toContain('客户A') expect(rows[1]!.text()).toContain('客户B') expect(rows[1]!.text()).toContain('无入仓码') rows[0]!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 40) rows[1]!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 50) await state.save() expect(createContainerFeeAllocationGroup).not.toHaveBeenCalled() rows[1]!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 60) await dialog.get('[aria-label="展开全屏"]').trigger('click') expect(dialog.find('textarea').element.value).toBe('分摊测试备注') await state.save() expect(createContainerFeeAllocationGroup).toHaveBeenCalledWith('9', { fee: expect.objectContaining({ amount: 100, feeItemId: '7', remark: '分摊测试备注', direction: 'RECEIVABLE' }), allocations: [ { customerId: '11', orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001', amount: 40 }, { customerId: '22', orderId: '502', orderNo: 'FL-502', inboundId: null, inboundCode: null, amount: 60 }, ], }) wrapper.unmount() }) it.each(['success', 'failure'])('ignores a closed editor request that finishes late: %s', async (outcome) => { const item = { id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false } let resolveOld!: (items: typeof item[]) => void let rejectOld!: (reason: Error) => void vi.mocked(listApplicableFeeItems).mockImplementationOnce(() => new Promise((resolve, reject) => { resolveOld = resolve; rejectOld = reject })) vi.mocked(listApplicableFeeItems).mockResolvedValueOnce([{ ...item, defaultPrice: 80 }]) const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view']) const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void editorOpen: boolean form: { amount: number } feeItems: typeof item[] feeItemsLoading: boolean feeItemsError: unknown } }).setupState state.openCreate(); await flushPromises() state.editorOpen = false; await flushPromises() state.openCreate(); await flushPromises() expect(state.feeItems[0]?.defaultPrice).toBe(80) if (outcome === 'success') resolveOld([item]) else rejectOld(new Error('旧窗口请求失败')) await flushPromises() expect(state.feeItemsError).toBeNull() expect(state.feeItemsLoading).toBe(false) const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((field) => field.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' }) select.vm.$emit('update:modelValue', '7'); select.vm.$emit('change', '7') await flushPromises() expect(state.form.amount).toBe(80) wrapper.unmount() }) it('does not let an old completion clear the current loading state or refill a closed editor', async () => { const item = { id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false } let resolveOld!: (items: typeof item[]) => void let resolveCurrent!: (items: typeof item[]) => void vi.mocked(listApplicableFeeItems) .mockImplementationOnce(() => new Promise((resolve) => { resolveOld = resolve })) .mockImplementationOnce(() => new Promise((resolve) => { resolveCurrent = resolve })) const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view']) const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; editorOpen: boolean; feeItems: unknown[]; feeItemsLoading: boolean; feeItemsError: unknown } }).setupState state.openCreate(); await flushPromises() state.editorOpen = false state.openCreate(); await flushPromises() resolveOld([item]); await flushPromises() expect(state.feeItems).toEqual([]) expect(state.feeItemsLoading).toBe(true) state.editorOpen = false; await flushPromises() resolveCurrent([{ ...item, defaultPrice: 80 }]); await flushPromises() expect(state.feeItems).toEqual([]) expect(state.feeItemsLoading).toBe(false) expect(state.feeItemsError).toBeNull() wrapper.unmount() }) it('prefills the selected default, clears stale prices and retains manual overrides', async () => { const base = { name: '操作费', feeCode: 'OPERATION', supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false } vi.mocked(listApplicableFeeItems).mockResolvedValue([ { ...base, id: '7', defaultPrice: 60 }, { ...base, id: '8', defaultPrice: null }, { ...base, id: '9', defaultPrice: 0 }, ]) const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view']) const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise resetForm(row: Record): void form: { feeItemId: string; amount: number } } }).setupState state.resetForm({ feeItemId: '7', direction: 'RECEIVABLE', businessDate: '2026-08-12', amount: 100 }) await flushPromises() expect(state.form.amount).toBe(100) state.openCreate(); await flushPromises() const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((item) => item.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' }) for (const [id, price] of [['7', 60], ['8', 0], ['9', 0]] as const) { select.vm.$emit('update:modelValue', id); select.vm.$emit('change', id) await flushPromises() expect(state.form.amount).toBe(price) } select.vm.$emit('update:modelValue', '7'); select.vm.$emit('change', '7') await flushPromises() state.form.amount = 75 await state.save(); await flushPromises() expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ feeItemId: '7', amount: 75 })) }) it('keeps fee controls and table utilities in compact aligned toolbars', () => { expect(panel).toContain('class="fees-header__actions"') expect(panel).toContain('class="customer-context"') expect(panel).toContain('