import { defineComponent, h, reactive } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import InventoryTurnoverView from './InventoryTurnoverView.vue' import type { TurnoverQuery, TurnoverResponse, TurnoverRow } from '../turnoverTypes' const mocks = vi.hoisted(() => ({ route: { query: {} as Record }, allowed: true, overview: vi.fn(), turnover: vi.fn(), daily: vi.fn(), push: vi.fn() })) vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) })) vi.mock('../api/analytics', () => ({ analyticsApi: mocks })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) })) const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup: () => () => h('div') }) const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-actions']?.(), ...(p.data ?? []).flatMap((row: TurnoverRow) => [slots.owner?.({ row }), slots.sku?.({ row }), slots.status?.({ row })])]) } }) const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } }) const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } }) const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] } const risk = { matchedSkuCount: 0, knownPhysicalQuantity: 0, unknownSkuCount: 0, physicalQuantityRatio: 0 } const row: TurnoverRow = { customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutbound30d: risk, slowCoverage: risk, unassessableSkuCount: 0 } function response(q: TurnoverQuery): TurnoverResponse { return { context: { snapshotContext: q.snapshotContext || `token-${q.warehouseCode}`, sourceAccount: q.sourceAccount, warehouseCode: q.warehouseCode, endDate: q.endDate, baseWindowDays: q.baseWindowDays, queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', inventoryObservedFrom: null, inventoryObservedUntil: null, sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1', dailySamples: [], missingSampleDates: ['2026-10-12'], accumulationStartedOn: null, analyticsContext: { salesRunId: '11', inventoryRunId: '22', salesScopeHash: 'a', inventoryScopeHash: 'b', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' } }, summary: q.includeOverview ? { physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 } } const wrappers: ReturnType[] = [] function page() { const w = mount(InventoryTurnoverView, { global: { stubs: { AdminPage: shell, DataTable: table, QueryToolbar: toolbar, AnalyticsChart: true, ChartDetailsDialog: true, AppDialog: true, ApiErrorMeta: true, 'el-button': button, 'el-tag': shell } } }); wrappers.push(w); return w } async function change(w: ReturnType, patch: object, search = false) { const t = w.findComponent(toolbar); t.vm.$emit('update:modelValue', { ...t.props('modelValue'), ...patch }); await flushPromises() if (search) { t.vm.$emit('search'); await flushPromises() } } beforeEach(() => { vi.clearAllMocks(); mocks.allowed = true; mocks.route = reactive({ query: { endDate: '2026-10-12' } }); mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A' }, { warehouseCode: 'B' }] }); mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q)); sessionStorage.clear() }) afterEach(() => wrappers.splice(0).forEach(w => w.unmount())) describe('inventory turnover page fixed snapshot behavior', () => { it('honors an explicit new route token in the same scope and reloads its overview', async () => { const w = page(); await flushPromises() expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBeUndefined() mocks.route.query = { endDate: '2026-10-12', warehouseCode: 'A', snapshotContext: 'token-T2' } await flushPromises() expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ warehouseCode: 'A', snapshotContext: 'token-T2', includeOverview: true }) w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-T2', includeOverview: false, current: 2 }) }) it('loads the first warehouse once and keeps charts/context across filtered pagination', async () => { const w = page(); await flushPromises() expect(mocks.turnover).toHaveBeenCalledTimes(1) expect(mocks.turnover.mock.calls[0]![0]).toMatchObject({ warehouseCode: 'A', groupBy: 'SKU', baseWindowDays: 30, includeOverview: true }) await change(w, { sku: 'SAME' }, true) expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', includeOverview: false, sku: 'SAME', current: 1 }) w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', current: 2, includeOverview: false }) expect(w.findAll('.turnover-cards article')).toHaveLength(4) }) it('rejects unauthorized deep links and does not query without warehouses or permission', async () => { mocks.route.query = { warehouseCode: 'NO' }; const w = page(); await flushPromises() expect(w.text()).toContain('指定仓库不可用'); expect(mocks.turnover).not.toHaveBeenCalled() mocks.route.query = {}; mocks.overview.mockResolvedValue({ warehouses: [] }); page(); await flushPromises(); expect(mocks.turnover).not.toHaveBeenCalled() mocks.allowed = false; mocks.overview.mockClear(); page(); await flushPromises(); expect(mocks.overview).not.toHaveBeenCalled() }) it('cancels old warehouse reads and never restores their late response', async () => { let finish!: (value: TurnoverResponse) => void mocks.turnover.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) const w = page(); await flushPromises(); const old = mocks.turnover.mock.calls[0]! await change(w, { warehouseCode: 'B' }) expect((old[1] as AbortSignal).aborted).toBe(true) finish(response(old[0])); await flushPromises() expect(mocks.turnover.mock.calls.at(-1)![0].warehouseCode).toBe('B') expect(w.text()).toContain('本仓概览') await change(w, { sku: 'X' }, true) expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBe('token-B') }) it('clears SKU sorting for owners and preserves original owner page on return', async () => { const w = page(); await flushPromises() await change(w, { sku: 'SAME', sortBy: 'COVERAGE_DESC' }, true) await change(w, { groupBy: 'OWNER' }) expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sku: undefined, sortBy: 'PHYSICAL_DESC', snapshotContext: 'token-A', includeOverview: true }) w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() await w.findAll('button').find(b => b.text() === 'OWNER-A')!.trigger('click'); await flushPromises() expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'SKU', customerCode: 'OWNER-A', snapshotContext: 'token-A' }) await w.findAll('button').find(b => b.text() === '返回货主汇总')!.trigger('click'); await flushPromises() expect(w.findComponent(table).props('page')).toBe(2) expect(w.findComponent(toolbar).props('modelValue').groupBy).toBe('OWNER') }) it('blocks invalid thresholds and preserves known data on same-scope refresh failure', async () => { const w = page(); await flushPromises() await change(w, { focus: 'COVERAGE_GT', coverageDaysThreshold: '' }, true) expect(mocks.turnover).toHaveBeenCalledTimes(1); expect(w.text()).toContain('请填写') mocks.turnover.mockRejectedValue(new Error('offline')) await change(w, { coverageDaysThreshold: 60 }, true) expect(w.findComponent(table).props('data')).toHaveLength(1) expect(w.text()).toContain('保留') }) it('passes exact SKU identity and fixed versions into trend and cancels a closed request', async () => { let finish!: (value: object) => void mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) const w = page(); await flushPromises() await w.findAll('button').find(b => b.text() === 'SAME')!.trigger('click'); await flushPromises() const [q, signal] = mocks.daily.mock.calls[0]! expect(q).toMatchObject({ customerCode: 'OWNER-A', sku: 'SAME', warehouseCode: 'A', salesRunId: '11', inventoryRunId: '22', queryAsOf: '2026-10-13T00:00:00Z', startDate: '2026-09-13', endDate: '2026-10-12' }) const dialog = w.findAllComponents({ name: 'AppDialog' }).find(d => d.props('title') === 'SKU每日出库趋势')! dialog.vm.$emit('update:modelValue', false); await flushPromises() expect((signal as AbortSignal).aborted).toBe(true) finish({}); await flushPromises() expect(w.findComponent({ name: 'SkuTrend' }).exists()).toBe(false) }) it('does not combine rows from a changed token with prior charts', async () => { const w = page(); await flushPromises() mocks.turnover.mockImplementationOnce(async (q: TurnoverQuery) => { const r = response(q); r.context.snapshotContext = 'different'; r.rows = []; return r }) w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() expect(w.text()).toContain('查询版本已变化') expect(w.findComponent(table).props('data')).toHaveLength(1) }) })