From 449d0c4965dcc31badd47a6f773545646d661b57 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 12 Oct 2026 21:36:24 +0800 Subject: [PATCH] refactor(self-operated): focus analytics pages on SKU metrics --- .../components/AnalyticsView.vue | 141 +++++++++--------- .../self-operated/components/SkuTrend.vue | 13 +- src/modules/self-operated/operation-matrix.md | 14 +- .../self-operated/pages/workbench.test.ts | 101 ++++++++++--- 4 files changed, 163 insertions(+), 106 deletions(-) diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index e58b2617..ba177dff 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -11,27 +11,25 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { analyticsApi } from '../api/analytics' -import type { BusinessQuery, DailyResponse, InventoryAttention, InventorySort, Metric, Overview, OwnerOverview, QueryContext, RangePreset, SalesSummary } from '../types' +import type { BusinessQuery, DailyResponse, InventoryAttention, InventorySort, Metric, Overview, QueryContext, RangePreset, SalesSummary } from '../types' import { RequestFence, RuntimePoller, contextParams, hasActiveTask, label, beijingTime, reasonsText, quantityText, sameContext, shownQuantity, validateCustomRange } from '../state' import SkuTrend from './SkuTrend.vue' -import OwnerPicker from './OwnerPicker.vue' -import OwnerOverviewPanel from './OwnerOverviewPanel.vue' const props = defineProps<{ mode: 'sales' | 'inventory' }>() const route = useRoute() const router = useRouter() const access = useAccessPreviewStore() const permitted = computed(() => access.can('platform:self-operated-analytics:list')) -const filters = ref({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', customerCode: typeof route.query.customerCode === 'string' ? route.query.customerCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '' }) -const applied = ref({ customerCode: String(filters.value.customerCode), sku: String(filters.value.sku) }) +function initialFilters(): QueryModel { return { warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: 'summary', rangePreset: 'TODAY', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customStart: '', customEnd: '' } } +const filters = ref(initialFilters()) +const applied = ref({ sku: String(filters.value.sku) }) const overview = ref(null) const context = ref(null) const rows = ref<(SalesSummary | Metric)[]>([]) const daily = ref(null) -const ownerOverview = ref(null) -const attention = ref('ALL') -const sortBy = ref('AVAILABLE_DESC') -const thresholdInput = ref('') +const attention = computed(() => (filters.value.attention || 'ALL') as InventoryAttention) +const sortBy = computed(() => (filters.value.sortBy || 'AVAILABLE_DESC') as InventorySort) +const thresholdInput = computed(() => String(filters.value.threshold ?? '')) const appliedInventory = ref<{ attention: InventoryAttention; sortBy: InventorySort; coverageDaysThreshold?: number }>({ attention: 'ALL', sortBy: 'AVAILABLE_DESC' }) const loading = ref(false) const error = ref('') @@ -42,24 +40,39 @@ const newDay = ref(false) const current = ref(1) const size = ref(20) const total = ref(0) -const preset = ref('TODAY') -const customStart = ref('') -const customEnd = ref('') -const tab = ref<'summary' | 'hot' | 'slow'>('summary') -const windowDays = ref<7 | 30>(30) +const preset = computed(() => (filters.value.rangePreset || 'TODAY') as RangePreset) +const customStart = computed(() => String(filters.value.customStart ?? '')) +const customEnd = computed(() => String(filters.value.customEnd ?? '')) +const tab = computed(() => (filters.value.analysis || 'summary') as 'summary' | 'hot' | 'slow') +const windowDays = computed(() => Number(filters.value.windowDays || 30) as 7 | 30) const sourceAccount = 'lingxing-primary' const warehouseCode = computed(() => String(filters.value.warehouseCode ?? '')) -const fields = computed(() => [ - { key: 'warehouseCode', type: 'select', label: '仓库', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] }, - { key: 'sku', type: 'input', label: 'SKU', placeholder: '原始 SKU 精确编码' }, -]) +const fields = computed(() => { + const result: QueryField[] = [ + { key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] }, + { key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' }, + ] + if (props.mode === 'inventory') result.push( + { key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, { value: 'NO_OUTBOUND_30D', label: '近30天无出库' }, { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] }, + { key: 'threshold', type: 'input', label: '可售天数阈值', width: 160, placeholder: '可售天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } }, + { key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, { value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }] }, + ) + else { + result.push({ key: 'analysis', type: 'select', label: '销量类别', width: 170, props: { clearable: false }, options: [{ value: 'summary', label: 'SKU销量' }, { value: 'hot', label: '热销Top100' }, { value: 'slow', label: '滞销Top100' }] }) + 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: 'customStart', type: 'date', label: '开始日期', width: 160 }, { key: 'customEnd', type: 'date', label: '结束日期', width: 160 }) + } 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个完整自然日' }] }) + } + return result +}) const summaryMode = computed(() => props.mode === 'sales' && tab.value === 'summary') const columns = computed(() => [ ...(summaryMode.value ? [{ key: 'actions', label: '操作', width: 110, slotName: 'actions', fixed: 'left' }] : []), - { prop: 'customerCode', label: '货主编码', minWidth: 140 }, - ...(props.mode === 'inventory' ? [{ prop: 'customerName', label: '货主名称', minWidth: 140 }] : []), { prop: 'sku', label: 'SKU', minWidth: 170, ...(props.mode === 'inventory' ? { slotName: 'sku' } : {}) }, - { prop: 'productName', label: '商品名称', minWidth: 190 }, { prop: 'quantityUnit', label: '单位', width: 80 }, + { prop: 'productName', label: '商品名称', minWidth: 190 }, + { key: 'owner', label: '所属货主', minWidth: 140, slotName: 'owner' }, + { key: 'unit', label: '单位', width: 70, slotName: 'unit' }, ...(props.mode === 'inventory' ? [ { key: 'physicalQuantity', label: '全品质实物', width: 110, slotName: 'physicalQuantity' }, { key: 'availableQuantity', label: '正品可售', width: 110, slotName: 'availableQuantity' }, @@ -74,14 +87,14 @@ const columns = computed(() => [ ] : [{ key: 'quantity', label: summaryMode.value ? '区间出库量 / 已采集量' : '完整窗口出库量', 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', 'outboundQuantity30d', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'].includes(String(column.key)) ? { ...column, align: 'right' } : column)) const dataFence = new RequestFence() const dailyFence = new RequestFence() const runtimeFence = new RequestFence() function asSummary(row: unknown) { return row as SalesSummary } function asMetric(row: unknown) { return row as Metric } function query(): BusinessQuery { - const result: BusinessQuery = { sourceAccount, warehouseCode: warehouseCode.value, customerCode: applied.value.customerCode || undefined, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) } + const result: BusinessQuery = { sourceAccount, warehouseCode: warehouseCode.value, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) } if (summaryMode.value) { result.rangePreset = preset.value if (preset.value === 'CUSTOM') { result.startDate = customStart.value; result.endDate = customEnd.value } @@ -95,7 +108,7 @@ async function load(reset = false) { const problem = validateCustomRange(customStart.value, customEnd.value, context.value?.anchorDate) if (problem) { error.value = problem; return } } - if (reset) { context.value = null; daily.value = null; ownerOverview.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 } + if (reset) { context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 } dailyFence.cancel() const request = dataFence.begin() loading.value = true @@ -107,18 +120,12 @@ async function load(reset = false) { : tab.value === 'hot' ? await analyticsApi.hot(params, request.signal) : tab.value === 'slow' ? await analyticsApi.slow(params, request.signal) : await analyticsApi.summary(params, request.signal) if (!request.current()) return - if (context.value && !sameContext(context.value, result.context)) throw new Error('查询版本已变化,请整体刷新') - const owner = props.mode === 'inventory' && applied.value.customerCode - ? await analyticsApi.ownerOverview({ sourceAccount, warehouseCode: warehouseCode.value, customerCode: applied.value.customerCode, coverageDaysThreshold: appliedInventory.value.coverageDaysThreshold, ...contextParams(result.context) }, request.signal) - : null - if (!request.current()) return - if (owner && !sameContext(result.context, owner.context)) throw new Error('货主概览版本与明细不一致,请整体刷新') + if (context.value && !sameContext(context.value, result.context)) throw new Error('查询版本已变化,请重新查询') context.value = result.context - ownerOverview.value = owner rows.value = result.records total.value = result.total daily.value = null - } catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '查询失败,保留上次成功数据;可整体刷新重新获取版本'); error.value = errorDetails.value.message } } + } catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '查询失败,请重新查询'); error.value = errorDetails.value.message } } finally { if (request.current()) loading.value = false } } async function showTrend(row: SalesSummary | Metric) { @@ -132,7 +139,7 @@ async function showTrend(row: SalesSummary | Metric) { } else if (params.rangePreset === 'CUSTOM') { params.startDate = customStart.value; params.endDate = customEnd.value } const result = await analyticsApi.daily(params, request.signal) if (!request.current()) return - if (!context.value || !sameContext(context.value, result.context)) throw new Error('趋势版本与列表不一致,请整体刷新') + if (!context.value || !sameContext(context.value, result.context)) throw new Error('趋势版本与列表不一致,请重新查询') daily.value = result } catch (cause) { if (request.current()) error.value = getApiErrorMessage(cause, 'SKU趋势读取失败') } } @@ -160,14 +167,14 @@ function search() { return } appliedInventory.value = { attention: attention.value, sortBy: sortBy.value, coverageDaysThreshold: rawThreshold ? Number(rawThreshold) : undefined } - applied.value = { customerCode: String(filters.value.customerCode ?? ''), sku: String(filters.value.sku ?? '') } - ownerOverview.value = null + applied.value = { sku: String(filters.value.sku ?? '').trim() } current.value = 1 - void load() + void load(true) } -function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, customerCode: applied.value.customerCode, sku: applied.value.sku } }) } -watch(warehouseCode, () => { dataFence.cancel(); dailyFence.cancel(); rows.value = []; ownerOverview.value = null; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) }) -watch([preset, tab, windowDays], () => { current.value = 1; void load() }) +function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } } +function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, sku: applied.value.sku } }) } +watch(warehouseCode, () => { dataFence.cancel(); dailyFence.cancel(); rows.value = []; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) }) +watch([preset, tab, windowDays], () => { current.value = 1; if (!summaryMode.value || preset.value !== 'CUSTOM' || customStart.value && customEnd.value) void load() }) watch([current, size], () => { void load() }) onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() }) onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); document.removeEventListener('visibilitychange', visibility) }) @@ -176,38 +183,24 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu

没有自营经营数据查看权限。

-

来源账号:{{ sourceAccount }} · 北京时间 Asia/Shanghai · 数量为每个 SKU 的件数 PCS

- - -
- - - - 应用库存关注 -
-

请先选择仓库。货主默认为全部已授权范围。

-

{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}

+ +

{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}

-
- -
-
- {{ option.label }} - -
-
-

固定使用截至昨日的 30 个完整自然日窗口。{{ mode === 'inventory' ? '实物含全品质,可售仅正品;冻结及次品不重复计入实物,在途不计当前占仓。日均量及可售天数由服务端计算,阈值仅用于本次查询。' : '滞销观察资格由服务端判断。' }}

-

查询基准:{{ beijingTime(context.queryAsOf) }}(北京时间);有效至 {{ beijingTime(context.queryExpiresAt) }}。范围 {{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(结束边界不含)。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }};销量发布 {{ beijingTime(context.salesPublishedAt) }};库存采集 {{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.rebuildRequired ? '修订待重建' : '' }}

-

有新数据,可整体刷新。

-

北京时间已跨日;当前范围仍按原查询基准日,整体刷新后取得新日期和版本。

-

指标计算窗口:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(结束边界不含);{{ context.calculationWindowDays ?? (tab === 'hot' ? windowDays : 30) }} 天。

-

{{ state.dataFamily === 'INVENTORY' ? '库存' : '销量' }}当前时效:{{ label(state.dataFreshness) }};最近发布时间 {{ beijingTime(state.lastPublishedAt) }}。运行状态按最近观测判断。

- -

{{ applied.customerCode ? '当前货主尚无可用经营概览。' : '选择一个具体货主后显示全量经营概览;当前可浏览全仓 SKU 明细。' }}

- - - - +

{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。

+
+ 统计说明 · 北京时间/件 +

{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。

+

实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。

+

查询时间:{{ beijingTime(context.queryAsOf) }};有效至 {{ beijingTime(context.queryExpiresAt) }}。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }}。{{ context.rebuildRequired ? '数据修订待重建。' : '' }}

+

实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含)。

+

{{ state.dataFamily === 'INVENTORY' ? '库存' : '销量' }}数据:{{ label(state.dataFreshness) }};最近发布时间 {{ beijingTime(state.lastPublishedAt) }}。

+
+ + + + + + @@ -216,9 +209,15 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
+ diff --git a/src/modules/self-operated/components/SkuTrend.vue b/src/modules/self-operated/components/SkuTrend.vue index 7a44e301..aa1b34bc 100644 --- a/src/modules/self-operated/components/SkuTrend.vue +++ b/src/modules/self-operated/components/SkuTrend.vue @@ -1,7 +1,7 @@