fix(self-operated): clarify today's unavailable sales and tooltip precision

This commit is contained in:
2026-10-14 11:18:47 +08:00
parent e0f54edcc9
commit d8a4160bc7
5 changed files with 23 additions and 2 deletions
@@ -124,6 +124,12 @@ const columns = computed<DataTableColumn[]>(() => [
const dataFence = new RequestFence()
const dailyFence = new RequestFence()
const runtimeFence = new RequestFence()
function showRecentCompleteDays() {
const end = previousDate(context.value?.anchorDate ?? beijingToday())
const start = new Date(Date.parse(`${end}T00:00:00Z`) - 29 * 86400000).toISOString().slice(0, 10)
filters.value = { ...filters.value, rangePreset: 'CUSTOM', customRange: [start, end] }
void search()
}
function asSummary(row: unknown) { return row as SalesSummary }
function asMetric(row: unknown) { return row as Metric }
function asInventory(row: unknown) { return row as InventoryMetric }
@@ -286,6 +292,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
<template v-if="$slots['view-tabs']" #toolbar-start><slot name="view-tabs" /></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :preference-key="`self-operated-${mode}-query`" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices>
<p v-if="summaryMode && !drilldown && appliedSalesRange?.rangePreset === 'TODAY' && rows.length && rows.every(row => shownQuantity(asSummary(row)) == null)" class="analytics-note" role="status">当前仅查询今日,所列商品暂无可用的今日数量;历史采集量不计入今日。<el-button link type="primary" @click="showRecentCompleteDays">查看近30个完整日</el-button></p>
<p v-if="routeProblem" role="alert">{{ routeProblem }};已停止查询,不能按全仓条件继续。<el-button @click="returnOverview">返回经营总览</el-button></p>
<div v-if="origin" class="density-actions"><span>{{ drilldown?.summary ?? '来自经营总览 · 来源筛选已清除,保留原有效版本' }}</span><el-button v-if="drilldown" @click="clearDrilldown">清除来源条件</el-button><el-button @click="returnOverview">返回经营总览</el-button></div>
<p v-if="runtimeError" class="analytics-note" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
@@ -317,7 +324,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
<template #unit="{ row }">{{ asMetric(row).quantityUnit === 'PCS' ? '件' : asMetric(row).quantityUnit || '—' }}</template>
<template #lastOutbound="{ row }">{{ asMetric(row).lastObservedOutboundDate ?? '已采集历史内未见出库' }}<br />历史覆盖起点:{{ asMetric(row).observedHistoryStartDate ?? '—' }}</template>
<template #attention="{ row }">{{ reasonsText(asMetric(row).attentionReasons) }}</template>
<template #quantity="{ row }">{{ quantityText(summaryMode ? shownQuantity(asSummary(row)) : asMetric(row).outboundQuantity) }} {{ (summaryMode ? asSummary(row).dataStatus !== 'COMPLETE' : tab === 'hot' && asMetric(row).salesCoverage !== 'COMPLETE') ? '(已采集,未完整)' : '' }}</template>
<template #quantity="{ row }">{{ quantityText(summaryMode ? shownQuantity(asSummary(row)) : asMetric(row).outboundQuantity) }} {{ summaryMode && shownQuantity(asSummary(row)) == null ? '(暂无可用数据)' : (summaryMode ? asSummary(row).dataStatus !== 'COMPLETE' : tab === 'hot' && asMetric(row).salesCoverage !== 'COMPLETE') ? '(已采集,未完整)' : '' }}</template>
<template #status="{ row }">{{ label(summaryMode ? asSummary(row).dataStatus : asMetric(row).status) }}</template>
<template #reason="{ row }">{{ reasonsText(summaryMode ? asSummary(row).reasonCodes : asMetric(row).reasonCodes) }}</template>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'windowOutboundQuantity', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'] as const" :key="key" #[key]="{ row }">{{ quantityText(asInventory(row)[key]) }}<span v-if="asMetric(row).quantityStatuses?.[key] && asMetric(row).quantityStatuses?.[key] !== 'VALID'">({{ label(asMetric(row).quantityStatuses?.[key]) }})</span></template>
@@ -115,3 +115,5 @@
| 数据采集/当前阶段圆环动效 | 用户反馈圆环跳闪、未知总量看似停止 | ECharts Canvas圆环单独启用合并更新及700ms进度过渡;未知总量仅在可见、状态有效且执行中显示旋转指示及执行中文字,不伪造比例;等待、陈旧、隐藏或阶段结束停止动效,遵循减少动态效果偏好 | 状态与轮询回归;视觉待人工截图确认 |
| 经营总览/统一统计时间(2026-10-14) | 用户要求分布、预计消化最长及无出库库存榜跟随顶部搜索时间 | 三榜、分布及日均/无出库指标统一使用所选区间;名称改为“所选时段无出库库存Top10”,明细保留原日期并锁定;库存取当前版本,缺日不填零,30/60/90天分组边界不变 | 日期、分组、排序与下钻回归;待人工1920×1080、100%截图确认 |
| 销量分析/今日空数量及日均精度 | 用户反馈历史采齐但今日为空、日均悬浮小数过长 | 今日全无可用数量时明确范围并提供近30完整日查询;空值不标已采集。日均悬浮与摘要统一最多2位小数、件/日单位,底层精度不变 | 日期请求与精度回归;实际服务覆盖需返回值核对 |
@@ -50,6 +50,14 @@ 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 () => {
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(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-09-12', endDate: '2026-10-11' })
})
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' }] })
@@ -16,6 +16,10 @@ describe('turnover honest precision and charts', () => {
const partial: TurnoverWindowMetric = { days: 15, startDate: '2026-09-28', endDateExclusive: '2026-10-13', dataStatus: 'PARTIAL', observedQuantity: 150, outboundQuantity: null, averageDailyOutboundQuantity: null, completeDayCount: 7, partialDayCount: 1, unknownDayCount: 7, reasonCodes: [] }
const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: [{ ...partial, days: 7, dataStatus: 'COMPLETE', averageDailyOutboundQuantity: 10 }, partial] }
const option = turnoverChart(charts, 'daily')
const tooltip = option.tooltip as { valueFormatter: (value: number | null) => string }
expect(tooltip.valueFormatter(2165.133333)).toBe('2,165.13 件/日')
expect(tooltip.valueFormatter(0)).toBe('0 件/日')
expect(tooltip.valueFormatter(null)).toBe('— 件/日')
expect(JSON.stringify(option.series)).toContain('"data":[10,null]')
expect(windowText(partial)).toBe('已采集 150,覆盖不足')
})
@@ -47,7 +47,7 @@ export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'daily'):
return {
aria: { enabled: true }, animation: typeof window === 'undefined' || !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
animationDuration: 450, color: ['#6c7bea'],
tooltip: { trigger: 'axis', renderMode: 'richText' }, grid: { left: 12, right: 40, top: owners ? 12 : 36, bottom: 12, containLabel: true },
tooltip: { trigger: 'axis', renderMode: 'richText', valueFormatter: value => `${turnoverNumber(typeof value === 'number' || typeof value === 'string' ? value : null)} ${owners ? '件' : '件/日'}` }, grid: { left: 12, right: 40, top: owners ? 12 : 36, bottom: 12, containLabel: true },
xAxis: owners ? { type: 'value', name: '件' } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) },
yAxis: owners ? { type: 'category', inverse: true, data: charts.ownerPhysicalTop.map(w => w.customerName || w.customerCode) } : { type: 'value', name: '件/日', nameGap: 12, nameTextStyle: { align: 'left' } },
series: [{ type: 'bar', barMaxWidth: 24, itemStyle: { borderRadius: 4 }, data: rows.map((_, i) => {