fix(self-operated): follow selected dates in hot rankings
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# 自营经营分析
|
||||
|
||||
- 模块入口 `routes.ts`;正式页面 `pages/OverviewView.vue`、`SalesView.vue`、`InventoryView.vue`、`CollectionView.vue`。根路由、导航、IAM 由总控装配。
|
||||
- 经营总览唯一设计入口 `docs/自营业务/经营总览图表设计.md`;`analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;趋势范围不改变固定30日指标。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD`,过期禁止冒充原图结果。不得恢复货主独立筛选或面板。
|
||||
- 经营总览唯一设计入口 `docs/自营业务/经营总览图表设计.md`;`analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选同时影响区间出库、日趋势与热销榜,热销按已采集量排名并标明覆盖不足,库存评估仍使用固定30日。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD`,过期禁止冒充原图结果。不得恢复货主独立筛选或面板。
|
||||
- API 只经统一 `shared/api/http.ts` 消费 Lastleg 的平台自营统计合同;不得调用 Integration 或持有来源凭据。
|
||||
- 北京时间 `Asia/Shanghai`,每 SKU 件数 `PCS`;货主不合并同名 SKU。未知数量保留空值,库存可售天数只用服务端结果。
|
||||
- 经营查询固定服务端版本和 queryAsOf;显式刷新/切仓重新取上下文,轮询只读运行状态。请求取消和世代校验共同防迟到响应。
|
||||
|
||||
@@ -91,7 +91,10 @@ const fields = computed<QueryField[]>(() => {
|
||||
if (tab.value === 'summary') {
|
||||
result.push({ key: 'rangePreset', type: 'select', label: '销量日期', width: 150, props: { clearable: false }, options: [{ value: 'TODAY', label: '今日' }, { value: 'LAST_3_DAYS', label: '近3天' }, { value: 'THIS_WEEK', label: '本周' }, { value: 'LAST_15_DAYS', label: '近15天' }, { value: 'THIS_MONTH', label: '本月' }, { value: 'CUSTOM', label: '自定义' }] })
|
||||
if (preset.value === 'CUSTOM') result.push({ key: 'customRange', type: 'daterange', label: '销量日期范围', width: 300, props: { 'start-placeholder': '开始日期', 'end-placeholder': '结束日期', 'range-separator': '至' } })
|
||||
} else if (tab.value === 'hot') result.push({ key: 'windowDays', type: 'select', label: '热销窗口', width: 180, props: { clearable: false }, options: [{ value: 7, label: '近7个完整自然日' }, { value: 30, label: '近30个完整自然日' }] })
|
||||
} else if (tab.value === 'hot') {
|
||||
if (drilldown.value?.analysis === 'hot') result.push({ key: 'customRange', type: 'daterange', label: '热销日期范围', width: 300 })
|
||||
else result.push({ key: 'windowDays', type: 'select', label: '热销窗口', width: 180, props: { clearable: false }, options: [{ value: 7, label: '近7个完整自然日' }, { value: 30, label: '近30个完整自然日' }] })
|
||||
}
|
||||
}
|
||||
return drilldown.value ? result.map(field => field.key === 'warehouseCode' ? field : { ...field, props: { ...field.props, disabled: true } }) : result
|
||||
})
|
||||
@@ -113,7 +116,7 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'inventoryCoverageDays', label: '可售天数', width: 110, slotName: 'inventoryCoverageDays' },
|
||||
{ key: 'lastOutbound', label: '最近已观测出库', minWidth: 220, slotName: 'lastOutbound' },
|
||||
{ key: 'attention', label: '关注原因', minWidth: 160, slotName: 'attention' },
|
||||
] : [{ key: 'quantity', label: summaryMode.value ? '区间出库量 / 已采集量' : '完整窗口出库量', width: 200, slotName: 'quantity' }]),
|
||||
] : [{ key: 'quantity', label: summaryMode.value || tab.value === 'hot' ? '区间出库量 / 已采集量' : '完整窗口出库量', width: 200, slotName: 'quantity' }]),
|
||||
{ key: 'status', label: '数据状态', minWidth: 130, slotName: 'status' },
|
||||
{ key: 'reason', label: '说明', minWidth: 220, slotName: 'reason' },
|
||||
].map(column => ['quantity', 'physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'windowOutboundQuantity', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'].includes(String(column.key)) ? { ...column, align: 'right' } : column))
|
||||
@@ -128,7 +131,10 @@ function query(): InventoryQuery {
|
||||
if (drilldown.value?.customerCode) result.customerCode = drilldown.value.customerCode
|
||||
if (summaryMode.value) {
|
||||
Object.assign(result, appliedSalesRange.value)
|
||||
} else if (props.mode === 'sales' && tab.value === 'hot') result.windowDays = windowDays.value
|
||||
} else if (props.mode === 'sales' && tab.value === 'hot') {
|
||||
if (drilldown.value?.analysis === 'hot') Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
|
||||
else result.windowDays = windowDays.value
|
||||
}
|
||||
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, appliedWindow.value, drilldown.value?.inventory)
|
||||
return result
|
||||
}
|
||||
@@ -302,7 +308,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' ? '(已采集,未完整)' : '' }}</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 #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>
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('dashboard exact drilldown contract', () => {
|
||||
expect(parsed.returnTo).toEqual({ rangePreset: 'LAST_15_DAYS', scroll: 650 })
|
||||
})
|
||||
it('preserves exact owner/SKU, maps hot enum and the independent finite candidate selector', () => {
|
||||
expect(parseDashboardDrilldown({ ...base, analysis: 'HOT', windowDays: '30', customerCode: 'OWNER-B', sku: 'SKU-X' }, 'sales')).toMatchObject({ customerCode: 'OWNER-B', analysis: 'hot' })
|
||||
expect(parseDashboardDrilldown({ ...base, analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-09-13', endDate: '2026-10-12', customerCode: 'OWNER-B', sku: 'SKU-X' }, 'sales')).toMatchObject({ customerCode: 'OWNER-B', analysis: 'hot' })
|
||||
expect(parseDashboardDrilldown({ ...base, coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' }, 'inventory')?.inventory).toMatchObject({ coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' })
|
||||
expect(parseDashboardDrilldown({ ...base, analysis: 'SKU', rangePreset: 'CUSTOM', startDate: '2026-10-12', endDate: '2026-10-12' }, 'sales')).toMatchObject({ analysis: 'summary', startDate: '2026-10-12', endDate: '2026-10-12' })
|
||||
})
|
||||
@@ -28,6 +28,9 @@ describe('dashboard exact drilldown contract', () => {
|
||||
expect(() => parseDashboardDrilldown(Object.fromEntries(Object.entries({ ...base, ...invalid }).filter(([, value]) => value !== undefined)) as Record<string, string>, 'inventory')).toThrow()
|
||||
})
|
||||
it('rejects invalid date/analysis/source and keeps warehouse selection mandatory', () => {
|
||||
expect(parseDashboardDrilldown({ ...base, analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-09-01', endDate: '2026-10-13' }, 'sales')).toMatchObject({ analysis: 'hot', rangePreset: 'CUSTOM', startDate: '2026-09-01', endDate: '2026-10-13' })
|
||||
expect(() => parseDashboardDrilldown({ ...base, analysis: 'HOT', windowDays: '30' }, 'sales')).toThrow()
|
||||
expect(() => parseDashboardDrilldown({ ...base, analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-10-13', endDate: '2026-10-12' }, 'sales')).toThrow()
|
||||
expect(() => parseDashboardDrilldown({ ...base, analysis: 'ALL' }, 'sales')).toThrow()
|
||||
expect(() => parseDashboardDrilldown({ ...base, analysis: 'SKU', rangePreset: 'CUSTOM', startDate: '2026-02-30', endDate: '2026-02-30' }, 'sales')).toThrow()
|
||||
expect(() => parseDashboardDrilldown({ ...base, from: 'https://example.com' }, 'sales')).toThrow()
|
||||
|
||||
@@ -62,9 +62,11 @@ export function parseDashboardDrilldown(query: LocationQuery, mode: 'sales' | 'i
|
||||
if (['coverageBucket', 'coverageSelection', 'bucketRuleVersion', 'attention', 'sortBy'].some(key => query[key] !== undefined)) throw new Error('销量下钻不能携带库存条件')
|
||||
const analysis = scalar(query, 'analysis', true)
|
||||
if (analysis === 'HOT') {
|
||||
if (['rangePreset', 'startDate', 'endDate'].some(key => query[key] !== undefined)) throw new Error('热销下钻不能携带趋势日期条件')
|
||||
if (scalar(query, 'windowDays', true) !== '30') throw new Error('热销下钻须为30个完整日')
|
||||
result.analysis = 'hot'; result.summary += ' · 近30日热销Top100'
|
||||
if (query.windowDays !== undefined || scalar(query, 'rangePreset', true) !== 'CUSTOM') throw new Error('热销下钻须使用榜单实际日期')
|
||||
result.startDate = scalar(query, 'startDate', true); result.endDate = scalar(query, 'endDate', true)
|
||||
if (validateCustomRange(result.startDate, result.endDate, context.anchorDate)) throw new Error('热销下钻日期无效')
|
||||
result.analysis = 'hot'; result.rangePreset = 'CUSTOM'
|
||||
result.summary += ` · 所选时段热销Top100 · ${result.startDate} 至 ${result.endDate}`
|
||||
} else if (analysis === 'SKU') {
|
||||
if (query.windowDays !== undefined) throw new Error('SKU日期下钻不能携带榜单窗口')
|
||||
const range = scalar(query, 'rangePreset', true)
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
| 销量/库存 | 默认仓库与重置 | 2026-10-13 用户确认进入页面即按仓库显示:无传入仓库时自动选中接口返回的首个可用仓库并查询;链接带入仓库优先;重置保留当前仓库并重新查询 | 保留切仓与迟到响应隔离;没有可用仓库不发无仓库查询;自动回归覆盖两页首次进入、链接进入、缓存页返回及重置 |
|
||||
| /self-operated/sales | 自定义销量日期 | 2026-10-13 用户截图 `codex-clipboard-fac03bb1-6703-467d-b357-5731ea8cfb32.png`:两个单日框分散换行,未完整选择时残留逆序错误和旧结果。原筛选区改为公共QueryToolbar单一日期范围控件,明确开始/结束日期,支持同日;不新增浮层或改变统计口径 | 日期范围整体换行,校验完整范围后查询;草稿修改不改变已查询列表的分页/趋势条件,提交失败不冒充新范围结果;补充自动回归,待人工1920×1080、100%截图确认 |
|
||||
| /self-operated/overview | 查询、刷新与六项指标 | `docs/自营业务/经营总览图表设计.md`“菜单与页面布局”“指标带”;单仓+趋势日期QueryToolbar、紧凑指标带,整体固定版本 | 不新增货主控件;未知/过期独立提示;待人工1920×1080截图确认 |
|
||||
| /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;SVG折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 |
|
||||
| /self-operated/overview | SKU趋势 | 同设计“图表交互与视觉依据”;1120px居中AppDialog,固定30日、精确货主SKU | 关闭取消请求,不改现有详情承载;待人工截图确认 |
|
||||
| /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;SVG折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 热销随时间筛选按已采集量排名并提示覆盖不足;可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 |
|
||||
| /self-operated/overview | SKU趋势 | 同设计“图表交互与视觉依据”;1120px居中AppDialog,热销沿所选时段、其它榜沿固定30日,精确货主SKU | 关闭取消请求,不改现有详情承载;待人工截图确认 |
|
||||
| 总览→销量/库存 | 日期/榜单/分桶下钻与返回 | 同设计“精确下钻合同”;目标页验证完整版本、精确筛选及互斥,显示可清除来源摘要,固定本地返回 | 库存时效到期阻止严格下钻;返回恢复趋势范围、版本、滚动;待人工截图确认 |
|
||||
| /self-operated/sales | 仓库、SKU、日期/榜单查询及分页 | 当前截图:货主控件分散筛选;本轮冻结为单一紧凑筛选区,仓库明确提示必须选择,SKU为主 | 复用 AdminPage、QueryToolbar、DataTable、高密度样式;日期/榜单切换和分页保持版本;待人工截图确认 |
|
||||
| /self-operated/sales | SKU 趋势 | 用户最新截图明确要求详情不再放列表下方;使用1120px居中AppDialog,保留整个日轴、未知断线及跨页SKU入口 | 可关闭/全屏;关闭取消请求、防止迟到回写;待人工截图确认 |
|
||||
| /self-operated/sales | SKU销量、热销、滞销切换 | 本轮并入统一筛选区的销量类别选择;独立完整日窗口 | 热销 7/30 日,滞销 30 日;待人工截图确认 |
|
||||
| /self-operated/sales | SKU销量、热销、滞销切换 | 本轮并入统一筛选区的销量类别选择;独立完整日窗口 | 热销独立入口 7/30 日,总览下钻沿所选日期与版本并标明未采齐;滞销 30 日;待人工截图确认 |
|
||||
| /self-operated/inventory | SKU 库存关注、阈值及排序 | 当前截图:库存筛选散落且货主优先;仓库、SKU、销量窗口、统计截止日、关注、阈值、排序共用 QueryToolbar;移除货主经营概览 | SKU、商品名称靠前,所属货主为一辅助列;分页与排序保留所选窗口及固定版本;待人工1920×1080、100%截图确认 |
|
||||
| /self-operated/inventory | 10/30天窗口与统计截止日 | 用户确认预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口选择和含当日截止日期放在原筛选区,默认30天、北京时间昨天,可固定历史截止日;切换窗口重置查询及页码 | 窗口出库与日均列名随实际窗口变化;未采齐不以部分销量推算,零销量明确显示“所选窗口无出库”,可售天数只展示服务端结果;10天或历史截止日不提供仅适用默认30天的关注/排序;总览原图下钻仍固定30天;待人工截图确认 |
|
||||
| /self-operated/inventory | 实际统计区间与库存观测说明 | 列表提示直接显示服务端实际销量起止日期、含截止日、窗口天数及销量状态;库存采集起止时间单独展示,明确当前库存来自一轮完整分页观测;详细公式仍在统计说明折叠区 | 截止日仅约束销量窗口,不伪装为该日历史库存;未知与零保持区分;待人工截图确认 |
|
||||
|
||||
@@ -207,6 +207,28 @@ describe('warehouse operating dashboard', () => {
|
||||
expect(mocks.daily.mock.calls[0]?.[1].aborted).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('LATE-SKU')
|
||||
})
|
||||
it('keeps an uncovered selected period empty without changing its dates or claiming zero sales', async () => {
|
||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, hotSkus: { ...dashboardFixture.hotSkus, windowStartDate: '2026-10-13', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 1, eligibleCount: '0', items: [], dataStatus: 'UNKNOWN', reasonCodes: ['SALES_WINDOW_INCOMPLETE'] } })
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(wrapper.findAllComponents(DashboardBars)[1]!.props('rows')).toEqual([])
|
||||
expect(wrapper.text()).toContain('未采集日期不计为零出库')
|
||||
await findButton(wrapper, '查看Top100').trigger('click')
|
||||
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ startDate: '2026-10-13', endDate: '2026-10-13', rangePreset: 'CUSTOM' })
|
||||
})
|
||||
it('shows partial hot sales for the selected dates and preserves them in Top100 and SKU trends', async () => {
|
||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, hotSkus: { ...dashboardFixture.hotSkus, windowStartDate: '2026-10-01', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 13, dataStatus: 'PARTIAL', reasonCodes: ['SALES_WINDOW_INCOMPLETE'], items: [{ ...dashboardFixture.hotSkus.items[0], windowOutboundQuantity: '37', value: '37', averageDailyOutboundQuantity: null, inventoryCoverageDays: null }] } })
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(wrapper.text()).toContain('所选时段热销Top10')
|
||||
expect(wrapper.text()).toContain('按所选时段已采集量排名')
|
||||
const hot = wrapper.findAllComponents(DashboardBars)[1]!
|
||||
expect(hot.props('rows')[0]).toMatchObject({ value: '37' })
|
||||
expect(hot.props('rows')[0].detail).toContain('13天窗口已采集出库 37 件')
|
||||
expect(hot.props('rows')[0].detail).not.toContain('近30日出库')
|
||||
await findButton(wrapper, '查看Top100').trigger('click')
|
||||
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-10-01', endDate: '2026-10-13' })
|
||||
hot.vm.$emit('select', '0'); await flushPromises()
|
||||
expect(mocks.daily.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-01', endDate: '2026-10-13', customerCode: 'OWNER-A', sku: 'SKU-X' })
|
||||
})
|
||||
it('emits exact bucket/day/ranking navigation and keeps unknown day navigation disabled', async () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
wrapper.findAllComponents(DashboardBars)[0]!.vm.$emit('select', 'DAYS_GT_90'); await flushPromises()
|
||||
@@ -218,7 +240,7 @@ describe('warehouse operating dashboard', () => {
|
||||
await chart.findAll('g')[0]!.trigger('keydown', { key: 'Enter' })
|
||||
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', windowDays: '30' })
|
||||
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-09-13', endDate: '2026-10-12' })
|
||||
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 () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { analyticsApi } from '../api/analytics'
|
||||
import type { Dashboard, DashboardItem, DailyResponse, DashboardQuery, Overview, QueryContext, RangePreset } from '../types'
|
||||
import type { Dashboard, DashboardItem, DashboardRanking, DailyResponse, DashboardQuery, Overview, QueryContext, RangePreset } from '../types'
|
||||
import { beijingTime, contextParams, label, quantityText, reasonsText, RequestFence, RuntimePoller, sameContext, validateCustomRange } from '../state'
|
||||
import { buckets, expired, inventoryExpired, parseContext, parseOverviewReturn, previousDate, rangeOptions, type OverviewReturn } from '../dashboardNavigation'
|
||||
import DashboardBars, { type BarRow } from '../components/DashboardBars.vue'
|
||||
@@ -55,7 +55,7 @@ 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 }[] = [
|
||||
@@ -65,12 +65,12 @@ const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: bo
|
||||
]
|
||||
const metricIcons = [ArrowUpRight, Activity, Boxes, Package, Layers, Clock3]
|
||||
const metricColors = ['#5575ed', '#3aa9ba', '#8871ce', '#35a987', '#c6923e', '#d67b91']
|
||||
const rankingNames = [{ key: 'hotSkus', label: '近30日热销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: '近30日无出库库存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` }
|
||||
}))
|
||||
function rankingRows(items: DashboardItem[]): BarRow[] { return items.map((item, index) => ({ key: String(index), label: item.sku, secondary: `${item.productName || '—'} · 货主 ${item.customerCode}`, value: item.value, detail: `SKU ${item.sku};货主 ${item.customerCode};实物 ${quantityText(item.physicalQuantity)} 件,可售 ${quantityText(item.availableQuantity)} 件,近30日出库 ${quantityText(item.outboundQuantity30d)} 件,日均 ${quantityText(item.averageDailyOutboundQuantity)} 件,可售 ${quantityText(item.inventoryCoverageDays)} 天` })) }
|
||||
function rankingRows(ranking: DashboardRanking): BarRow[] { return ranking.items.map((item, index) => ({ key: String(index), label: item.sku, secondary: `${item.productName || '—'} · 货主 ${item.customerCode}`, value: item.value, detail: `SKU ${item.sku};货主 ${item.customerCode};实物 ${quantityText(item.physicalQuantity)} 件,可售 ${quantityText(item.availableQuantity)} 件,${ranking.calculationWindowDays}天窗口${ranking.dataStatus === 'COMPLETE' ? '出库' : '已采集出库'} ${quantityText(item.windowOutboundQuantity)} 件,窗口日均 ${quantityText(item.averageDailyOutboundQuantity)} 件,可售 ${quantityText(item.inventoryCoverageDays)} 天` })) }
|
||||
const dataFence = new RequestFence()
|
||||
const runtimeFence = new RequestFence()
|
||||
const trendFence = new RequestFence()
|
||||
@@ -127,18 +127,21 @@ function drill(path: 'sales' | 'inventory', params: Record<string, string>, stri
|
||||
void router.push({ path: `/self-operated/${path}`, query: { warehouseCode: warehouseCode.value, from: 'overview', context: JSON.stringify(context.value), overviewReturn: JSON.stringify({ ...appliedRange, scroll: window.scrollY }), ...params } })
|
||||
}
|
||||
function allRanking(key: typeof rankingNames[number]['key']) {
|
||||
if (key === 'hotSkus') drill('sales', { analysis: 'HOT', windowDays: '30' })
|
||||
if (key === 'hotSkus') {
|
||||
if (!data.value) return
|
||||
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' })
|
||||
}
|
||||
async function showTrend(item: DashboardItem) {
|
||||
async function showTrend(item: DashboardItem, ranking: DashboardRanking) {
|
||||
if (!context.value || expired(context.value)) { error.value = '原图查询已过期,请整体刷新经营总览'; return }
|
||||
const fixed = context.value
|
||||
if (!fixed.fixedWindowStartDate || !fixed.fixedWindowEndDateExclusive) { error.value = '完整30日窗口尚不可用'; return }
|
||||
if (!ranking.windowStartDate || !ranking.windowEndDateExclusive) { error.value = '榜单日期范围尚不可用'; return }
|
||||
trendOpen.value = true; trendLoading.value = true; trend.value = null; trendError.value = ''
|
||||
const request = trendFence.begin()
|
||||
try {
|
||||
const result = await analyticsApi.daily({ sourceAccount, warehouseCode: warehouseCode.value, customerCode: item.customerCode, sku: item.sku, current: 1, size: 20, ...contextParams(fixed), rangePreset: 'CUSTOM', startDate: fixed.fixedWindowStartDate, endDate: previousDate(fixed.fixedWindowEndDateExclusive) }, request.signal)
|
||||
const result = await analyticsApi.daily({ sourceAccount, warehouseCode: warehouseCode.value, customerCode: item.customerCode, sku: item.sku, current: 1, size: 20, ...contextParams(fixed), rangePreset: 'CUSTOM', startDate: ranking.windowStartDate, endDate: previousDate(ranking.windowEndDateExclusive) }, request.signal)
|
||||
if (!request.current()) return
|
||||
if (!sameContext(fixed, result.context)) { trendError.value = '趋势版本与总览不一致,请刷新总览'; return }
|
||||
trend.value = result
|
||||
@@ -175,7 +178,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>时间筛选同时影响区间出库、日趋势与热销榜;热销按所选时段已采集量排名,未采齐会明确提示。库存消化与无出库评估仍需近30个完整日。</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>
|
||||
@@ -198,7 +201,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
|
||||
<section class="dashboard-chart"><h3>库存消化天数分布</h3><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><p v-if="data.coverageDistribution.candidateSkuCount == null">无可用库存分布资料;销售候选不作为库存总量。</p><DashboardBars v-else :rows="bucketRows" title="库存消化天数分布" unit="SKU" :disabled="stockExpired || contextExpired" @select="code => drill('inventory', { coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' })" /><details><summary>分组说明与待核验原因</summary><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></details></section>
|
||||
</div>
|
||||
<div class="dashboard-rankings">
|
||||
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart"><div class="dashboard-ranking-heading"><h3>{{ ranking.label }}</h3><el-button :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button></div><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><DashboardBars ranked :rows="rankingRows(data[ranking.key].items)" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!)" /></section>
|
||||
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart"><div class="dashboard-ranking-heading"><h3>{{ ranking.label }}</h3><el-button :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button></div><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><DashboardBars ranked :rows="rankingRows(data[ranking.key])" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!, data![ranking.key])" /></section>
|
||||
</div>
|
||||
</template>
|
||||
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable><div class="admin-density"><p v-if="trendError" role="alert">{{ trendError }}</p><SkuTrend v-if="trend" :data="trend" /></div></AppDialog>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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', outboundQuantity30d: '300', skuOutboundOrderCount: '3', physicalQuantity: '3000', availableQuantity: '3000', frozenQuantity: '0', defectivePhysicalQuantity: '0', averageDailyOutboundQuantity: '10', inventoryCoverageDays: '300' }
|
||||
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: [] }
|
||||
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 },
|
||||
|
||||
@@ -226,14 +226,25 @@ describe('self-operated actual workbench request state', () => {
|
||||
await findButton(wrapper, '返回经营总览').trigger('click')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/overview', query: { warehouseCode: 'WH-A', context: JSON.stringify(pinned), overviewReturn: JSON.stringify(back) } })
|
||||
})
|
||||
it('labels incomplete hot quantities as collected for the direct hot entry', async () => {
|
||||
mocks.api.hot.mockResolvedValue({ context, records: [{ ...metric, outboundQuantity: '37', salesCoverage: 'COVERAGE_INSUFFICIENT' }], total: 1 })
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
await setFilters(wrapper, { analysis: 'hot' })
|
||||
expect(wrapper.findComponent(tableStub).props('columns')).toContainEqual(expect.objectContaining({ key: 'quantity', label: '区间出库量 / 已采集量' }))
|
||||
expect(wrapper.text()).toContain('37 (已采集,未完整)')
|
||||
mocks.api.hot.mockResolvedValue({ context, records: [{ ...metric, outboundQuantity: '37', salesCoverage: 'COMPLETE' }], total: 1 })
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('(已采集,未完整)')
|
||||
})
|
||||
it('maps overview HOT and custom day to real API parameters without forwarding route-only enums', async () => {
|
||||
const pinned = { ...context, queryExpiresAt: '2099-10-13T00:30:00Z' }
|
||||
mocks.routeQuery = { warehouseCode: 'WH-A', from: 'overview', context: JSON.stringify(pinned), overviewReturn: JSON.stringify({ rangePreset: 'LAST_15_DAYS', scroll: 0 }), analysis: 'HOT', windowDays: '30' }
|
||||
mocks.routeQuery = { warehouseCode: 'WH-A', from: 'overview', context: JSON.stringify(pinned), overviewReturn: JSON.stringify({ rangePreset: 'LAST_15_DAYS', scroll: 0 }), analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-12' }
|
||||
mocks.api.hot.mockResolvedValue({ context: pinned, records: [], total: 100 })
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
expect(mocks.api.summary).not.toHaveBeenCalled()
|
||||
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ windowDays: 30, salesRunId: '11' })
|
||||
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-12', salesRunId: '11' })
|
||||
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('analysis')
|
||||
mocks.routeQuery = { ...mocks.routeQuery, analysis: 'SKU', rangePreset: 'CUSTOM', startDate: '2026-10-11', endDate: '2026-10-11' }
|
||||
delete mocks.routeQuery.windowDays
|
||||
|
||||
@@ -71,7 +71,7 @@ export const reasonLabels: Record<string, string> = {
|
||||
NO_OUTBOUND_IN_WINDOW: '所选窗口无出库', NO_OUTBOUND_30D: '近30天无出库', COVERAGE_GT_THRESHOLD: '可售天数超过本次阈值',
|
||||
FROZEN_STOCK: '存在冻结库存', DEFECTIVE_STOCK: '存在次品实物库存', UNASSESSABLE: '数据不足/待观察',
|
||||
THRESHOLD_NOT_SET: '未设置查询阈值', NO_PHYSICAL_INVENTORY: '无实物库存',
|
||||
METRIC_DEPENDENCY_UNKNOWN: '该指标依赖资料未知', SALES_WINDOW_UNAVAILABLE: '所选窗口完整销量不可用',
|
||||
METRIC_DEPENDENCY_UNKNOWN: '该指标依赖资料未知', SALES_WINDOW_UNAVAILABLE: '所选窗口完整销量不可用', SALES_WINDOW_INCOMPLETE: '所选时段销量尚未采齐',
|
||||
RISK_DEPENDENCY_UNKNOWN: '部分SKU关注判断未知', NO_OUTBOUND_IN_COLLECTED_HISTORY: '已采集历史内未见出库',
|
||||
SALES_NOT_COVERED: '销量未覆盖', SKU_HISTORY_UNKNOWN: 'SKU历史未知', QUANTITY_UNIT_UNKNOWN: '数量单位未知',
|
||||
INVENTORY_NOT_PUBLISHED: '库存尚未发布', SALES_NOT_PUBLISHED: '销量尚未发布', INVENTORY_STALE: '库存快照过期',
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface QueryContext {
|
||||
export interface Scope { sourceAccount: string; warehouseCode: string }
|
||||
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 DashboardItem { customerCode: string; sku: string; productName: string | null; value: Quantity; unit: string; outboundQuantity30d: 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 DashboardBucket { bucketCode: CoverageBucket; skuCount: Quantity; knownPhysicalQuantity: Quantity; unknownPhysicalSkuCount: Quantity }
|
||||
export interface Dashboard {
|
||||
@@ -112,6 +112,7 @@ export interface Metric {
|
||||
averageDailyOutboundQuantity: Quantity
|
||||
inventoryCoverageDays: Quantity
|
||||
inventoryObservedAt: string | null
|
||||
salesCoverage?: string
|
||||
status: string
|
||||
reasonCodes: string[]
|
||||
calculationWindowDays?: number
|
||||
|
||||
Reference in New Issue
Block a user