Files
admin-web/src/modules/self-operated/components/InventoryTurnoverView.test.ts
T

316 lines
28 KiB
TypeScript

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<string, string> }, allowed: true, ownerDirectory: vi.fn(), 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-start']?.(), slots['toolbar-actions']?.(), slots['before-results']?.(), ...(p.columns ?? []).map((column: { headerSlotName?: string; prop: string; label: string }) => column.headerSlotName ? slots[column.headerSlotName]?.({ column: { property: column.prop, label: column.label } }) : null), ...(p.data ?? []).flatMap((row: TurnoverRow) => (p.columns ?? []).map((column: { slotName?: string; prop: keyof TurnoverRow }) => column.slotName && slots[column.slotName] ? slots[column.slotName]?.({ row }) : String(row[column.prop] ?? '')))]) } })
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 volume = { value: 1.25, knownSubtotal: 1.25, missingDimensionQuantity: 0, missingDimensionSkuCount: 0, unknownQuantitySkuCount: 0, declaredDimensionQuantity: 0, reasonCodes: [] }
const volumes = [7, 15, 30].map(days => ({ days, startDate: '2026-09-13', endDateExclusive: '2026-10-13', volume }))
const row: TurnoverRow = { inventoryVolumeM3: volume, outboundVolumesM3: volumes, unitVolumeM3: 0.0125, dimensionSource: 'WAREHOUSE_MEASURED', customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, transitQuantity: 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: [], noOutboundInWindow: 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 ? { inventoryVolumeM3: volume, outboundVolumesM3: volumes, physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
}
const wrappers: ReturnType<typeof mount>[] = []
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, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('div', [slots.reference?.(), slots.default?.()]) } }) } } }); wrappers.push(w); return w }
async function change(w: ReturnType<typeof mount>, 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.ownerDirectory.mockResolvedValue([{ customerCode: "OWNER-A", customerName: "货主甲" }]); 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('defaults to volume ranking, switches locally and drills into the same owner snapshot', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
if (result.charts) {
result.charts.ownerVolumeTop = [{ customerCode: 'OWNER-A', customerName: '货主甲', inventoryVolumeM3: { ...volume, value: 1234.56789 } }]
result.charts.unknownVolumeOwnerCount = 2
}
return result
})
const w = page(); await flushPromises()
expect(w.get('[aria-label="图表指标"] button[aria-pressed="true"]').text()).toBe('货主库存立方数 Top10')
expect(w.text()).toContain('2 个货主因缺尺寸或库存数量未知,未参与排名')
const chart = w.findComponent({ name: 'AnalyticsChart' })
const option = chart.props('option') as { series: { data: number[]; label: { position: string; formatter: (p: {value: number}) => string } }[] }
expect(option.series[0]!.data).toEqual([1234.56789])
expect(option.series[0]!.label.position).toBe('right')
expect(option.series[0]!.label.formatter({ value: 1234.56789 })).toBe('1,234.57 m³')
const calls = mocks.turnover.mock.calls.length
await w.get('[aria-label="图表指标"] button:last-child').trigger('click')
expect(w.get('[aria-label="图表指标"] button[aria-pressed="true"]').text()).toBe('日均出库对比')
await w.get('[aria-label="图表指标"] button:first-child').trigger('click')
expect(mocks.turnover).toHaveBeenCalledTimes(calls)
w.findComponent({ name: 'AnalyticsChart' }).vm.$emit('select', { dataIndex: 0 }); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', groupBy: 'SKU', current: 1, snapshotContext: 'token-A' })
})
it('shows customer and SKU volume, missing dimensions and estimates in the selected window', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.rows[0]!.inventoryVolumeM3 = { ...volume, value: null, knownSubtotal: 0.125, missingDimensionQuantity: 12, missingDimensionSkuCount: 2 }
result.rows[0]!.outboundVolumesM3 = [{ days: q.baseWindowDays, startDate: '2026-10-10', endDateExclusive: '2026-10-13', volume: { ...volume, value: 2.5, declaredDimensionQuantity: 40 } }]
return result
})
const w = page(); await flushPromises()
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
expect(w.text()).toContain('已知小计 0.125 m³')
expect(w.text()).toContain('其中 40 件按商品尺寸估算')
expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(true)
await change(w, { groupBy: 'OWNER', rangePreset: 'LAST_3_DAYS' })
expect(w.findComponent(table).props('columns').find((c: {prop: string}) => c.prop === 'outboundVolumesM3').label).toContain('3日')
expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(false)
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
})
it('filters turnover by directory customer and keeps the code in pagination', async () => {
const w = page(); await flushPromises()
expect(w.findComponent(toolbar).props('fields').find((f: { key: string }) => f.key === 'customerCode').options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }])
await change(w, { customerCode: 'OWNER-A' })
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', current: 1, includeOverview: true })
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', current: 2, snapshotContext: 'token-A' })
})
it('sorts every numeric header on the server in both directions with the same snapshot', async () => {
const w = page(); await flushPromises()
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
const metrics = { inventoryVolumeM3: 'INVENTORY_VOLUME', outboundVolumesM3: 'OUTBOUND_VOLUME', unitVolumeM3: 'UNIT_VOLUME', physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' }
for (const [prop, metric] of Object.entries(metrics)) {
// Start on a later page and with another sort to prove that a header resets pagination.
await change(w, { sortBy: metric === 'PHYSICAL' ? 'AVAILABLE_DESC' : 'PHYSICAL_DESC' }, true)
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
const header = w.get(`button[data-column="${prop}"]`)
for (const direction of ['DESC', 'ASC']) {
await header.trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ sortBy: `${metric}_${direction}`, current: 1, snapshotContext: 'token-A', includeOverview: false })
expect(w.findComponent(toolbar).props('modelValue').sortBy).toBe(`${metric}_${direction}`)
expect(header.attributes('aria-label')).toContain(direction === 'ASC' ? '当前升序' : '当前降序')
}
}
await change(w, { rangePreset: 'LAST_7_DAYS' })
expect(w.get('button[data-column="averageDailyOutboundQuantity"]').text()).toContain('7日日均')
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ baseWindowDays: 7, sortBy: 'AVERAGE_DAILY_DESC' })
})
it('keeps the successful sort marker and page when a header query fails', async () => {
const w = page(); await flushPromises()
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
mocks.turnover.mockRejectedValueOnce(new Error('offline'))
await w.get('button[data-column="outbound15"]').trigger('click'); await flushPromises()
expect(w.findComponent(table).props('page')).toBe(2)
expect(w.get('button[data-column="physicalQuantity"]').attributes('aria-label')).toContain('当前降序')
expect(w.get('button[data-column="outbound15"]').attributes('aria-label')).not.toContain('当前')
expect(w.findComponent(table).props('data')).toHaveLength(1)
})
it('supports owner numeric sorting and restores the default header on reset', async () => {
const w = page(); await flushPromises()
await change(w, { groupBy: 'OWNER' })
expect(w.find('button[data-column="inventoryCoverageDays"]').exists()).toBe(false)
expect(w.find('button[data-column="unitVolumeM3"]').exists()).toBe(false)
for (const [prop, metric] of [['inventoryVolumeM3', 'INVENTORY_VOLUME'], ['outboundVolumesM3', 'OUTBOUND_VOLUME']]) {
await w.get(`button[data-column="${prop}"]`).trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sortBy: `${metric}_DESC` })
}
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sortBy: 'AVERAGE_DAILY_DESC' })
w.findComponent(toolbar).vm.$emit('reset'); await flushPromises()
expect(w.get('button[data-column="physicalQuantity"]').attributes('aria-label')).toContain('当前降序')
})
it('keeps published historical averages visible while explaining pending rebuild separately', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.context.analyticsContext.rebuildRequired = true
result.context.analyticsContext.salesPublishedAt = '2026-10-14T01:40:24Z'
result.charts!.averageDaily = row.windows.map(window => ({ ...window,
averageDailyOutboundQuantity: window.days === 7 ? 1234.567143 : window.days === 15 ? 2100 : null,
outboundQuantity: window.days === 30 ? null : window.outboundQuantity,
completeDayCount: Math.min(window.days, 18), unknownDayCount: window.days === 30 ? 12 : 0,
dataStatus: window.days === 30 ? 'PARTIAL' : 'COMPLETE',
}))
return result
})
const w = page(); await flushPromises()
await w.get('[aria-label="图表指标"] button:last-child').trigger('click')
const chart = w.findComponent({ name: 'AnalyticsChart' }).props('option') as { series: { data: (number | null)[]; label: { show: boolean; formatter: (params: { value: number | null }) => string } }[] }
expect(chart.series[0]!.label.show).toBe(true)
expect(chart.series[0]!.label.formatter({ value: chart.series[0]!.data[0]! })).toBe('1,234.57')
expect(chart.series[0]!.label.formatter({ value: chart.series[0]!.data[1]! })).toBe('2,100')
expect(chart.series[0]!.data[2]).toBeNull()
expect(w.find('.turnover-window-summary').exists()).toBe(false)
expect(w.text()).toContain('完整 18 / 30 日')
expect(w.text()).toContain('已发布销量版本计算')
expect(w.text()).toContain('新采集数据待重算')
expect(w.findAllComponents({ name: 'AnalyticsChart' }).length).toBeGreaterThan(0)
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q))
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect(w.text()).not.toContain('新采集数据待重算')
})
it('queries fast and slow tabs with a visible default threshold, preserving custom values', async () => {
const w = page(); await flushPromises()
const select = async (label: string) => { await w.findAll('.turnover-focus button').find(b => b.text() === label)!.trigger('click'); await flushPromises() }
await select('消化较慢')
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ focus: 'COVERAGE_GT', coverageDaysThreshold: 30, sortBy: 'COVERAGE_DESC', current: 1 })
expect(w.find('.turnover-focus button[aria-pressed="true"]').text()).toBe('消化较慢')
await w.get('input[aria-label="可售天数阈值(天)"]').setValue('60')
await select('消化较快')
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ focus: 'COVERAGE_LE', coverageDaysThreshold: 60, sortBy: 'COVERAGE_ASC' })
const calls = mocks.turnover.mock.calls.length
await w.get('input[aria-label="可售天数阈值(天)"]').setValue('0')
await select('消化较慢')
expect(mocks.turnover).toHaveBeenCalledTimes(calls)
expect(w.get('.turnover-filter-feedback').text()).toContain('正整数')
expect(w.find('.turnover-focus button[aria-pressed="true"]').text()).toBe('消化较快')
await select('全部')
expect(mocks.turnover.mock.calls.at(-1)![0].coverageDaysThreshold).toBeUndefined()
expect(w.find('.turnover-threshold').exists()).toBe(false)
expect(w.findComponent(toolbar).props('fields').some((field: { key: string }) => field.key === 'coverageDaysThreshold')).toBe(false)
})
it('queries the remaining focus tabs and uses owner-compatible sorting', async () => {
const w = page(); await flushPromises()
await change(w, { groupBy: 'OWNER' })
for (const [label, focus, sortBy] of [['所选时段无出库', 'NO_OUTBOUND_IN_WINDOW', 'NO_OUTBOUND_PHYSICAL_DESC'], ['待评估', 'UNASSESSABLE', 'PHYSICAL_DESC'], ['消化较慢', 'COVERAGE_GT', 'PHYSICAL_DESC'], ['消化较快', 'COVERAGE_LE', 'PHYSICAL_DESC'], ['全部', 'ALL', 'PHYSICAL_DESC']]) {
await w.findAll('.turnover-focus button').find(b => b.text() === label)!.trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', focus, sortBy, current: 1 })
}
})
it('shows stale inventory and failed collection without presenting unknown totals as zero', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY', observedAt: '2026-10-14T00:48:00Z', currentTask: { taskNo: 'LXCOL-TEST-STOCK', taskStatus: 'DEAD', latestAttempt: { status: 'FAILED', errorCode: 'SELFOP_STOCK_DUPLICATE' } } }] })
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.context.analyticsContext.inventoryStatus = 'STALE'
result.context.inventoryObservedUntil = '2026-10-12T16:15:00Z'
result.summary!.physicalQuantity = { value: null, knownSubtotal: null, unknownSkuCount: 5402, reasonCodes: ['INVENTORY_UNKNOWN_OR_STALE'] }
result.summary!.noOutboundSkuCount = { value: null, knownSubtotal: 0, unknownSkuCount: 5402, reasonCodes: ['SALES_OR_STOCK_UNASSESSABLE'] }
return result
})
const w = page(); await flushPromises()
expect(w.text()).toContain('当前库存已过期')
expect(w.text()).toContain('库存分页出现重复记录')
expect(w.text()).toContain('LXCOL-TEST-STOCK')
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong').map(n => n.text())).toEqual(['—', '100', '—', '100'])
expect(w.get('article[data-metric="no-outbound"] small').text()).toBe('已知小计 0 · 5402 SKU待核验')
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY', currentTask: { taskNo: 'LXCOL-TEST-STOCK', taskStatus: 'SUCCEEDED' } }] })
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => { const result = response(q); result.context.analyticsContext.inventoryStatus = 'VALID'; return result })
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect(mocks.overview).toHaveBeenCalledTimes(2)
expect(w.text()).not.toContain('当前库存已过期'); expect(w.text()).not.toContain('库存分页出现重复记录')
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong')[0]!.text()).toBe('100')
})
it('does not show another warehouse failure and separates runtime refresh failure from report data', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY' }, { warehouseCode: 'B', dataFamily: 'INVENTORY', currentTask: { taskNo: 'LXCOL-OTHER', taskStatus: 'DEAD', latestAttempt: { status: 'FAILED', errorCode: 'SELFOP_STOCK_DUPLICATE' } } }] })
const w = page(); await flushPromises()
expect(w.text()).not.toContain('LXCOL-OTHER')
mocks.overview.mockRejectedValue(new Error('unavailable'))
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect(w.text()).toContain('采集状态更新失败')
expect(w.text()).not.toContain('库存周转读取失败')
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong')[0]!.text()).toBe('100')
})
it('renders quantity and window cells through the shared table slot contract', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.rows[0]!.attentionReasons = ['UNASSESSABLE']
return result
})
const w = page(); await flushPromises()
expect(w.text()).not.toContain('[object Object]')
expect(w.text()).not.toContain('UNASSESSABLE')
expect(w.text()).toContain('数据不足/待观察')
expect(w.text()).toContain('300')
expect(w.text()).toContain('暂无可排名的库存数据')
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong').map(n => n.text())).toEqual(['100', '100', '100', '100'])
expect(w.findAllComponents({ name: 'AppDialog' }).every(d => d.attributes('append-to-body') !== undefined)).toBe(true)
})
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(6)
})
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')
expect(w.findComponent(toolbar).props('modelValue')).toMatchObject({ rangePreset: 'CUSTOM', customRange: ['2026-09-13', '2026-10-12'] })
w.findComponent(table).vm.$emit('update:page', 1); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ endDate: '2026-10-12', baseWindowDays: 30, snapshotContext: 'token-A' })
})
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)
})
})