This commit is contained in:
2026-10-19 17:56:31 +08:00
parent 5a46198bf0
commit 5c28cd7c79
84 changed files with 3356 additions and 3630 deletions
@@ -32,6 +32,24 @@ beforeEach(() => {
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
describe('warehouse operating dashboard', () => {
it.each([
['1896.0666666666667', '1,896.07'],
[1896.0666666666667, '1,896.07'],
['1200', '1,200'],
['12.5', '12.5'],
['0', '0'],
[null, '—'],
])('formats the daily average %s without changing the returned metric', async (value, expected) => {
const response = structuredClone(dashboardFixture)
response.kpis.averageDailyOutboundQuantity.value = value
mocks.dashboard.mockResolvedValue(response)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
const card = wrapper.findAll('.dashboard-kpis section').find(node => node.find('h3').text() === '所选时段日均出库')!
expect(card.find('strong').text()).toBe(expected)
expect(card.text()).toContain('件/日')
expect(response.kpis.averageDailyOutboundQuantity.value).toBe(value)
})
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)) {
@@ -246,6 +264,8 @@ describe('warehouse operating dashboard', () => {
expect(wrapper.text()).toContain('区间已采集出库量')
expect(wrapper.text()).toContain('已知小计 128')
expect(wrapper.text()).toContain('另 1 SKU 待核验')
expect(wrapper.find('.dashboard-kpis').text()).not.toContain('另 0 SKU 待核验')
expect(wrapper.findAll('.dashboard-kpis small').every(node => node.text() !== '—')).toBe(true)
expect(wrapper.text()).not.toContain('lingxing-primary')
})
it('changes the shared date range while pinning versions, then explicitly refreshes the whole snapshot', async () => {
@@ -70,6 +70,12 @@ const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: bo
{ key: 'physicalQuantity', label: '实物库存', inventory: true }, { key: 'availableQuantity', label: '正品可售', inventory: true },
{ key: 'physicalSkuCount', label: '有库存SKU', inventory: true }, { key: 'noOutboundSkuCount', label: '所选时段无出库SKU', inventory: true },
]
// 日均值只在展示时舍入,保留原始接口精度和未知状态。
function averageDailyText(value: string | number | null | undefined) {
if (value == null || String(value).trim() === '') return '—'
const number = Number(value)
return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 2 }) : '—'
}
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: '所选时段无出库库存Top10', unit: '件', all: '查看全部' }] as const
@@ -212,7 +218,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<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>
<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>{{ metric.key === 'averageDailyOutboundQuantity' ? averageDailyText(data.kpis[metric.key].value) : 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="Number(data.kpis[metric.key].unknownSkuCount) > 0">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small v-if="data.kpis[metric.key].reasonCodes?.length">{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
</div>
<div class="dashboard-main-charts">
<section class="dashboard-chart">