Files
admin-web/src/modules/self-operated/pages/OverviewView.test.ts
T

359 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OverviewView from './OverviewView.vue'
import DashboardBars from '../components/DashboardBars.vue'
import DashboardTrend from '../components/DashboardTrend.vue'
import DataTable from '@/shared/components/DataTable.vue'
import { dashboardFixture } from './dashboard.fixture'
const mocks = vi.hoisted(() => ({ dashboard: vi.fn(), overview: vi.fn(), daily: vi.fn(), push: vi.fn(), query: {} as Record<string, string>, permitted: true }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }), useRouter: () => ({ push: mocks.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.permitted }) }))
const passthrough = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const query = defineComponent({ props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], setup(_, { emit }) { return () => h('button', { onClick: () => emit('search') }, '查询') } })
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
const dialog = defineComponent({ props: ['modelValue', 'width', 'title', 'appendToBody'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', [h('button', { onClick: () => undefined }, '趋势弹框'), slots.default?.()]) : null } })
const error = defineComponent({ props: ['message'], setup(props) { return () => h('p', props.message) } })
const popover = defineComponent({ props: ['trigger', 'placement', 'title', 'width'], setup(_, { slots }) { return () => h('div', slots.reference?.()) } })
const options = { global: { directives: { loading: () => undefined }, stubs: { AdminPage: passthrough, QueryToolbar: query, AppDialog: dialog, LoadErrorState: error, ApiErrorMeta: true, SkuTrend: true, 'el-button': button, 'el-popover': popover, 'el-table-column': true, 'el-table': true, 'el-option': true, 'el-select': true } } }
const wrappers: ReturnType<typeof mount>[] = []
function page() { const wrapper = mount(OverviewView, options); wrappers.push(wrapper); return wrapper }
function findButton(wrapper: ReturnType<typeof mount>, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
async function choose(wrapper: ReturnType<typeof mount>, values: Record<string, string | string[] | null>) { const toolbar = wrapper.findComponent(query); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
beforeEach(() => {
vi.clearAllMocks(); mocks.query = {}; mocks.permitted = true
vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
mocks.overview.mockResolvedValue({ warehouses: [], observedAt: '2026-10-13T00:00:00Z' })
mocks.dashboard.mockResolvedValue(dashboardFixture)
mocks.daily.mockResolvedValue({ context: dashboardFixture.context, summary: { ...dashboardFixture.salesTrend.summary, customerCode: 'OWNER-A', sku: 'SKU-X' }, points: dashboardFixture.salesTrend.points })
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
describe('warehouse operating dashboard', () => {
it.each(['PARTIAL', 'UNKNOWN'])('renders omitted nullable KPI fields for %s instead of rejecting the dashboard', async status => {
const response = structuredClone(dashboardFixture)
for (const metric of Object.values(response.kpis)) {
metric.value = null; metric.knownSubtotal = null; metric.unknownSkuCount = null
metric.outboundQuantity = null; metric.observedQuantity = null; metric.dataStatus = status
}
response.kpis.intervalOutboundQuantity.observedQuantity = '8'
response.kpis.physicalQuantity.knownSubtotal = '128'
// Match a server response serialized with NON_NULL, not an explicit-null fixture.
mocks.dashboard.mockResolvedValue(JSON.parse(JSON.stringify(response, (_, value) => value === null ? undefined : value)))
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).not.toContain('指标结构不完整')
expect(wrapper.findAll('.dashboard-kpis strong').map(node => node.text())).toEqual(['8', '—', '—', '—', '—', '—'])
expect(wrapper.text()).toContain('已知小计 128')
expect(wrapper.findAllComponents(DashboardBars)).toHaveLength(4)
})
it.each(['intervalOutboundQuantity', 'averageDailyOutboundQuantity', 'physicalQuantity', 'availableQuantity', 'physicalSkuCount', 'noOutboundSkuCount'] as const)('keeps the page usable when the response omits %s and recovers after refresh', async key => {
const invalid = structuredClone(dashboardFixture)
delete (invalid.kpis as Partial<typeof invalid.kpis>)[key]
mocks.dashboard.mockResolvedValue(invalid)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('经营总览返回的指标结构不完整')
expect(wrapper.find('.dashboard-kpis').exists()).toBe(false)
expect(wrapper.findComponent(query).exists()).toBe(true)
mocks.dashboard.mockResolvedValue(dashboardFixture)
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
expect(wrapper.text()).not.toContain('经营总览返回的指标结构不完整')
})
it('preserves the last valid result when a refreshed response has a null KPI', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, kpis: { ...dashboardFixture.kpis, averageDailyOutboundQuantity: null } })
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('经营总览返回的指标结构不完整,请重试或联系管理员;展示上次查询')
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
})
it('explains today and pending publication using the returned query context', async () => {
const data = structuredClone(dashboardFixture)
data.context.rebuildRequired = true
data.context.salesStatus = 'REBUILD_REQUIRED'
mocks.dashboard.mockResolvedValue(data)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('所选时段包含今日(2026-10-13)')
expect(wrapper.text()).toContain('库存评估等待新版销量发布')
const refreshed = structuredClone(data)
refreshed.context.rangeEndDateExclusive = refreshed.context.anchorDate
refreshed.context.rebuildRequired = false
refreshed.context.salesStatus = 'VALID'
mocks.dashboard.mockResolvedValue(refreshed)
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(wrapper.text()).not.toContain('所选时段包含今日(')
expect(wrapper.text()).not.toContain('库存评估等待新版销量发布')
})
it('offers coverage evidence for both inventory rankings even when they are empty', async () => {
const data = structuredClone(dashboardFixture)
data.longCoverageSkus = { ...data.longCoverageSkus, items: [], eligibleCount: '0', unassessableCount: '2711', dataStatus: 'PARTIAL' }
data.noOutboundSkus = { ...data.noOutboundSkus, items: [], eligibleCount: '0', unassessableCount: '2741', dataStatus: 'PARTIAL' }
mocks.dashboard.mockResolvedValue(data)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '待评估原因')).toHaveLength(2)
await findButton(wrapper, '待评估原因').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('全仓库存分布的待核验原因')
expect(wrapper.text()).toContain('待评估 2711 SKU')
expect(wrapper.text()).toContain('待评估 2741 SKU')
expect(mocks.dashboard).toHaveBeenCalledOnce()
})
it('removes today, blocks custom today before querying and drills into the displayed dates', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
const toolbar = wrapper.findComponent(query)
const options = toolbar.props('fields').find((field: { key: string }) => field.key === 'rangePreset').options
expect(options.some((option: { value: string }) => option.value === 'TODAY')).toBe(false)
expect(options.find((option: { value: string }) => option.value === 'LAST_15_DAYS').label).toBe('最近15天')
const calls = mocks.dashboard.mock.calls.length
await choose(wrapper, { rangePreset: 'CUSTOM', dateRange: ['2026-10-01', '2026-10-13'] })
const disabled = toolbar.props('fields').find((field: { key: string }) => field.key === 'dateRange').props.disabledDate
expect(disabled(new Date(2026, 9, 13))).toBe(true)
expect(disabled(new Date(2026, 9, 12))).toBe(false)
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard).toHaveBeenCalledTimes(calls)
expect(wrapper.text()).toContain('结束日期最晚为昨日')
await findButton(wrapper, '查看销量').trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ rangePreset: 'CUSTOM', startDate: dashboardFixture.context.rangeStartDate, endDate: '2026-10-13' })
})
it('uses one date range field, blocks incomplete ranges and clears obsolete validation errors', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A', rangePreset: 'CUSTOM' })
const toolbar = wrapper.findComponent(query)
expect(toolbar.props('fields').filter((field: { type: string }) => field.type.includes('date'))).toMatchObject([{ key: 'dateRange', type: 'daterange', label: '日期范围', width: 300 }])
expect(mocks.dashboard).not.toHaveBeenCalled()
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard).not.toHaveBeenCalled()
await choose(wrapper, { dateRange: ['2026-10-10'] })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard).not.toHaveBeenCalled()
expect(wrapper.findComponent(error).exists()).toBe(true)
await choose(wrapper, { dateRange: ['2026-10-10', '2026-10-10'] })
expect(wrapper.findComponent(error).exists()).toBe(false)
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard).toHaveBeenCalledOnce()
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
expect(mocks.dashboard.mock.calls[0]?.[0]).not.toHaveProperty('dateRange')
await choose(wrapper, { dateRange: null })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard).toHaveBeenCalledOnce()
await choose(wrapper, { rangePreset: 'LAST_3_DAYS' })
expect(wrapper.findComponent(error).exists()).toBe(false)
})
it('restores a custom range and keeps the applied dates when an unsubmitted range is edited', async () => {
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10', scroll: 123 }) }
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent(query).props('modelValue').dateRange).toEqual(['2026-10-10', '2026-10-10'])
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ startDate: '2026-10-10', endDate: '2026-10-10', salesRunId: '11' })
await choose(wrapper, { dateRange: ['2026-10-11', '2026-10-12'] })
await findButton(wrapper, '查看Top100').trigger('click')
expect(JSON.parse(mocks.push.mock.calls.at(-1)?.[0].query.overviewReturn)).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
wrapper.findComponent(query).vm.$emit('reset'); await flushPromises()
expect(wrapper.findComponent(query).props('modelValue').dateRange).toEqual([])
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toHaveProperty('startDate')
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toHaveProperty('endDate')
})
it('does not replace an invalid warehouse-less return with the default warehouse', async () => {
mocks.query = { context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'LAST_7_DAYS', scroll: 760 }) }
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: 'A仓' }], observedAt: '2026-10-13T00:00:00Z' })
const wrapper = page(); await flushPromises()
expect(wrapper.text()).toContain('经营总览返回参数无效')
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('')
expect(mocks.dashboard).not.toHaveBeenCalled()
await choose(wrapper, { warehouseCode: 'WH-A' })
expect(mocks.dashboard).toHaveBeenCalledOnce()
expect(mocks.dashboard.mock.calls[0]?.[0]).not.toHaveProperty('queryAsOf')
})
it('uses the common title/actions/filter surface without an empty result table', async () => {
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent(DataTable).props()).toMatchObject({ appearance: 'integrated', resultsVisible: false, columnSettingsMode: 'hide' })
expect(wrapper.find('.data-table__toolbar-start h2').text()).toBe('经营总览')
expect(wrapper.find('.data-table__toolbar-actions').text()).toContain('整体刷新')
expect(wrapper.find('.data-table__filters').findComponent(query).exists()).toBe(true)
expect(wrapper.findComponent(query).attributes('preference-key')).toBe('self-operated-overview-query')
expect(wrapper.find('el-table-stub').exists()).toBe(false)
expect(wrapper.findComponent(popover).props()).toMatchObject({ trigger: 'click', placement: 'bottom-end', title: '统计口径与采集时间', width: 460 })
expect(wrapper.find('.data-table__toolbar-actions button[aria-label="统计口径与采集时间"]').exists()).toBe(true)
expect(wrapper.find('details.dashboard-data-notes').exists()).toBe(false)
})
it('automatically loads the first warehouse and keeps it selected on reset', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: '仓A' }, { warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] })
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('WH-A')
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'CUSTOM' })
expect(wrapper.find('.dashboard-data-notes').exists()).toBe(false)
await choose(wrapper, { rangePreset: 'LAST_3_DAYS' })
wrapper.findComponent(query).vm.$emit('reset'); await flushPromises()
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('queryAsOf')
})
it('preserves a warehouse supplied by a direct link instead of replacing it with the default', async () => {
mocks.query = { warehouseCode: 'WH-B' }
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] })
const wrapper = page(); await flushPromises()
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
expect(mocks.dashboard.mock.calls[0]?.[0].warehouseCode).toBe('WH-B')
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('WH-B')
})
it('restores the saved warehouse, range, fixed context and scroll when returning from a drilldown', async () => {
const scroll = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'LAST_7_DAYS', scroll: 760 }) }
const wrapper = page(); await flushPromises()
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'CUSTOM', startDate: '2026-10-06', endDate: '2026-10-12', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_7_DAYS' })
expect(scroll).toHaveBeenCalledWith({ top: 760 })
})
it('does not silently refresh an expired return context', async () => {
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify({ ...dashboardFixture.context, queryExpiresAt: '2026-10-13T00:01:00Z' }), overviewReturn: JSON.stringify({ rangePreset: 'LAST_3_DAYS', scroll: 0 }) }
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }); vi.setSystemTime(new Date('2026-10-13T00:02:00Z'))
const wrapper = page(); await flushPromises()
expect(mocks.dashboard).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('原图查询已过期')
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(mocks.dashboard.mock.calls[0]?.[0]).not.toHaveProperty('queryAsOf')
})
it('keeps sales available when inventory is unavailable, with no fabricated distribution', async () => {
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, inventoryDrilldownExpiresAt: null }, coverageDistribution: { ...dashboardFixture.coverageDistribution, candidateSkuCount: null, buckets: [], dataStatus: 'UNKNOWN', reasonCodes: ['INVENTORY_UNAVAILABLE'] } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('无可用库存分布资料')
expect(wrapper.text()).toContain('库存资料不可用')
expect(wrapper.findComponent(DashboardTrend).exists()).toBe(true)
expect(findButton(wrapper, '查看Top100').attributes('disabled')).toBeUndefined()
})
it('keeps inventory present without published sales and never derives a daily average from partial sales', async () => {
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, salesRunId: null, salesScopeHash: null }, kpis: { ...dashboardFixture.kpis, averageDailyOutboundQuantity: { ...dashboardFixture.kpis.averageDailyOutboundQuantity, value: null, knownSubtotal: null, dataStatus: 'UNKNOWN', reasonCodes: ['SALES_UNAVAILABLE'] }, physicalQuantity: { ...dashboardFixture.kpis.physicalQuantity, value: '128', dataStatus: 'COMPLETE' } }, salesTrend: { ...dashboardFixture.salesTrend, points: [] } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAll('.dashboard-kpis strong')[1]!.text()).toBe('—')
expect(wrapper.findAll('.dashboard-kpis strong')[2]!.text()).toBe('128')
expect(wrapper.text()).toContain('销量资料不可用')
expect(wrapper.findAllComponents(DashboardBars)[0]!.props('rows')).toHaveLength(7)
})
it('does not request business data without a selected warehouse and renders six metrics/five charts from one snapshot', async () => {
const wrapper = page(); await flushPromises()
expect(mocks.dashboard).not.toHaveBeenCalled()
await choose(wrapper, { warehouseCode: 'WH-A' })
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'CUSTOM' })
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
expect(wrapper.findAllComponents(DashboardBars)).toHaveLength(4)
expect(wrapper.findAllComponents(DashboardTrend)).toHaveLength(1)
expect(wrapper.text()).toContain('所选时段无出库库存Top10')
expect(wrapper.text()).not.toContain('近30日无出库')
expect(wrapper.findAllComponents(DashboardBars).slice(1).every(chart => chart.text().includes('2026-10-10 至 2026-10-14'))).toBe(true)
expect(wrapper.text()).toContain('区间已采集出库量')
expect(wrapper.text()).toContain('已知小计 128')
expect(wrapper.text()).toContain('另 1 SKU 待核验')
expect(wrapper.text()).not.toContain('lingxing-primary')
})
it('changes the shared date range while pinning versions, then explicitly refreshes the whole snapshot', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
await choose(wrapper, { rangePreset: 'LAST_3_DAYS' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-12', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('salesRunId')
})
it('automatically loads the new warehouse, shows loading and ignores the old late response', async () => {
let finish!: (value: unknown) => void
let finishNew!: (value: unknown) => void
mocks.dashboard.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
mocks.dashboard.mockImplementationOnce(() => new Promise(resolve => { finishNew = resolve }))
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
await choose(wrapper, { warehouseCode: 'WH-B' })
expect(wrapper.find('.dashboard-kpis').exists()).toBe(false)
expect(wrapper.find('.dashboard-placeholder').exists()).toBe(true)
expect(mocks.dashboard).toHaveBeenCalledTimes(2)
expect(mocks.dashboard.mock.calls[1]?.[0]).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'CUSTOM' })
expect(mocks.dashboard.mock.calls[1]?.[0]).not.toHaveProperty('queryAsOf')
finishNew(dashboardFixture); await flushPromises()
expect(wrapper.find('.dashboard-kpis').exists()).toBe(true)
finish({ ...dashboardFixture, kpis: { ...dashboardFixture.kpis, physicalQuantity: { ...dashboardFixture.kpis.physicalQuantity, value: '999999' } } }); await flushPromises()
expect(wrapper.text()).not.toContain('999999')
expect(mocks.dashboard.mock.calls[0]?.[1].aborted).toBe(true)
})
it('retains the switched warehouse when the query toolbar clears its fields on reset', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] })
const wrapper = page(); await flushPromises()
await choose(wrapper, { warehouseCode: 'WH-B' })
const toolbar = wrapper.findComponent(query)
toolbar.props('modelValue').warehouseCode = ''
toolbar.props('modelValue').rangePreset = ''
toolbar.vm.$emit('reset'); await flushPromises()
expect(toolbar.props('modelValue')).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'LAST_15_DAYS' })
expect(mocks.dashboard).toHaveBeenCalledTimes(3)
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'CUSTOM' })
})
it('preserves same-SKU owner identity in the 1120 dialog and cancels closed requests', async () => {
let finish!: (value: unknown) => void
mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
wrapper.findAllComponents(DashboardBars)[1]!.vm.$emit('select', '1'); await flushPromises()
expect(mocks.daily.mock.calls[0]?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-X', rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-13', salesRunId: '11' })
expect(wrapper.findAllComponents(dialog).find(item => item.props('title') === 'SKU每日出库趋势')!.props('width')).toBe('1120px')
for (const modal of wrapper.findAllComponents(dialog)) expect(modal.props('appendToBody')).not.toBeUndefined()
wrapper.findAllComponents(dialog).find(item => item.props('title') === 'SKU每日出库趋势')!.vm.$emit('update:modelValue', false); await flushPromises()
finish({ context: dashboardFixture.context, summary: { ...dashboardFixture.salesTrend.summary, sku: 'LATE-SKU' }, points: [] }); await flushPromises()
expect(mocks.daily.mock.calls[0]?.[1].aborted).toBe(true)
expect(wrapper.text()).not.toContain('LATE-SKU')
})
it('keeps an uncovered selected period empty without changing its dates or claiming zero sales', async () => {
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, hotSkus: { ...dashboardFixture.hotSkus, windowStartDate: '2026-10-13', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 1, eligibleCount: '0', items: [], dataStatus: 'UNKNOWN', reasonCodes: ['SALES_WINDOW_INCOMPLETE'] } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAllComponents(DashboardBars)[1]!.props('rows')).toEqual([])
expect(wrapper.text()).toContain('未采集日期不计为零出库')
await findButton(wrapper, '查看Top100').trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ startDate: '2026-10-13', endDate: '2026-10-13', rangePreset: 'CUSTOM' })
})
it('shows partial hot sales for the selected dates and preserves them in Top100 and SKU trends', async () => {
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, hotSkus: { ...dashboardFixture.hotSkus, windowStartDate: '2026-10-01', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 13, dataStatus: 'PARTIAL', reasonCodes: ['SALES_WINDOW_INCOMPLETE'], items: [{ ...dashboardFixture.hotSkus.items[0], windowOutboundQuantity: '37', value: '37', averageDailyOutboundQuantity: null, inventoryCoverageDays: null }] } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('所选时段热销Top10')
expect(wrapper.text()).toContain('按所选时段已采集量排名')
const hot = wrapper.findAllComponents(DashboardBars)[1]!
expect(hot.props('rows')[0]).toMatchObject({ value: '37' })
expect(hot.props('rows')[0].detail).toContain('13天窗口已采集出库 37 件')
expect(hot.props('rows')[0].detail).not.toContain('近30日出库')
await findButton(wrapper, '查看Top100').trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-10-01', endDate: '2026-10-13' })
hot.vm.$emit('select', '0'); await flushPromises()
expect(mocks.daily.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-01', endDate: '2026-10-13', customerCode: 'OWNER-A', sku: 'SKU-X' })
})
it('emits exact bucket/day/ranking navigation and keeps unknown day navigation disabled', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
wrapper.findAllComponents(DashboardBars)[0]!.vm.$emit('select', 'DAYS_GT_90'); await flushPromises()
expect(mocks.push.mock.calls.at(-1)?.[0]).toMatchObject({ path: '/self-operated/inventory', query: { coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', from: 'overview' } })
const chart = wrapper.findComponent(DashboardTrend)
await chart.find('.chart-disclosure button').trigger('click')
await chart.findAll('tbody button')[1]!.trigger('click')
expect(mocks.push).toHaveBeenCalledTimes(1)
expect(chart.text()).toContain('销量未覆盖')
await chart.findAll('tbody button')[0]!.trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'SKU', rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
await findButton(wrapper, '查看Top100').trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-13' })
expect(JSON.parse(mocks.push.mock.calls.at(-1)?.[0].query.context)).toEqual(dashboardFixture.context)
})
it('expires inventory before sales while retaining stock-independent SKU trends', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }); vi.setSystemTime(new Date('2026-10-13T00:00:00Z'))
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, inventoryDrilldownExpiresAt: '2026-10-13T00:00:01Z' } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
await vi.advanceTimersByTimeAsync(2000)
expect(wrapper.text()).toContain('原图库存已过期或不可用')
expect(wrapper.findAllComponents(DashboardBars)[0]!.props('disabled')).toBe(true)
expect(findButton(wrapper, '查看Top100').attributes('disabled')).toBeUndefined()
wrapper.findAllComponents(DashboardBars)[2]!.vm.$emit('select', '0'); await flushPromises()
expect(mocks.daily).toHaveBeenCalledTimes(1)
wrapper.findAllComponents(DashboardBars)[0]!.vm.$emit('select', 'DAYS_GT_90'); await flushPromises()
expect(mocks.push).not.toHaveBeenCalled()
})
it('retains prior figures on failed refresh and does not render unknown quantities as zero', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
mocks.dashboard.mockRejectedValue(new Error('offline'))
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('展示上次查询')
expect(wrapper.find('.dashboard-kpis').exists()).toBe(true)
expect(wrapper.findAll('.dashboard-kpis strong')[2]!.text()).toBe('—')
})
})