fix(self-operated): clarify turnover chart values and coverage threshold

This commit is contained in:
2026-10-14 14:00:35 +08:00
parent e7411eb03f
commit ff9ca74ba4
4 changed files with 24 additions and 16 deletions
@@ -80,8 +80,12 @@ describe('inventory turnover page fixed snapshot behavior', () => {
return result
})
const w = page(); await flushPromises()
expect(w.text()).toContain('1,234.57 件/日')
expect(w.text()).toContain('2,100 件/日')
const chart = w.findComponent({ name: 'AnalyticsChart' }).props('option') as { series: { data: (number | null)[]; label: { show: boolean; formatter: (params: { value: number | null }) => string } }[] }
expect(chart.series[0]!.label.show).toBe(true)
expect(chart.series[0]!.label.formatter({ value: chart.series[0]!.data[0]! })).toBe('1,234.57')
expect(chart.series[0]!.label.formatter({ value: chart.series[0]!.data[1]! })).toBe('2,100')
expect(chart.series[0]!.data[2]).toBeNull()
expect(w.find('.turnover-window-summary').exists()).toBe(false)
expect(w.text()).toContain('完整 18 / 30 日')
expect(w.text()).toContain('已发布销量版本计算')
expect(w.text()).toContain('新采集数据待重算')
@@ -96,15 +100,19 @@ describe('inventory turnover page fixed snapshot behavior', () => {
await select('消化较慢')
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ focus: 'COVERAGE_GT', coverageDaysThreshold: 30, sortBy: 'COVERAGE_DESC', current: 1 })
expect(w.find('.turnover-focus button[aria-pressed="true"]').text()).toBe('消化较慢')
await w.get('input[aria-label="快慢阈值天数"]').setValue('60')
await w.get('input[aria-label="可售天数阈值(天)"]').setValue('60')
await select('消化较快')
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ focus: 'COVERAGE_LE', coverageDaysThreshold: 60, sortBy: 'COVERAGE_ASC' })
const calls = mocks.turnover.mock.calls.length
await w.get('input[aria-label="快慢阈值天数"]').setValue('0')
await w.get('input[aria-label="可售天数阈值(天)"]').setValue('0')
await select('消化较慢')
expect(mocks.turnover).toHaveBeenCalledTimes(calls)
expect(w.get('.turnover-filter-feedback').text()).toContain('正整数')
expect(w.find('.turnover-focus button[aria-pressed="true"]').text()).toBe('消化较快')
await select('全部')
expect(mocks.turnover.mock.calls.at(-1)![0].coverageDaysThreshold).toBeUndefined()
expect(w.find('.turnover-threshold').exists()).toBe(false)
expect(w.findComponent(toolbar).props('fields').some((field: { key: string }) => field.key === 'coverageDaysThreshold')).toBe(false)
})
it('queries the remaining focus tabs and uses owner-compatible sorting', async () => {
const w = page(); await flushPromises()
@@ -47,7 +47,6 @@ const fields = computed<QueryField[]>(() => [
{ key: 'groupBy', label: '分组', type: 'select', width: 120, options: [{ value: 'SKU', label: '按SKU' }, { value: 'OWNER', label: '按货主' }], props: { clearable: false } },
...(filters.value.groupBy === 'SKU' ? [{ key: 'sku', label: 'SKU', type: 'input' as const, width: 175, placeholder: 'SKU精确查询' }] : []),
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
{ key: 'coverageDaysThreshold', label: '快慢阈值(天)', type: 'input', width: 145, placeholder: '正整数,可选' },
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
])
@@ -79,6 +78,7 @@ const cards = computed(() => data.value?.summary ? [
{ name: '实物件数', value: data.value.summary.physicalQuantity }, { name: '正品可售件数', value: data.value.summary.availableQuantity },
{ name: '所选时段无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount },
] : [])
const incompleteDailyWindows = computed(() => data.value?.charts?.averageDaily.filter(w => w.dataStatus !== 'COMPLETE' || w.averageDailyOutboundQuantity == null) ?? [])
const runtimeError = ref('')
const inventoryRuntime = computed(() => runtime.value?.warehouses.find(w => w.warehouseCode === applied.value?.warehouseCode && w.dataFamily === 'INVENTORY'))
const collectionNotice = computed(() => !data.value || runtimeError.value ? '' : inventoryCollectionNotice(inventoryRuntime.value))
@@ -107,7 +107,7 @@ function query(refresh = false): TurnoverQuery {
const anchor = !refresh && data.value ? beijingDate(Date.parse(data.value.context.queryAsOf)) : beijingDate(Date.now())
const range = overviewRange(filters.value.rangePreset as RangePreset, anchor, String(custom[0] ?? ''), String(custom[1] ?? ''))
const days = Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 86400000) + 1
return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: range.endDate, baseWindowDays: days, groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCode: String(filters.value.customerCode || '') || undefined, sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], coverageDaysThreshold: filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true }
return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: range.endDate, baseWindowDays: days, groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCode: String(filters.value.customerCode || '') || undefined, sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], coverageDaysThreshold: !['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.value.focus)) || filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true }
}
function scopeKey(q: TurnoverQuery) { return [q.warehouseCode, q.endDate, q.baseWindowDays].join('|') }
function overviewKey(q: TurnoverQuery) { return [scopeKey(q), q.customerCode, q.groupBy].join('|') }
@@ -244,7 +244,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
<div v-if="chartsOpen" class="turnover-charts">
<section><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
<section><header class="turnover-chart-header"><h3>日均出库对比(含所选时段)</h3><ChartDetailsDialog inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div class="turnover-window-summary"><div v-for="window in data.charts.averageDaily" :key="window.days"><span>{{ window.days }}日日均</span><strong>{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : '覆盖不足' }}</strong><small v-if="window.dataStatus !== 'COMPLETE'">完整 {{ window.completeDayCount }} / {{ window.days }} 日</small></div></div></section>
<section><header class="turnover-chart-header"><h3>日均出库对比(含所选时段)</h3><ChartDetailsDialog inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)</span></div></section>
</div>
</template>
<nav class="turnover-focus" aria-label="库存关注快捷筛选">
@@ -255,9 +255,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<button type="button" :aria-pressed="applied?.focus === 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</button>
</nav>
<div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold">
<label>快慢阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="快慢阈值天数" @keydown.enter.prevent="search()" /> 天</label>
<label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label>
<el-button @click="search()">应用</el-button>
<span>预计可售天数大于阈值为较慢,不超过阈值为较快;首次使用默认30天,可修改。数据不足的商品请查看“待评估”。</span>
<span>按当前可售库存预计还能销售的天数筛选:超过阈值为较慢,不超过阈值为较快。此数值不是上方的统计时间范围;快捷筛选首次默认30天,可修改。零可售或无出库不归为较快。</span>
</div>
<p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
<p v-else-if="loading" class="turnover-filter-feedback" role="status">正在更新筛选结果…</p>
@@ -312,11 +312,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
.turnover-chart-empty { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-lighter); border-radius: 8px; margin-top: 12px; }
.turnover-chart-empty strong { color: var(--el-text-color-regular); }
.turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin: 8px 0 0; }
.turnover-window-summary { flex-shrink: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 12px 0 0; }
.turnover-window-summary > div { background: var(--el-fill-color-lighter); padding: 8px 12px; border-radius: 6px; }
.turnover-window-summary span, .turnover-window-summary strong { display: block; }
.turnover-window-summary span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin-bottom: 4px; }
.turnover-window-summary strong { font-variant-numeric: tabular-nums; }
.turnover-window-gaps { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 8px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-focus { display: flex; flex-wrap: wrap; gap: 24px; margin: 4px 0 0; border-bottom: 1px solid var(--el-border-color-light); }
.turnover-focus button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 12px 4px; font: inherit; color: var(--el-text-color-regular); cursor: pointer; }
.turnover-focus button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }