fix(self-operated): render dashboard KPIs with omitted unknown values
This commit is contained in:
@@ -32,24 +32,41 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
|
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
|
||||||
describe('warehouse operating dashboard', () => {
|
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 => {
|
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)
|
const invalid = structuredClone(dashboardFixture)
|
||||||
delete (invalid.kpis as Partial<typeof invalid.kpis>)[key]
|
delete (invalid.kpis as Partial<typeof invalid.kpis>)[key]
|
||||||
mocks.dashboard.mockResolvedValue(invalid)
|
mocks.dashboard.mockResolvedValue(invalid)
|
||||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||||
expect(wrapper.text()).toContain('经营总览指标返回不完整')
|
expect(wrapper.text()).toContain('经营总览返回的指标结构不完整')
|
||||||
expect(wrapper.find('.dashboard-kpis').exists()).toBe(false)
|
expect(wrapper.find('.dashboard-kpis').exists()).toBe(false)
|
||||||
expect(wrapper.findComponent(query).exists()).toBe(true)
|
expect(wrapper.findComponent(query).exists()).toBe(true)
|
||||||
mocks.dashboard.mockResolvedValue(dashboardFixture)
|
mocks.dashboard.mockResolvedValue(dashboardFixture)
|
||||||
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
|
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
|
||||||
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
|
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
|
||||||
expect(wrapper.text()).not.toContain('经营总览指标返回不完整')
|
expect(wrapper.text()).not.toContain('经营总览返回的指标结构不完整')
|
||||||
})
|
})
|
||||||
it('preserves the last valid result when a refreshed response has a null KPI', async () => {
|
it('preserves the last valid result when a refreshed response has a null KPI', async () => {
|
||||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, kpis: { ...dashboardFixture.kpis, averageDailyOutboundQuantity: null } })
|
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, kpis: { ...dashboardFixture.kpis, averageDailyOutboundQuantity: null } })
|
||||||
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
|
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
|
||||||
expect(wrapper.text()).toContain('经营总览指标返回不完整,请更新服务后重新查询;展示上次查询')
|
expect(wrapper.text()).toContain('经营总览返回的指标结构不完整,请重试或联系管理员;展示上次查询')
|
||||||
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
|
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,10 @@ async function load(refresh = false) {
|
|||||||
if (!request.current()) return
|
if (!request.current()) return
|
||||||
if (!result?.context || metricNames.some(({ key }) => {
|
if (!result?.context || metricNames.some(({ key }) => {
|
||||||
const metric = result?.kpis?.[key]
|
const metric = result?.kpis?.[key]
|
||||||
return metric == null || typeof metric !== 'object' || !('value' in metric)
|
// Unknown quantities may be omitted by the server's NON_NULL serialization.
|
||||||
|
return metric == null || typeof metric !== 'object' || !['COMPLETE', 'PARTIAL', 'UNKNOWN'].includes(metric.dataStatus)
|
||||||
})) {
|
})) {
|
||||||
error.value = `经营总览指标返回不完整,请更新服务后重新查询${data.value ? ';展示上次查询' : ''}`
|
error.value = `经营总览返回的指标结构不完整,请重试或联系管理员${data.value ? ';展示上次查询' : ''}`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pinned && !sameContext(pinned, result.context)) { error.value = '查询版本不一致,请整体刷新经营总览'; return }
|
if (pinned && !sameContext(pinned, result.context)) { error.value = '查询版本不一致,请整体刷新经营总览'; return }
|
||||||
@@ -183,7 +184,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
|
|||||||
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
|
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<DataTable appearance="integrated" :data="[]" :columns="[]" :results-visible="false" column-settings-mode="hide">
|
<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 ? '固定版本 · 北京时间' : '正在准备数据' }}</span></div></template>
|
<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: 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-end>
|
<template #toolbar-end>
|
||||||
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间">
|
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export interface QueryContext {
|
|||||||
}
|
}
|
||||||
export interface Scope { sourceAccount: string; warehouseCode: string }
|
export interface Scope { sourceAccount: string; warehouseCode: string }
|
||||||
export type DashboardQuery = Omit<BusinessQuery, 'current' | 'size' | 'sku' | 'customerCode'>
|
export type DashboardQuery = Omit<BusinessQuery, 'current' | 'size' | 'sku' | 'customerCode'>
|
||||||
export interface DashboardKpi { value: Quantity; knownSubtotal: Quantity; unit: string; dataStatus: string; unknownSkuCount: Quantity; reasonCodes: string[]; outboundQuantity: Quantity; observedQuantity: Quantity }
|
export interface DashboardKpi { value?: Quantity; knownSubtotal?: Quantity; unit: string; dataStatus: string; unknownSkuCount?: Quantity; reasonCodes: string[]; outboundQuantity?: Quantity; observedQuantity?: Quantity }
|
||||||
export interface DashboardItem { customerCode: string; sku: string; productName: string | null; value: Quantity; unit: string; windowOutboundQuantity: Quantity; skuOutboundOrderCount: Quantity; physicalQuantity: Quantity; availableQuantity: Quantity; frozenQuantity: Quantity; defectivePhysicalQuantity: Quantity; averageDailyOutboundQuantity: Quantity; inventoryCoverageDays: Quantity }
|
export interface DashboardItem { customerCode: string; sku: string; productName: string | null; value: Quantity; unit: string; windowOutboundQuantity: Quantity; skuOutboundOrderCount: Quantity; physicalQuantity: Quantity; availableQuantity: Quantity; frozenQuantity: Quantity; defectivePhysicalQuantity: Quantity; averageDailyOutboundQuantity: Quantity; inventoryCoverageDays: Quantity }
|
||||||
export interface DashboardRanking { windowStartDate: string; windowEndDateExclusive: string; calculationWindowDays: number; eligibleCount: Quantity; unassessableCount: Quantity; items: DashboardItem[]; dataStatus: string; reasonCodes: string[] }
|
export interface DashboardRanking { windowStartDate: string; windowEndDateExclusive: string; calculationWindowDays: number; eligibleCount: Quantity; unassessableCount: Quantity; items: DashboardItem[]; dataStatus: string; reasonCodes: string[] }
|
||||||
export interface DashboardBucket { bucketCode: CoverageBucket; skuCount: Quantity; knownPhysicalQuantity: Quantity; unknownPhysicalSkuCount: Quantity }
|
export interface DashboardBucket { bucketCode: CoverageBucket; skuCount: Quantity; knownPhysicalQuantity: Quantity; unknownPhysicalSkuCount: Quantity }
|
||||||
|
|||||||
Reference in New Issue
Block a user