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

108 lines
10 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, 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<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 } } }); 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.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)
})
})