feat(self-operated): unify completed-day ranges and analytics sorting

This commit is contained in:
2026-10-14 12:39:16 +08:00
parent 6b30e35d07
commit e7411eb03f
17 changed files with 426 additions and 135 deletions
@@ -70,6 +70,23 @@ describe('warehouse operating dashboard', () => {
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' }
@@ -83,10 +100,27 @@ describe('warehouse operating dashboard', () => {
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'))).toEqual([{ key: 'dateRange', type: 'daterange', label: '日期范围', width: 300 }])
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()
@@ -103,7 +137,7 @@ describe('warehouse operating dashboard', () => {
await choose(wrapper, { dateRange: null })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard).toHaveBeenCalledOnce()
await choose(wrapper, { rangePreset: 'TODAY' })
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 () => {
@@ -116,12 +150,12 @@ describe('warehouse operating dashboard', () => {
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]).not.toHaveProperty('startDate')
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('endDate')
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: 'THIS_MONTH', scroll: 760 }) }
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('经营总览返回参数无效')
@@ -148,9 +182,9 @@ describe('warehouse operating dashboard', () => {
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: 'LAST_15_DAYS' })
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: 'TODAY' })
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')
@@ -165,14 +199,14 @@ describe('warehouse operating dashboard', () => {
})
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: 'THIS_MONTH', scroll: 760 }) }
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: 'THIS_MONTH', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'THIS_MONTH' })
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: 'TODAY', scroll: 0 }) }
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()
@@ -201,7 +235,7 @@ describe('warehouse operating dashboard', () => {
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: 'LAST_15_DAYS' })
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)
@@ -215,8 +249,8 @@ describe('warehouse operating dashboard', () => {
})
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: 'TODAY' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'TODAY', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
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')
})
@@ -230,7 +264,7 @@ describe('warehouse operating dashboard', () => {
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: 'LAST_15_DAYS' })
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)
@@ -248,7 +282,7 @@ describe('warehouse operating dashboard', () => {
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: 'LAST_15_DAYS' })
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
@@ -13,8 +13,9 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { analyticsApi } from '../api/analytics'
import type { Dashboard, DashboardItem, DashboardRanking, DailyResponse, DashboardQuery, Overview, QueryContext, RangePreset } from '../types'
import { beijingTime, contextParams, label, quantityText, reasonsText, RequestFence, RuntimePoller, sameContext, validateCustomRange } from '../state'
import { buckets, expired, inventoryExpired, parseContext, parseOverviewReturn, previousDate, rangeOptions, type OverviewReturn } from '../dashboardNavigation'
import { beijingTime, contextParams, label, quantityText, reasonsText, RequestFence, RuntimePoller, sameContext } from '../state'
import { buckets, expired, inventoryExpired, parseContext, parseOverviewReturn, previousDate, type OverviewReturn } from '../dashboardNavigation'
import { beijingDate, overviewRange, overviewRangeOptions } from '../overviewRange'
import HotSkuDetails from '../components/HotSkuDetails.vue'
import InventoryRankingDiagnostics from '../components/InventoryRankingDiagnostics.vue'
import ChartDetailsDialog from '../components/ChartDetailsDialog.vue'
@@ -56,10 +57,13 @@ try {
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
const contextExpired = computed(() => Boolean(context.value && expired(context.value, now.value)))
const stockExpired = computed(() => Boolean(context.value && inventoryExpired(context.value, now.value)))
const rangeAnchor = computed(() => context.value?.anchorDate ?? beijingDate(now.value))
const includesToday = computed(() => Boolean(data.value?.context.rangeStartDate && data.value.context.rangeEndDateExclusive
&& data.value.context.rangeStartDate <= data.value.context.anchorDate && data.value.context.rangeEndDateExclusive > data.value.context.anchorDate))
const fields = computed<QueryField[]>(() => [
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 220, placeholder: '切换仓库', props: { clearable: false }, options: [...new Map((runtime.value?.warehouses ?? []).map(item => [item.warehouseCode, { value: item.warehouseCode, label: item.warehouseName || item.warehouseCode }])).values()] },
{ key: 'rangePreset', type: 'select', label: '统计时间范围', width: 180, props: { clearable: false }, options: rangeOptions },
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'dateRange', type: 'daterange' as const, label: '日期范围', width: 300 }] : []),
{ key: 'rangePreset', type: 'select', label: '统计时间范围', width: 220, props: { clearable: false }, options: overviewRangeOptions.map(option => ({ ...option, disabled: option.value !== 'CUSTOM' && (() => { try { overviewRange(option.value, rangeAnchor.value); return false } catch { return true } })() })) },
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'dateRange', type: 'daterange' as const, label: '日期范围', width: 300, props: { disabledDate: (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` >= rangeAnchor.value } }] : []),
])
const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: boolean }[] = [
{ key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity', label: '所选时段日均出库' },
@@ -85,17 +89,19 @@ async function load(refresh = false) {
if (!permitted.value || !warehouseCode.value || routeError.value) return
if (!refresh && contextExpired.value) { error.value = '原图查询已过期,请整体刷新经营总览'; return }
const range = String(filters.value.rangePreset) as RangePreset
if (!rangeOptions.some(option => option.value === range)) { error.value = '请选择有效的出库趋势范围'; return }
if (!overviewRangeOptions.some(option => option.value === range)) { error.value = '请选择有效的统计时间范围'; return }
const selected = Array.isArray(filters.value.dateRange) ? filters.value.dateRange : []
const start = typeof selected[0] === 'string' ? selected[0] : ''
const end = typeof selected[1] === 'string' ? selected[1] : ''
if (range === 'CUSTOM') { const problem = validateCustomRange(start, end, context.value?.anchorDate); if (problem) { rangeValidationError.value = problem; error.value = problem; errorDetails.value = null; return } }
const pinned = refresh ? null : context.value
let dates: ReturnType<typeof overviewRange>
try { dates = overviewRange(range, pinned?.anchorDate ?? beijingDate(now.value), start, end) }
catch (cause) { const problem = (cause as Error).message; rangeValidationError.value = problem; error.value = problem; errorDetails.value = null; return }
const request = dataFence.begin()
trendOpen.value = false
loading.value = true; error.value = ''; rangeValidationError.value = ''; errorDetails.value = null
try {
const params: DashboardQuery = { sourceAccount, warehouseCode: warehouseCode.value, rangePreset: range, ...contextParams(pinned), ...(range === 'CUSTOM' ? { startDate: start, endDate: end } : {}) }
const params: DashboardQuery = { sourceAccount, warehouseCode: warehouseCode.value, ...dates, ...contextParams(pinned) }
const result = await analyticsApi.dashboard(params, request.signal)
if (!request.current()) return
if (!result?.context || metricNames.some(({ key }) => {
@@ -185,11 +191,11 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<template v-else>
<DataTable appearance="integrated" :data="[]" :columns="[]" :results-visible="false" column-settings-mode="hide">
<template #toolbar-start><div class="dashboard-title"><h2>经营总览</h2><span class="dashboard-snapshot">{{ runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode }}<i />{{ loading ? '正在更新' : data ? '固定版本 · 北京时间' : error ? '查询未完成' : '正在准备数据' }}</span></div></template>
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: appliedRange.rangePreset, ...(appliedRange.startDate ? { startDate: appliedRange.startDate, endDate: appliedRange.endDate! } : {}) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: data!.context.rangeStartDate!, endDate: previousDate(data!.context.rangeEndDateExclusive!) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
<template #toolbar-end>
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间">
<template #reference><el-button circle aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
<div class="dashboard-data-notes admin-density"><p>时间筛选统一影响出库量、日均、趋势、库存消化分布和三个榜单。日均按所选区间天数计算;未采齐或含今日时,热销展示已采集量,库存消化和无出库评估保持待核验。库存使用本次查询的有效库存快照,不代表所选日期的历史库存。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p><p v-else>查询仓库数据后显示对应采集时间。</p></div>
<div class="dashboard-data-notes admin-density"><p>时间筛选统一影响出库量、日均、趋势、库存消化分布和三个榜单。日期最晚截至昨日,日均按所选完整自然日数计算;未采齐时,热销展示已采集量,库存消化和无出库评估保持待核验。库存使用本次查询的有效库存快照,不代表所选日期的历史库存。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p><p v-else>查询仓库数据后显示对应采集时间。</p></div>
</el-popover>
</template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" preference-key="self-operated-overview-query" ariaLabel="经营总览筛选" @search="load()" @reset="reset" /></template>
@@ -203,7 +209,8 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<p v-else-if="stockExpired && data" role="status">原图库存已过期或不可用,库存指标为历史/待刷新资料,严格库存下钻已停用;出库趋势与热销仍可查看。</p>
<div v-if="loading && !data" class="dashboard-placeholder" role="status" aria-busy="true"><span>正在读取六项指标、日趋势、库存分布和三个榜单…</span><div class="dashboard-skeleton-kpis" aria-hidden="true"><i v-for="index in 6" :key="index" /></div><div class="dashboard-skeleton-charts" aria-hidden="true"><i /><i /></div></div>
<template v-if="data">
<p v-if="includesToday" role="status">所选时段包含今日({{ data.context.anchorDate }}),尚不满足完整自然日要求,库存消化天数与两个库存榜单暂不能完成评估。可选择截至昨日的自定义时段;仍需满足销量覆盖、库存及观察期条件。</p>
<p v-if="data.context.rebuildRequired || data.context.salesStatus === 'REBUILD_REQUIRED'" role="status">销量有变动,待重新统计发布。出库趋势展示已发布版本的历史覆盖结果;库存评估等待新版销量发布。</p>
<div class="dashboard-kpis" aria-label="本仓经营指标">
<section v-for="(metric, index) in metricNames" :key="metric.key" :style="{ '--metric-color': metricColors[index], '--reveal-delay': `${index * 45}ms` }"><div class="dashboard-metric-heading"><h3>{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3><component :is="metricIcons[index]" :size="18" aria-hidden="true" /></div><div class="dashboard-metric-value"><strong>{{ quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span></div><p class="dashboard-metric-status">{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p><p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'">已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}</p><p v-if="data.kpis[metric.key].unknownSkuCount != null">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small>{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
</div>
+101 -24
View File
@@ -23,7 +23,7 @@ const queryStub = defineComponent({ props: ['modelValue', 'fields'], emits: ['up
h('button', { 'data-test': 'switch-warehouse', onClick: () => { emit('update:modelValue', { ...props.modelValue, warehouseCode: 'WH-B' }); emit('search') } }, '切换仓库'),
h('button', { onClick: () => emit('search') }, '查询'), h('button', { onClick: () => emit('reset') }, '重置'),
]) } })
const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), slots.filters?.(), slots.notices?.(), !(props.data ?? []).length ? props.emptyText : null, ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.windowOutboundQuantity?.({ row }), slots.inventoryCoverageDays?.({ row }), slots.status?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row }), slots.dailySamples?.({ row }), slots.configuration?.({ row })]))]) } })
const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText', 'page'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), slots.filters?.(), slots.notices?.(), ...(props.columns ?? []).map((column: { prop?: string; headerSlotName?: string; label?: string }) => column.headerSlotName ? slots[column.headerSlotName]?.({ column: { property: column.prop, label: column.label } }) : null), !(props.data ?? []).length ? props.emptyText : null, ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.windowOutboundQuantity?.({ row }), h('span', { class: 'daily-cell' }, slots.averageDailyOutboundQuantity?.({ row })), h('span', { class: 'reason-cell' }, slots.reason?.({ row })), slots.inventoryCoverageDays?.({ row }), slots.status?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row }), slots.dailySamples?.({ row }), slots.configuration?.({ row })]))]) } })
const button = defineComponent({ props: ['disabled', 'nativeType'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, type: props.nativeType || 'button', onClick: () => emit('click') }, slots.default?.()) } })
const input = defineComponent({ props: ['modelValue', 'id'], emits: ['update:modelValue'], setup(props, { emit }) { return () => h('textarea', { id: props.id, value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value) }) } })
const loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } })
@@ -50,14 +50,89 @@ beforeEach(() => {
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() })
describe('self-operated actual workbench request state', () => {
it('explains unknown today values and queries the preceding 30 complete days explicitly', async () => {
it('sorts inventory numeric headers in both directions using the pinned full-result query', async () => {
const wrapper=mount(AnalyticsView,{...options,props:{mode:'inventory'}});wrappers.push(wrapper);await flushPromises()
const columns={physicalQuantity:'PHYSICAL',availableQuantity:'AVAILABLE',frozenQuantity:'FROZEN',defectivePhysicalQuantity:'DEFECTIVE',transitQuantity:'TRANSIT',windowOutboundQuantity:'OUTBOUND',averageDailyOutboundQuantity:'AVERAGE_DAILY',inventoryCoverageDays:'COVERAGE_DAYS'}
for(const [prop,metricName] of Object.entries(columns)) {
wrapper.findComponent(tableStub).vm.$emit('update:page',2);await flushPromises()
const header=wrapper.get(`button[data-column="${prop}"]`)
const directions=['DESC','ASC']
for(const direction of directions) {
const previousCalls=mocks.api.inventory.mock.calls.length
await header.trigger('click');await flushPromises()
expect(mocks.api.inventory.mock.calls.length).toBe(previousCalls+1)
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({sortBy:`${metricName}_${direction}`,current:1,queryAsOf:context.queryAsOf,salesRunId:context.salesRunId})
expect(header.attributes('aria-label')).toContain(direction==='ASC'?'当前升序':'当前降序')
}
}
})
it('preserves the successful header and page on sorting failure', async () => {
const wrapper=mount(AnalyticsView,{...options,props:{mode:'inventory'}});wrappers.push(wrapper);await flushPromises()
wrapper.findComponent(tableStub).vm.$emit('update:page',2);await flushPromises()
mocks.api.inventory.mockRejectedValueOnce(new Error('offline'))
await wrapper.get('button[data-column="transitQuantity"]').trigger('click');await flushPromises()
expect(wrapper.findComponent(tableStub).props('page')).toBe(2)
expect(wrapper.findComponent(tableStub).props('data')).toHaveLength(1)
expect(wrapper.get('button[data-column="availableQuantity"]').attributes('aria-label')).toContain('当前降序')
expect(wrapper.get('button[data-column="transitQuantity"]').attributes('aria-label')).not.toContain('当前')
})
it('shows known sales subtotals with specific gap reasons while keeping unknown metrics empty', async () => {
mocks.api.inventory.mockResolvedValue({context,records:[{...metric,observedWindowOutboundQuantity:92,observationEligible:false,status:'COVERAGE_INSUFFICIENT',quantityStatuses:{windowOutboundQuantity:'COVERAGE_INSUFFICIENT'}}],total:1})
const wrapper=mount(AnalyticsView,{...options,props:{mode:'inventory'}});wrappers.push(wrapper);await flushPromises()
expect(wrapper.text()).toContain('已采集 92')
expect(wrapper.findAll('.inventory-missing').map(e=>e.text())).toContain('销量窗口未采齐')
expect(wrapper.findComponent(tableStub).props('data')[0].inventoryCoverageDays).toBeNull()
mocks.api.inventory.mockResolvedValue({context,records:[{...metric,observedWindowOutboundQuantity:0,quantityStatuses:{windowOutboundQuantity:'COVERAGE_INSUFFICIENT'}}],total:1})
await findButton(wrapper,'查询').trigger('click');await flushPromises()
expect(wrapper.text()).toContain('已采集 0');expect(wrapper.text()).toContain('销量窗口未采齐')
})
it('shows complete window metrics and a separate observation warning for recently discovered SKUs', async () => {
const row = { ...metric, windowOutboundQuantity: 92, averageDailyOutboundQuantity: '6.133333333333', inventoryCoverageDays: 48.9, observationEligible: false, status: 'VALID', reasonCodes: ['OBSERVATION_INSUFFICIENT'], quantityStatuses: { windowOutboundQuantity: 'VALID' } }
mocks.api.inventory.mockResolvedValue({ context: { ...context, calculationWindowDays: 15 }, records: [row], total: 1 })
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
expect(wrapper.get('.daily-cell').text()).toBe('6.13')
expect(wrapper.get('.reason-cell').text()).toBe('商品历史观察不足(仅提示,不影响完整窗口统计)')
expect(wrapper.findAll('.inventory-missing')).toHaveLength(0)
expect(wrapper.findComponent(tableStub).props('data')[0]).toMatchObject({ windowOutboundQuantity: 92, averageDailyOutboundQuantity: '6.133333333333', inventoryCoverageDays: 48.9 })
})
it('updates to new published data even when the draft sort was changed', async () => {
vi.useFakeTimers();vi.setSystemTime(new Date('2026-10-12T01:05:00Z'))
const wrapper=mount(AnalyticsView,{...options,props:{mode:'inventory'}});wrappers.push(wrapper);await flushPromises()
mocks.api.overview.mockResolvedValue({...overview,warehouses:[{warehouseCode:'WH-A',lastPublishedAt:'2026-10-12T01:03:00Z'}]})
await vi.advanceTimersByTimeAsync(60000);await flushPromises()
await setFilters(wrapper,{sortBy:'FROZEN_DESC'})
const nextContext={...context,salesRunId:'12',queryAsOf:'2026-10-12T01:06:00Z'}
mocks.api.inventory.mockResolvedValue({context:nextContext,records:[{...metric,windowOutboundQuantity:300,averageDailyOutboundQuantity:10,inventoryCoverageDays:10}],total:1})
await findButton(wrapper,'更新数据').trigger('click');await flushPromises()
expect(mocks.api.inventory.mock.calls.at(-1)?.[0].salesRunId).toBeUndefined()
expect(wrapper.findComponent(tableStub).props('data')[0].windowOutboundQuantity).toBe(300)
expect(wrapper.text()).not.toContain('有新数据可用')
})
it('defaults to the preceding 30 complete days and preserves unknown quantities', async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-12T01:00:00Z'))
mocks.api.summary.mockResolvedValue({ context, records: [{ ...summary, observedQuantity: null, dataStatus: 'UNKNOWN' }], total: 1 })
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
expect(wrapper.text()).toContain('暂无可用数据')
expect(wrapper.text()).not.toContain('(已采集,未完整)')
await findButton(wrapper, '查看近30个完整日').trigger('click'); await flushPromises()
expect(wrapper.findComponent(queryStub).props('modelValue').rangePreset).toBe('LAST_30_DAYS')
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-09-12', endDate: '2026-10-11' })
})
it.each([[3, '2026-09-28'], [7, '2026-09-24'], [15, '2026-09-16'], [30, '2026-09-01']])('queries %s complete days across month boundaries', async (days, startDate) => {
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-30T16:30:00Z'))
mocks.api.summary.mockResolvedValue({ context: { ...context, anchorDate: '2026-10-01' }, records: [summary], total: 1 })
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
await setFilters(wrapper, { rangePreset: `LAST_${days}_DAYS` })
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate, endDate: '2026-09-30' })
})
it('sorts sales through the API, returns to page one, and restores sorting after failure', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
await wrapper.get('[data-column="salesQuantity"]').trigger('click'); await flushPromises()
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ current: 1, salesSort: 'QUANTITY_ASC', salesRunId: context.salesRunId, queryAsOf: context.queryAsOf })
mocks.api.summary.mockRejectedValueOnce(new Error('排序失败'))
await wrapper.get('[data-column="salesQuantity"]').trigger('click'); await flushPromises()
expect(wrapper.get('[data-column="salesQuantity"]').attributes('aria-label')).toContain('当前升序')
})
it.each(['sales', 'inventory'] as const)('automatically loads the first available warehouse on %s entry', async mode => {
mocks.routeQuery = {}
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-B' }, { warehouseCode: 'WH-A' }] })
@@ -127,18 +202,18 @@ describe('self-operated actual workbench request state', () => {
expect(signal.aborted).toBe(true)
resolveOld({ context, records: [summary], total: 1 }); await flushPromises()
expect(wrapper.findComponent(tableStub).props('data')).toEqual([])
await setFilters(wrapper, { rangePreset: 'THIS_MONTH' })
expect(mocks.api.summary.mock.calls.at(-1)?.[0].rangePreset).toBe('THIS_MONTH')
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).not.toHaveProperty('startDate')
await setFilters(wrapper, { rangePreset: 'LAST_7_DAYS' })
expect(mocks.api.summary.mock.calls.at(-1)?.[0].rangePreset).toBe('CUSTOM')
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toHaveProperty('startDate')
expect(wrapper.text()).not.toContain('请选择有效的起止日期')
})
it('validates an explicit fresh query against Beijing today instead of yesterday’s pinned context', async () => {
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-13T01:00:00Z'))
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-10-13', '2026-10-13'] })
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-10-12', '2026-10-12'] })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
const request = mocks.api.summary.mock.calls.at(-1)?.[0]
expect(request).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-13', endDate: '2026-10-13' })
expect(request).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-12', endDate: '2026-10-12' })
expect(request).not.toHaveProperty('queryAsOf')
})
@@ -146,15 +221,18 @@ describe('self-operated actual workbench request state', () => {
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-12T17:00:00Z'))
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
expect(mocks.api.inventory.mock.calls[0]?.[0]).toMatchObject({ salesWindowDays: 30, salesWindowEndDate: '2026-10-12' })
await setFilters(wrapper, { salesWindowEndDate: '2026-10-10', attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
expect(wrapper.get('[data-testid="inventory-window-summary"]').text()).toContain('不含今天')
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-09-11', '2026-10-10'], attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 30, salesWindowEndDate: '2026-10-10', attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
})
it('selects a complete ten-day window, preserves it with pinned pagination, and uses its dates for daily trend', async () => {
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-13T01:00:00Z'))
const ten = { ...context, anchorDate: '2026-10-13', rangeStartDate: '2026-10-03', rangeEndDateExclusive: '2026-10-13', calculationWindowDays: 10 }
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
mocks.api.inventory.mockResolvedValue({ context: ten, records: [{ ...metric, windowOutboundQuantity: 50, averageDailyOutboundQuantity: 5, inventoryCoverageDays: 20 }], total: 45 })
await setFilters(wrapper, { salesWindowDays: 10, salesWindowEndDate: '2026-10-12' })
vi.setSystemTime(new Date('2026-10-13T01:00:00Z'))
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-10-03', '2026-10-12'] })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, salesWindowEndDate: '2026-10-12', current: 1 })
expect(wrapper.findComponent(tableStub).props('columns').find((c: { key: string }) => c.key === 'windowOutboundQuantity').label).toBe('10天窗口出库')
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
@@ -167,15 +245,15 @@ describe('self-operated actual workbench request state', () => {
})
it('keeps selected-window predicates and never calculates missing inventory coverage locally', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
await setFilters(wrapper, { attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC', salesWindowDays: 10 })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
await setFilters(wrapper, { attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC', rangePreset: 'LAST_7_DAYS' })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 7, attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
expect(wrapper.findComponent(tableStub).props('data')[0].inventoryCoverageDays).toBeNull()
mocks.api.inventory.mockResolvedValue({ context, records: [{ ...metric, windowOutboundQuantity: 0, status: 'NO_OUTBOUND_IN_WINDOW' }], total: 1 })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('所选窗口无出库')
expect(wrapper.findComponent(tableStub).props('data')[0].inventoryCoverageDays).toBeNull()
const calls = mocks.api.inventory.mock.calls.length
await setFilters(wrapper, { salesWindowEndDate: '2099-10-12' })
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2099-10-01', '2099-10-12'] })
expect(mocks.api.inventory).toHaveBeenCalledTimes(calls)
})
@@ -229,6 +307,8 @@ describe('self-operated actual workbench request state', () => {
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('salesWindowDays')
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('salesWindowEndDate')
expect(wrapper.text()).toContain('来自经营总览')
await wrapper.get('button[data-column="frozenQuantity"]').trigger('click'); await flushPromises()
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ sortBy: 'FROZEN_DESC', customerCode: 'OWNER-B', sku: 'SKU-X', coverageBucket: 'DAYS_30_60', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', drilldownFrom: 'DASHBOARD', salesRunId: '11', queryAsOf: context.queryAsOf, rangePreset: 'CUSTOM', startDate: pinned.rangeStartDate, endDate: '2026-10-11' })
await findButton(wrapper, '清除来源条件').trigger('click'); await flushPromises()
const cleared = mocks.api.inventory.mock.calls.at(-1)?.[0]
expect(cleared).toMatchObject({ salesRunId: '11', inventoryRunId: '0', queryAsOf: context.queryAsOf, attention: 'ALL' })
@@ -314,7 +394,7 @@ describe('self-operated actual workbench request state', () => {
expect(mocks.api.owners).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'OwnerPicker' }).exists()).toBe(false)
const fields = wrapper.findComponent(queryStub).props('fields')
expect(fields.map((field: { key: string }) => field.key)).toEqual(['warehouseCode', 'sku', 'salesWindowDays', 'salesWindowEndDate', 'attention', 'threshold', 'sortBy'])
expect(fields.map((field: { key: string }) => field.key)).toEqual(['warehouseCode', 'sku', 'rangePreset', 'attention', 'threshold', 'sortBy'])
expect(fields[0].placeholder).toBe('请选择仓库(必选)')
expect(wrapper.findComponent(tableStub).props('columns').slice(0, 3).map((column: { prop?: string; key?: string }) => column.prop || column.key)).toEqual(['sku', 'productName', 'owner'])
await setFilters(wrapper, { attention: 'FROZEN_STOCK' })
@@ -385,11 +465,11 @@ describe('self-operated actual workbench request state', () => {
it('pins versions and date across range and ranking switches, ranking omits chart parameters', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
await flushPromises()
await setFilters(wrapper, { rangePreset: 'THIS_MONTH' })
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'THIS_MONTH', queryAsOf: context.queryAsOf, salesRunId: '11', inventoryRunId: '0' })
await setFilters(wrapper, { rangePreset: 'LAST_7_DAYS' })
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', queryAsOf: context.queryAsOf, salesRunId: '11', inventoryRunId: '0' })
await setFilters(wrapper, { analysis: 'hot' })
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf, windowDays: 30 })
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('rangePreset')
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf, rangePreset: 'CUSTOM' })
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('windowDays')
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode', '')
})
it('late first warehouse response cannot overwrite second warehouse data', async () => {
@@ -403,17 +483,14 @@ describe('self-operated actual workbench request state', () => {
expect(wrapper.text()).not.toContain('SKU-A')
expect(mocks.api.summary.mock.calls[0]?.[1].aborted).toBe(true)
})
it('loads rankings with the fixed context when an unfinished CUSTOM date selection exists', async () => {
it('blocks both rankings when custom dates are incomplete', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
await flushPromises()
await setFilters(wrapper, { rangePreset: 'CUSTOM' })
expect(mocks.api.summary).toHaveBeenCalledTimes(1)
await setFilters(wrapper, { analysis: 'hot' })
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf, windowDays: 30 })
expect(mocks.api.hot).not.toHaveBeenCalled()
await setFilters(wrapper, { analysis: 'slow' })
expect(mocks.api.slow.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf })
expect(mocks.api.slow.mock.calls.at(-1)?.[0]).not.toHaveProperty('rangePreset')
expect(wrapper.find('.analytics-rules').text()).toContain('实际计算区间:2026-09-12 至 2026-10-12(截止日不含)')
expect(mocks.api.slow).not.toHaveBeenCalled()
})
it('failed runtime polling preserves prior available data and leaves fixed context untouched', async () => {
mocks.api.overview.mockRejectedValue(new Error('offline'))