From 698ca34529863b05e13d55a5456572a63db3a11b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 18:47:56 +0800 Subject: [PATCH] fix(self-operated): follow selected dates in hot rankings --- src/modules/self-operated/AGENTS.md | 2 +- .../components/AnalyticsView.vue | 14 +++++++---- .../self-operated/dashboardNavigation.test.ts | 5 +++- .../self-operated/dashboardNavigation.ts | 8 ++++--- src/modules/self-operated/operation-matrix.md | 6 ++--- .../self-operated/pages/OverviewView.test.ts | 24 ++++++++++++++++++- .../self-operated/pages/OverviewView.vue | 23 ++++++++++-------- .../self-operated/pages/dashboard.fixture.ts | 2 +- .../self-operated/pages/workbench.test.ts | 15 ++++++++++-- src/modules/self-operated/state.ts | 2 +- src/modules/self-operated/types.ts | 3 ++- 11 files changed, 76 insertions(+), 28 deletions(-) diff --git a/src/modules/self-operated/AGENTS.md b/src/modules/self-operated/AGENTS.md index 0caa2c8e..f6a48f2a 100644 --- a/src/modules/self-operated/AGENTS.md +++ b/src/modules/self-operated/AGENTS.md @@ -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;显式刷新/切仓重新取上下文,轮询只读运行状态。请求取消和世代校验共同防迟到响应。 diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index 3b2fbee1..1f9b58e8 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -91,7 +91,10 @@ const fields = computed(() => { 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(() => [ { 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 - + diff --git a/src/modules/self-operated/dashboardNavigation.test.ts b/src/modules/self-operated/dashboardNavigation.test.ts index 4dc0cb05..f99636be 100644 --- a/src/modules/self-operated/dashboardNavigation.test.ts +++ b/src/modules/self-operated/dashboardNavigation.test.ts @@ -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, '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() diff --git a/src/modules/self-operated/dashboardNavigation.ts b/src/modules/self-operated/dashboardNavigation.ts index 469bb7a4..12359a87 100644 --- a/src/modules/self-operated/dashboardNavigation.ts +++ b/src/modules/self-operated/dashboardNavigation.ts @@ -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) diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index cc7c4e36..0e8421cc 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -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 | 实际统计区间与库存观测说明 | 列表提示直接显示服务端实际销量起止日期、含截止日、窗口天数及销量状态;库存采集起止时间单独展示,明确当前库存来自一轮完整分页观测;详细公式仍在统计说明折叠区 | 截止日仅约束销量窗口,不伪装为该日历史库存;未知与零保持区分;待人工截图确认 | diff --git a/src/modules/self-operated/pages/OverviewView.test.ts b/src/modules/self-operated/pages/OverviewView.test.ts index 63480935..4a9eeaca 100644 --- a/src/modules/self-operated/pages/OverviewView.test.ts +++ b/src/modules/self-operated/pages/OverviewView.test.ts @@ -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 () => { diff --git a/src/modules/self-operated/pages/OverviewView.vue b/src/modules/self-operated/pages/OverviewView.vue index c4b2ef2d..02a022ac 100644 --- a/src/modules/self-operated/pages/OverviewView.vue +++ b/src/modules/self-operated/pages/OverviewView.vue @@ -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(() => [ { 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(() => 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, 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 @@ -198,7 +201,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea

库存消化天数分布

有实物库存或库存待核验 SKU;{{ data.context.fixedWindowStartDate }} 至 {{ data.context.fixedWindowEndDateExclusive }}(截止日不含)。

候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}

无可用库存分布资料;销售候选不作为库存总量。

分组说明与待核验原因

30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。

{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU

-

{{ ranking.label }}

{{ ranking.all }}

{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。

{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}

+

{{ ranking.label }}

{{ ranking.all }}

{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。

{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}

按所选时段已采集量排名,数据尚未采齐,排名可能随采集变化;未采集日期不计为零出库。

本榜仍需完整近30日销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。

{{ trendError }}

diff --git a/src/modules/self-operated/pages/dashboard.fixture.ts b/src/modules/self-operated/pages/dashboard.fixture.ts index 1617a948..322d348a 100644 --- a/src/modules/self-operated/pages/dashboard.fixture.ts +++ b/src/modules/self-operated/pages/dashboard.fixture.ts @@ -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 }, diff --git a/src/modules/self-operated/pages/workbench.test.ts b/src/modules/self-operated/pages/workbench.test.ts index a5f0bce3..f0ce5f7b 100644 --- a/src/modules/self-operated/pages/workbench.test.ts +++ b/src/modules/self-operated/pages/workbench.test.ts @@ -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 diff --git a/src/modules/self-operated/state.ts b/src/modules/self-operated/state.ts index 5d71aa89..7dd258fa 100644 --- a/src/modules/self-operated/state.ts +++ b/src/modules/self-operated/state.ts @@ -71,7 +71,7 @@ export const reasonLabels: Record = { 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: '库存快照过期', diff --git a/src/modules/self-operated/types.ts b/src/modules/self-operated/types.ts index 1dba7dff..aa2cc02b 100644 --- a/src/modules/self-operated/types.ts +++ b/src/modules/self-operated/types.ts @@ -36,7 +36,7 @@ export interface QueryContext { export interface Scope { sourceAccount: string; warehouseCode: string } export type DashboardQuery = Omit 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