This commit is contained in:
2026-10-14 11:02:56 +08:00
parent 3f728cd0b2
commit e0f54edcc9
24 changed files with 352 additions and 105 deletions
@@ -32,6 +32,27 @@ beforeEach(() => {
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
describe('warehouse operating dashboard', () => {
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('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' }
@@ -151,7 +172,7 @@ describe('warehouse operating dashboard', () => {
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, averageDailyOutboundQuantity30d: { ...dashboardFixture.kpis.averageDailyOutboundQuantity30d, value: null, knownSubtotal: null, dataStatus: 'UNKNOWN', reasonCodes: ['SALES_UNAVAILABLE'] }, physicalQuantity: { ...dashboardFixture.kpis.physicalQuantity, value: '128', dataStatus: 'COMPLETE' } }, salesTrend: { ...dashboardFixture.salesTrend, points: [] } })
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')
@@ -167,12 +188,15 @@ describe('warehouse operating dashboard', () => {
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 only trend range while pinning versions, then explicitly refreshes the whole snapshot', async () => {
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 })
@@ -214,7 +238,7 @@ describe('warehouse operating dashboard', () => {
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-09-13', endDate: '2026-10-12', salesRunId: '11' })
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()
@@ -256,7 +280,7 @@ describe('warehouse operating dashboard', () => {
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-09-13', endDate: '2026-10-12' })
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 () => {
@@ -58,17 +58,17 @@ const contextExpired = computed(() => Boolean(context.value && expired(context.v
const stockExpired = computed(() => Boolean(context.value && inventoryExpired(context.value, now.value)))
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 },
{ 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 }] : []),
])
const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: boolean }[] = [
{ key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity30d', label: '近30日日均出库' },
{ key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity', label: '所选时段日均出库' },
{ key: 'physicalQuantity', label: '实物库存', inventory: true }, { key: 'availableQuantity', label: '正品可售', inventory: true },
{ key: 'physicalSkuCount', label: '有库存SKU', inventory: true }, { key: 'noOutboundSkuCount30d', label: '近30日无出库SKU', inventory: true },
{ key: 'physicalSkuCount', label: '有库存SKU', inventory: true }, { key: 'noOutboundSkuCount', label: '所选时段无出库SKU', inventory: true },
]
const metricIcons = [ArrowUpRight, Activity, Boxes, Package, Layers, Clock3]
const metricColors = ['#5575ed', '#3aa9ba', '#8871ce', '#35a987', '#c6923e', '#d67b91']
const rankingNames = [{ key: 'hotSkus', label: '所选时段热销Top10', unit: '件', all: '查看Top100' }, { key: 'longCoverageSkus', label: '预计消化时间最长Top10', unit: '天', all: '查看全部' }, { key: 'noOutboundSkus', label: '近30日无出库库存Top10', unit: '件', all: '查看全部' }] as const
const rankingNames = [{ key: 'hotSkus', label: '所选时段热销Top10', unit: '件', all: '查看Top100' }, { key: 'longCoverageSkus', label: '预计消化时间最长Top10', unit: '天', all: '查看全部' }, { key: 'noOutboundSkus', label: '所选时段无出库库存Top10', unit: '件', all: '查看全部' }] as const
const bucketRows = computed<BarRow[]>(() => buckets.map(bucket => {
const item = data.value?.coverageDistribution.buckets.find(row => row.bucketCode === bucket.code)
return { key: bucket.code, label: bucket.label, value: item?.skuCount ?? null, detail: `已知实物小计 ${quantityText(item?.knownPhysicalQuantity)} 件;实物未知 ${quantityText(item?.unknownPhysicalSkuCount)} SKU` }
@@ -98,6 +98,13 @@ async function load(refresh = false) {
const params: DashboardQuery = { sourceAccount, warehouseCode: warehouseCode.value, rangePreset: range, ...contextParams(pinned), ...(range === 'CUSTOM' ? { startDate: start, endDate: end } : {}) }
const result = await analyticsApi.dashboard(params, request.signal)
if (!request.current()) return
if (!result?.context || metricNames.some(({ key }) => {
const metric = result?.kpis?.[key]
return metric == null || typeof metric !== 'object' || !('value' in metric)
})) {
error.value = `经营总览指标返回不完整,请更新服务后重新查询${data.value ? ';展示上次查询' : ''}`
return
}
if (pinned && !sameContext(pinned, result.context)) { error.value = '查询版本不一致,请整体刷新经营总览'; return }
data.value = result; context.value = result.context; appliedRange = { rangePreset: range, startDate: range === 'CUSTOM' ? start : undefined, endDate: range === 'CUSTOM' ? end : undefined, scroll: 0 }; newData.value = false
if (restoreScroll !== null) { await nextTick(); window.scrollTo({ top: restoreScroll }); restoreScroll = null }
@@ -135,7 +142,7 @@ function allRanking(key: typeof rankingNames[number]['key']) {
drill('sales', { analysis: 'HOT', rangePreset: 'CUSTOM', startDate: data.value.hotSkus.windowStartDate, endDate: previousDate(data.value.hotSkus.windowEndDateExclusive) })
}
else if (key === 'longCoverageSkus') drill('inventory', { coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' })
else drill('inventory', { attention: 'NO_OUTBOUND_30D', sortBy: 'PHYSICAL_DESC' })
else drill('inventory', { attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'PHYSICAL_DESC' })
}
async function showTrend(item: DashboardItem, ranking: DashboardRanking) {
if (!context.value || expired(context.value)) { error.value = '原图查询已过期,请整体刷新经营总览'; return }
@@ -181,7 +188,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<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>时间筛选同时影响区间出库、日趋势与热销榜;热销按所选时段已采集量排名,未采齐会明确提示。库存消化与无出库评估仍需近30个完整日。</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>
@@ -197,7 +204,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<template v-if="data">
<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 === 'averageDailyOutboundQuantity30d' ? '件/日' : 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>
<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>
<div class="dashboard-main-charts">
<section class="dashboard-chart">
@@ -210,7 +217,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<template #actions>
<ChartDetailsDialog inline button-label="分组说明" title="分组说明与待核验原因" width="760px" :reset-on="data"><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></ChartDetailsDialog>
</template>
<template #intro><p>有实物库存或库存待核验 SKU;{{ data.context.fixedWindowStartDate }} 至 {{ data.context.fixedWindowEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p></template>
<template #intro><p>有实物库存或库存待核验 SKU;{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p></template>
<template #empty><p>无可用库存分布资料;销售候选不作为库存总量。</p></template>
</DashboardBars>
</section>
@@ -228,7 +235,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<p>{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。</p>
<p>{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}</p>
<p v-if="ranking.key === 'hotSkus' && data.hotSkus.dataStatus !== 'COMPLETE'">按所选时段已采集量排名,数据尚未采齐,排名可能随采集变化;未采集日期不计为零出库。</p>
<p v-else-if="ranking.key !== 'hotSkus' && data[ranking.key].dataStatus !== 'COMPLETE'">本榜仍需完整近30日销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。</p>
<p v-else-if="ranking.key !== 'hotSkus' && data[ranking.key].dataStatus !== 'COMPLETE'">本榜仍需所选区间完整销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。</p>
</template>
<template v-if="ranking.key === 'hotSkus'" #details="{ select }"><HotSkuDetails :ranking="data.hotSkus" :disabled="contextExpired" @select="index => select(rankingRows(data!.hotSkus)[index]!)" /></template>
</DashboardBars>
@@ -1,10 +1,10 @@
import type { Dashboard, DashboardItem, DashboardKpi, DashboardRanking } from '../types'
const kpi: DashboardKpi = { value: '8', knownSubtotal: '8', unit: 'PCS', dataStatus: 'COMPLETE', unknownSkuCount: '0', reasonCodes: [], outboundQuantity: '8', observedQuantity: '8' }
export const dashboardItem: DashboardItem = { customerCode: 'OWNER-A', sku: 'SKU-X', productName: '测试商品', value: '300', unit: 'PCS', windowOutboundQuantity: '300', skuOutboundOrderCount: '3', physicalQuantity: '3000', availableQuantity: '3000', frozenQuantity: '0', defectivePhysicalQuantity: '0', averageDailyOutboundQuantity: '10', inventoryCoverageDays: '300' }
const ranking: DashboardRanking = { windowStartDate: '2026-09-13', windowEndDateExclusive: '2026-10-13', calculationWindowDays: 30, eligibleCount: '200', unassessableCount: '1', items: [dashboardItem, { ...dashboardItem, customerCode: 'OWNER-B', value: '30' }], dataStatus: 'COMPLETE', reasonCodes: [] }
const ranking: DashboardRanking = { windowStartDate: '2026-10-10', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 4, eligibleCount: '200', unassessableCount: '1', items: [dashboardItem, { ...dashboardItem, customerCode: 'OWNER-B', value: '30' }], dataStatus: 'COMPLETE', reasonCodes: [] }
export const dashboardFixture: Dashboard = {
context: { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13', inventoryDrilldownExpiresAt: '2099-10-13T00:20:00Z', inventoryFreshUntil: '2099-10-13T00:20:00Z', rangeStartDate: '2026-10-10', rangeEndDateExclusive: '2026-10-14', fixedWindowStartDate: '2026-09-13', fixedWindowEndDateExclusive: '2026-10-13', fixedWindowDays: 30 },
kpis: { intervalOutboundQuantity: { ...kpi, value: null, outboundQuantity: null, dataStatus: 'PARTIAL' }, averageDailyOutboundQuantity30d: kpi, physicalQuantity: { ...kpi, value: null, knownSubtotal: '128', dataStatus: 'PARTIAL', unknownSkuCount: '1' }, availableQuantity: kpi, physicalSkuCount: kpi, noOutboundSkuCount30d: kpi },
context: { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13', inventoryDrilldownExpiresAt: '2099-10-13T00:20:00Z', inventoryFreshUntil: '2099-10-13T00:20:00Z', rangeStartDate: '2026-10-10', rangeEndDateExclusive: '2026-10-14' },
kpis: { intervalOutboundQuantity: { ...kpi, value: null, outboundQuantity: null, dataStatus: 'PARTIAL' }, averageDailyOutboundQuantity: kpi, physicalQuantity: { ...kpi, value: null, knownSubtotal: '128', dataStatus: 'PARTIAL', unknownSkuCount: '1' }, availableQuantity: kpi, physicalSkuCount: kpi, noOutboundSkuCount: kpi },
salesTrend: { summary: { sourceAccount: 'lingxing-primary', customerCode: '', warehouseCode: 'WH-A', sku: '', productName: null, quantityUnit: 'PCS', outboundQuantity: null, observedQuantity: '8', dataStatus: 'PARTIAL', reasonCodes: [], completeDayCount: 2, partialDayCount: 1, unknownDayCount: 1 }, points: [
{ businessDate: '2026-10-10', outboundQuantity: '2', observedQuantity: '2', dataStatus: 'COMPLETE', reasonCodes: [], quantityUnit: 'PCS' },
{ businessDate: '2026-10-11', outboundQuantity: null, observedQuantity: null, dataStatus: 'UNKNOWN', reasonCodes: ['SALES_NOT_COVERED'], quantityUnit: 'PCS' },
@@ -134,12 +134,12 @@ describe('self-operated actual workbench request state', () => {
expect(request).not.toHaveProperty('queryAsOf')
})
it('defaults to Beijing yesterday and excludes rolling 30-day predicates for a historical cutoff', async () => {
it('defaults to Beijing yesterday and retains selected-window predicates for a historical cutoff', async () => {
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_30D', sortBy: 'OUTBOUND_30D_ASC' })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 30, salesWindowEndDate: '2026-10-10', attention: 'ALL', sortBy: 'AVAILABLE_DESC' })
await setFilters(wrapper, { salesWindowEndDate: '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 () => {
const ten = { ...context, anchorDate: '2026-10-13', rangeStartDate: '2026-10-03', rangeEndDateExclusive: '2026-10-13', calculationWindowDays: 10 }
@@ -157,10 +157,10 @@ describe('self-operated actual workbench request state', () => {
await setFilters(wrapper, { sortBy: 'PHYSICAL_DESC' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, salesWindowEndDate: '2026-10-12', sortBy: 'PHYSICAL_DESC', queryAsOf: ten.queryAsOf, current: 1 })
})
it('clears incompatible 30-day predicates and never calculates missing inventory coverage locally', async () => {
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_30D', sortBy: 'OUTBOUND_30D_ASC', salesWindowDays: 10 })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, attention: 'ALL', sortBy: 'AVAILABLE_DESC' })
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' })
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()
@@ -201,7 +201,7 @@ describe('self-operated actual workbench request state', () => {
})
it.each([
{ coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' },
{ attention: 'NO_OUTBOUND_30D', sortBy: 'PHYSICAL_DESC' },
{ attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'PHYSICAL_DESC' },
])('sends the complete overview ranking candidate predicate before server pagination: %j', conditions => {
const pinned = { ...context, queryExpiresAt: '2099-10-13T00:30:00Z', inventoryDrilldownExpiresAt: '2099-10-13T00:20:00Z' }
mocks.routeQuery = Object.fromEntries(Object.entries({ warehouseCode: 'WH-A', from: 'overview', context: JSON.stringify(pinned), overviewReturn: JSON.stringify({ rangePreset: 'TODAY', scroll: 0 }), ...conditions }).filter(([, value]) => value !== undefined)) as Record<string, string>
@@ -217,6 +217,9 @@ describe('self-operated actual workbench request state', () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-X', coverageBucket: 'DAYS_30_60', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', drilldownFrom: 'DASHBOARD', salesRunId: '11', queryAsOf: context.queryAsOf })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: pinned.rangeStartDate, endDate: '2026-10-11' })
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 findButton(wrapper, '清除来源条件').trigger('click'); await flushPromises()
const cleared = mocks.api.inventory.mock.calls.at(-1)?.[0]