feat(selfop): add operating dashboard and preserve drilldown context
This commit is contained in:
@@ -186,6 +186,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
title: '自营业务',
|
||||
icon: PrototypeSalesIcon,
|
||||
children: [
|
||||
{ key: 'self-operated-overview', title: '经营总览', icon: PrototypeDashboardIcon, path: '/self-operated/overview', permission: 'platform:self-operated-analytics:list' },
|
||||
{ key: 'self-operated-sales', title: '销量分析', icon: ClipboardList, path: '/self-operated/sales', permission: 'platform:self-operated-analytics:list' },
|
||||
{ key: 'self-operated-inventory', title: '库存分析', icon: WarehouseIcon, path: '/self-operated/inventory', permission: 'platform:self-operated-analytics:list' },
|
||||
{ key: 'self-operated-collection', title: '数据采集', icon: Database, path: '/self-operated/collection', permission: 'platform:self-operated-analytics:list' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 自营经营分析
|
||||
|
||||
- 模块入口 `routes.ts`;正式页面 `pages/SalesView.vue`、`InventoryView.vue`、`CollectionView.vue`。根路由、导航、IAM 由总控装配。
|
||||
- 模块入口 `routes.ts`;正式页面 `pages/OverviewView.vue`、`SalesView.vue`、`InventoryView.vue`、`CollectionView.vue`。根路由、导航、IAM 由总控装配。
|
||||
- 经营总览唯一设计入口 `docs/自营业务/经营总览图表设计.md`;`analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;趋势范围不改变固定30日指标。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD`,过期禁止冒充原图结果。不得恢复货主独立筛选或面板。
|
||||
- API 只经统一 `shared/api/http.ts` 消费 Lastleg 的平台自营统计合同;不得调用 Integration 或持有来源凭据。
|
||||
- 北京时间 `Asia/Shanghai`,每 SKU 件数 `PCS`;货主不合并同名 SKU。未知数量保留空值,库存可售天数只用服务端结果。
|
||||
- 经营查询固定服务端版本和 queryAsOf;显式刷新/切仓重新取上下文,轮询只读运行状态。请求取消和世代校验共同防迟到响应。
|
||||
|
||||
@@ -5,6 +5,13 @@ const http = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
|
||||
vi.mock('@/shared/api/http', () => ({ http, unwrapApiResponse: (value: { data: unknown }) => value.data }))
|
||||
beforeEach(() => { vi.clearAllMocks(); http.get.mockResolvedValue({ data: {} }); http.post.mockResolvedValue({ data: { taskNo: 'COL-001', status: 'QUEUED' } }) })
|
||||
describe('self-operated platform transport contract', () => {
|
||||
it('keeps dashboard business aggregation separate from sync-overview runtime polling', async () => {
|
||||
const params = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' as const, salesRunId: '11', inventoryRunId: '12', queryAsOf: '2026-10-13T00:00:00Z' }
|
||||
await analyticsApi.dashboard(params)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/self-operated-analytics/dashboard', { params, signal: undefined })
|
||||
expect(params).not.toHaveProperty('customerCode')
|
||||
expect(params).not.toHaveProperty('current')
|
||||
})
|
||||
it('uses local fixed-version owners, owner overview and inventory attention endpoints', async () => {
|
||||
const fixed = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', salesRunId: '11', inventoryRunId: '12', queryAsOf: '2026-10-12T01:00:00Z' }
|
||||
await analyticsApi.owners({ ...fixed, keyword: 'OWNER_', current: 2, size: 20 })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { BusinessPage, BusinessQuery, DailyResponse, Metric, Overview, OwnerOption, OwnerOverview, OwnerOverviewQuery, OwnerQuery, RetryCommand, RetryResult, Run, RuntimePage, RuntimeQuery, SalesSummary, Scope, TaskDetail } from '../types'
|
||||
import type { BusinessPage, BusinessQuery, Dashboard, DashboardQuery, DailyResponse, Metric, Overview, OwnerOption, OwnerOverview, OwnerOverviewQuery, OwnerQuery, RetryCommand, RetryResult, Run, RuntimePage, RuntimeQuery, SalesSummary, Scope, TaskDetail } from '../types'
|
||||
|
||||
const base = '/platform/v1/last-leg/self-operated-analytics'
|
||||
async function get<T>(path: string, params: object, signal?: AbortSignal): Promise<T> {
|
||||
@@ -7,6 +7,7 @@ async function get<T>(path: string, params: object, signal?: AbortSignal): Promi
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export const analyticsApi = {
|
||||
dashboard: (query: DashboardQuery, signal?: AbortSignal) => get<Dashboard>('dashboard', query, signal),
|
||||
owners: (query: OwnerQuery, signal?: AbortSignal) => get<BusinessPage<OwnerOption>>('owners', query, signal),
|
||||
ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get<OwnerOverview | null>('owner-overview', query, signal),
|
||||
summary: (query: BusinessQuery, signal?: AbortSignal) => get<BusinessPage<SalesSummary>>('sku-sales-summary', query, signal),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
@@ -15,17 +15,27 @@ import { analyticsApi } from '../api/analytics'
|
||||
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 { expired, inventoryExpired, parseDashboardDrilldown, type DashboardDrilldown } from '../dashboardNavigation'
|
||||
|
||||
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'))
|
||||
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 routeProblem = ref('')
|
||||
const drilldown = ref<DashboardDrilldown | null>(null)
|
||||
const origin = ref<DashboardDrilldown | null>(null)
|
||||
function readDrilldown() {
|
||||
routeProblem.value = ''
|
||||
try { drilldown.value = parseDashboardDrilldown(route.query, props.mode); origin.value = drilldown.value }
|
||||
catch (cause) { drilldown.value = null; origin.value = null; routeProblem.value = cause instanceof Error ? cause.message : '经营总览下钻条件无效' }
|
||||
}
|
||||
readDrilldown()
|
||||
function initialFilters(): QueryModel { return { warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'TODAY', windowDays: 30, attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customStart: drilldown.value?.startDate ?? '', customEnd: drilldown.value?.endDate ?? '' } }
|
||||
const filters = ref<QueryModel>(initialFilters())
|
||||
const applied = ref({ sku: String(filters.value.sku) })
|
||||
const overview = ref<Overview | null>(null)
|
||||
const context = ref<QueryContext | null>(null)
|
||||
const context = ref<QueryContext | null>(drilldown.value?.context ?? null)
|
||||
const rows = ref<(SalesSummary | Metric)[]>([])
|
||||
const daily = ref<DailyResponse | null>(null)
|
||||
const trendOpen = ref(false)
|
||||
@@ -35,7 +45,7 @@ const trendErrorDetails = ref<ApiErrorDetails | null>(null)
|
||||
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 appliedInventory = ref<{ attention: InventoryAttention; sortBy: InventorySort; coverageDaysThreshold?: number }>({ attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const errorDetails = ref<ApiErrorDetails | null>(null)
|
||||
@@ -69,7 +79,7 @@ const fields = computed<QueryField[]>(() => {
|
||||
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
|
||||
return drilldown.value ? result.map(field => field.key === 'warehouseCode' ? field : { ...field, props: { ...field.props, disabled: true } }) : result
|
||||
})
|
||||
const summaryMode = computed(() => props.mode === 'sales' && tab.value === 'summary')
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
@@ -100,20 +110,23 @@ 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, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) }
|
||||
if (drilldown.value?.customerCode) result.customerCode = drilldown.value.customerCode
|
||||
if (summaryMode.value) {
|
||||
result.rangePreset = preset.value
|
||||
if (preset.value === 'CUSTOM') { result.startDate = customStart.value; result.endDate = customEnd.value }
|
||||
} else if (props.mode === 'sales' && tab.value === 'hot') result.windowDays = windowDays.value
|
||||
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value)
|
||||
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, drilldown.value?.inventory)
|
||||
return result
|
||||
}
|
||||
async function load(reset = false) {
|
||||
if (!permitted.value || !warehouseCode.value) return
|
||||
if (!permitted.value || !warehouseCode.value || routeProblem.value) return
|
||||
if (origin.value && context.value && expired(context.value)) { error.value = '原图查询已过期,请返回经营总览整体刷新'; return }
|
||||
if (drilldown.value && props.mode === 'inventory' && context.value && inventoryExpired(context.value)) { error.value = '原图库存已过期,请刷新总览'; return }
|
||||
if (summaryMode.value && preset.value === 'CUSTOM') {
|
||||
const problem = validateCustomRange(customStart.value, customEnd.value, context.value?.anchorDate)
|
||||
if (problem) { error.value = problem; return }
|
||||
}
|
||||
if (reset) { context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
|
||||
if (reset) { if (!origin.value) context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
|
||||
trendOpen.value = false
|
||||
dailyFence.cancel()
|
||||
const request = dataFence.begin()
|
||||
@@ -184,11 +197,30 @@ function search() {
|
||||
void load(true)
|
||||
}
|
||||
function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } }
|
||||
function returnOverview() {
|
||||
const saved = origin.value
|
||||
void router.push({ path: '/self-operated/overview', query: saved ? { warehouseCode: warehouseCode.value, context: JSON.stringify(saved.context), overviewReturn: JSON.stringify(saved.returnTo) } : {} })
|
||||
}
|
||||
async function clearDrilldown() {
|
||||
drilldown.value = null
|
||||
filters.value = { ...filters.value, sku: '', analysis: 'summary', rangePreset: 'TODAY', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customStart: '', customEnd: '' }
|
||||
applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }; current.value = 1
|
||||
await nextTick(); void load()
|
||||
}
|
||||
function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, sku: applied.value.sku } }) }
|
||||
watch(warehouseCode, () => { trendOpen.value = false; 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() })
|
||||
let applyingRoute = false
|
||||
watch(warehouseCode, () => { if (applyingRoute) return; drilldown.value = null; origin.value = null; trendOpen.value = false; 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], () => { if (applyingRoute) return; current.value = 1; if (!summaryMode.value || preset.value !== 'CUSTOM' || customStart.value && customEnd.value) void load() })
|
||||
watch([current, size], () => { if (!applyingRoute) void load() })
|
||||
onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() })
|
||||
watch(() => route.query, async () => {
|
||||
if (route.path && route.path !== `/self-operated/${props.mode}`) return
|
||||
applyingRoute = true; dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; rows.value = []; total.value = 0
|
||||
readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); applied.value = { sku: String(filters.value.sku ?? '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1
|
||||
await nextTick(); applyingRoute = false; void load()
|
||||
})
|
||||
onActivated(() => { visibility(); poller.start() })
|
||||
onDeactivated(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false })
|
||||
onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); document.removeEventListener('visibilitychange', visibility) })
|
||||
</script>
|
||||
<template>
|
||||
@@ -198,6 +230,8 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" :empty-text="!warehouseCode ? '请选择仓库后查询SKU' : error ? '查询失败,请重试' : '暂无符合条件的SKU数据'">
|
||||
<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="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>
|
||||
<LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
|
||||
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。</p>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Quantity } from '../types'
|
||||
import { quantityText } from '../state'
|
||||
export interface BarRow { key: string; label: string; secondary?: string; value: Quantity; detail: string }
|
||||
const props = defineProps<{ rows: BarRow[]; unit: string; disabled?: boolean; title: string }>()
|
||||
const emit = defineEmits<{ select: [key: string] }>()
|
||||
const focused = ref('')
|
||||
const maximum = computed(() => Math.max(1, ...props.rows.map(row => Number(row.value ?? 0))))
|
||||
</script>
|
||||
<template>
|
||||
<div :aria-label="title" class="dashboard-bars">
|
||||
<p v-if="!rows.length">暂无符合条件的 SKU;请核对覆盖与待评估资料。</p>
|
||||
<button v-for="row in rows" :key="row.key" type="button" class="bar-row" :aria-disabled="disabled || row.value == null" :aria-label="`${row.label},${row.secondary ?? ''},${quantityText(row.value)} ${unit};${row.detail}`" :title="`${row.label} ${row.secondary ?? ''};${row.detail}`" @focus="focused = row.key" @mouseenter="focused = row.key" @click="!disabled && row.value != null && emit('select', row.key)">
|
||||
<span class="bar-heading"><span class="bar-label">{{ row.label }}</span><strong>{{ quantityText(row.value) }} {{ unit }}</strong></span>
|
||||
<span v-if="row.secondary" class="bar-secondary">{{ row.secondary }}</span>
|
||||
<svg viewBox="0 0 500 15" aria-hidden="true"><line x1="0" x2="500" y1="14" y2="14" stroke="var(--el-border-color)" /><rect v-if="row.value != null" x="0" y="1" :width="Number(row.value) / maximum * 500" height="11" rx="2" fill="var(--el-color-primary)" /></svg>
|
||||
</button>
|
||||
<p v-if="focused" class="bar-detail" role="status">{{ rows.find(row => row.key === focused)?.detail }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.dashboard-bars { display: grid; gap: var(--admin-space-2); }
|
||||
.bar-row { appearance: none; border: 0; padding: var(--admin-space-2) 0; background: transparent; text-align: left; color: inherit; cursor: pointer; width: 100%; font: inherit; }
|
||||
.bar-row:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.bar-row[aria-disabled="true"] { cursor: default; }
|
||||
.bar-heading { display: flex; gap: var(--admin-space-2); justify-content: space-between; align-items: baseline; }
|
||||
.bar-label { overflow-wrap: anywhere; }
|
||||
.bar-heading strong { white-space: nowrap; }
|
||||
.bar-secondary, .bar-detail { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
||||
svg { display: block; width: 100%; height: 15px; }
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { DailyPoint } from '../types'
|
||||
import { label, quantityText, reasonsText, shownQuantity, trendSegments } from '../state'
|
||||
const props = defineProps<{ points: DailyPoint[]; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ select: [date: string] }>()
|
||||
const focused = ref<DailyPoint | null>(null)
|
||||
const segments = computed(() => trendSegments(props.points))
|
||||
const maximum = computed(() => Math.max(1, ...props.points.map(point => Number(shownQuantity(point) ?? 0))))
|
||||
function x(index: number) { return props.points.length === 1 ? 400 : 30 + index / (props.points.length - 1) * 740 }
|
||||
function y(point: DailyPoint) { return 175 - Number(shownQuantity(point) ?? 0) / maximum.value * 145 }
|
||||
function text(point: DailyPoint) { return `${point.businessDate}:${quantityText(shownQuantity(point))} 件,${label(point.dataStatus)};${reasonsText(point.reasonCodes)}` }
|
||||
function select(point: DailyPoint) { focused.value = point; if (!props.disabled && point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null) emit('select', point.businessDate) }
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<p>缺失日期断线;空心点为未完整日;× 表示未知。今日为已采集累计。</p>
|
||||
<svg viewBox="0 0 800 220" class="dashboard-trend" role="group" aria-label="全仓每日出库趋势,点击已知日点查看当日销量">
|
||||
<line x1="30" y1="175" x2="770" y2="175" stroke="currentColor" /><text x="10" y="190">0</text><text x="30" y="20">{{ maximum }} 件</text>
|
||||
<path v-for="(segment, index) in segments" :key="index" :d="segment.path" fill="none" stroke="var(--el-color-primary)" stroke-width="2" :stroke-dasharray="segment.partial ? '5 4' : undefined" />
|
||||
<g v-for="(point, index) in points" :key="point.businessDate" tabindex="0" role="button" :aria-label="text(point)" :aria-disabled="disabled || point.dataStatus === 'UNKNOWN' || shownQuantity(point) == null" @focus="focused = point" @mouseenter="focused = point" @click="select(point)" @keydown.enter.prevent="select(point)" @keydown.space.prevent="select(point)">
|
||||
<title>{{ text(point) }}</title>
|
||||
<circle v-if="point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null" :cx="x(index)" :cy="y(point)" r="5" :fill="point.dataStatus === 'COMPLETE' ? 'var(--el-color-primary)' : 'var(--el-bg-color)'" stroke="var(--el-color-primary)" stroke-width="2" />
|
||||
<text v-else :x="x(index)" y="174" text-anchor="middle">×</text>
|
||||
<text v-if="index % Math.max(1, Math.ceil(points.length / 8)) === 0 || index === points.length - 1" :x="x(index)" y="208" text-anchor="middle">{{ point.businessDate.slice(5) }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
<p v-if="focused" role="status">{{ text(focused) }}</p>
|
||||
<p v-if="!segments.length">所选日期暂无可靠出库资料,未知不按零显示。</p>
|
||||
<details><summary>查看每日数值与覆盖原因</summary><table class="density-table"><thead><tr><th>日期</th><th>正式量</th><th>已采集量</th><th>状态</th><th>原因</th></tr></thead><tbody><tr v-for="point in points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr></tbody></table></details>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.dashboard-trend { width: 100%; height: 300px; }
|
||||
text { font-size: var(--admin-font-caption); }
|
||||
g:focus { outline: none; }
|
||||
g:focus circle { stroke: var(--el-text-color-primary); stroke-width: 3px; }
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buckets, expired, inventoryExpired, parseDashboardDrilldown, previousDate } from './dashboardNavigation'
|
||||
import type { QueryContext } from './types'
|
||||
|
||||
const context: QueryContext = { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2026-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13', inventoryDrilldownExpiresAt: '2026-10-13T00:10:00Z' }
|
||||
const base = { from: 'overview', warehouseCode: 'WH-A', context: JSON.stringify(context), overviewReturn: JSON.stringify({ rangePreset: 'LAST_15_DAYS', scroll: 650 }) }
|
||||
describe('dashboard exact drilldown contract', () => {
|
||||
it.each(buckets)('maps $code to exact server bucket rather than an attention approximation', ({ code }) => {
|
||||
const parsed = parseDashboardDrilldown({ ...base, coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' }, 'inventory')!
|
||||
expect(parsed.inventory).toMatchObject({ coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', drilldownFrom: 'DASHBOARD', attention: 'ALL' })
|
||||
expect(parsed.context).toEqual(context)
|
||||
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, 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' })
|
||||
})
|
||||
it.each([
|
||||
{ coverageBucket: 'BAD', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' },
|
||||
{ coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'BAD' },
|
||||
{ bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' },
|
||||
{ coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', attention: 'NO_OUTBOUND_30D' },
|
||||
{ coverageSelection: 'FINITE_POSITIVE', attention: 'NO_OUTBOUND_30D' },
|
||||
{ coverageSelection: 'FINITE_POSITIVE', coverageBucket: 'UNKNOWN', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' },
|
||||
{ context: '{}' }, { warehouseCode: '' }, { drilldownFrom: 'OTHER' },
|
||||
])('rejects malformed/conflicting parameters without widening to the whole warehouse: %j', invalid => {
|
||||
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: '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()
|
||||
expect(() => parseDashboardDrilldown({ coverageBucket: 'UNKNOWN' }, 'inventory')).toThrow()
|
||||
})
|
||||
it('expires inventory earlier without expiring sales and subtracts date boundaries without local timezone drift', () => {
|
||||
const now = Date.parse('2026-10-13T00:15:00Z')
|
||||
expect(inventoryExpired(context, now)).toBe(true)
|
||||
expect(expired(context, now)).toBe(false)
|
||||
expect(previousDate('2026-03-01')).toBe('2026-02-28')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import type { BusinessQuery, CoverageBucket, QueryContext, RangePreset } from './types'
|
||||
import { validateCustomRange } from './state'
|
||||
|
||||
export const buckets: { code: CoverageBucket; label: string }[] = [
|
||||
{ code: 'DAYS_0_30', label: '0~30天(含30)' }, { code: 'DAYS_30_60', label: '30~60天(含60)' },
|
||||
{ code: 'DAYS_60_90', label: '60~90天(含90)' }, { code: 'DAYS_GT_90', label: '超过90天' },
|
||||
{ code: 'NO_OUTBOUND_30D', label: '近30日无出库' }, { code: 'NO_AVAILABLE', label: '无可售库存' }, { code: 'UNKNOWN', label: '库存或销量待核验' },
|
||||
]
|
||||
export const rangeOptions: { value: RangePreset; label: string }[] = [{ 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: '自定义' }]
|
||||
export interface OverviewReturn { rangePreset: RangePreset; startDate?: string; endDate?: string; scroll: number }
|
||||
export interface DashboardDrilldown {
|
||||
context: QueryContext
|
||||
customerCode?: string
|
||||
analysis: 'summary' | 'hot'
|
||||
rangePreset: RangePreset
|
||||
startDate: string
|
||||
endDate: string
|
||||
inventory: Pick<BusinessQuery, 'attention' | 'sortBy' | 'coverageBucket' | 'bucketRuleVersion' | 'coverageSelection' | 'drilldownFrom'>
|
||||
returnTo: OverviewReturn
|
||||
summary: string
|
||||
}
|
||||
function scalar(query: LocationQuery, key: string, required = false): string {
|
||||
const value = query[key]
|
||||
if (value === undefined && !required) return ''
|
||||
if (typeof value !== 'string' || !value || value.length > 12000 || /[\u0000-\u001f]/.test(value)) throw new Error(`经营总览下钻参数 ${key} 无效`)
|
||||
return value
|
||||
}
|
||||
export function parseContext(raw: string): QueryContext {
|
||||
let value: QueryContext
|
||||
try { value = JSON.parse(raw) as QueryContext } catch { throw new Error('经营总览版本上下文无效') }
|
||||
if (!value || typeof value !== 'object' || !/^\d{4}-\d{2}-\d{2}$/.test(value.anchorDate ?? '') || value.businessTimezone !== 'Asia/Shanghai') throw new Error('经营总览版本上下文无效')
|
||||
for (const key of ['salesRunId', 'inventoryRunId'] as const) if (value[key] !== null && (typeof value[key] !== 'string' || !/^\d+$/.test(value[key]))) throw new Error('经营总览版本编号无效')
|
||||
for (const key of ['salesScopeHash', 'inventoryScopeHash'] as const) if (value[key] !== null && (typeof value[key] !== 'string' || value[key]!.length > 512)) throw new Error('经营总览版本范围无效')
|
||||
for (const key of ['queryAsOf', 'queryExpiresAt'] as const) if (typeof value[key] !== 'string' || !Number.isFinite(Date.parse(value[key]))) throw new Error('经营总览查询时间无效')
|
||||
if (validateCustomRange(value.anchorDate, value.anchorDate) || Date.parse(value.queryExpiresAt) <= Date.parse(value.queryAsOf)) throw new Error('经营总览查询日期无效')
|
||||
for (const key of ['inventoryFreshUntil', 'inventoryDrilldownExpiresAt'] as const) if (value[key] != null && (typeof value[key] !== 'string' || !Number.isFinite(Date.parse(value[key]!)))) throw new Error('经营总览库存时效无效')
|
||||
return value
|
||||
}
|
||||
export function parseOverviewReturn(raw: string): OverviewReturn {
|
||||
let value: OverviewReturn
|
||||
try { value = JSON.parse(raw) as OverviewReturn } catch { throw new Error('经营总览返回条件无效') }
|
||||
if (!value || !rangeOptions.some(option => option.value === value.rangePreset) || !Number.isFinite(value.scroll) || value.scroll < 0 || value.scroll > 1000000) throw new Error('经营总览返回条件无效')
|
||||
if (value.rangePreset === 'CUSTOM' && validateCustomRange(value.startDate ?? '', value.endDate ?? '')) throw new Error('经营总览返回日期无效')
|
||||
return value
|
||||
}
|
||||
export function parseDashboardDrilldown(query: LocationQuery, mode: 'sales' | 'inventory'): DashboardDrilldown | null {
|
||||
if (query.from === undefined) {
|
||||
if (['context', 'coverageBucket', 'coverageSelection', 'bucketRuleVersion', 'drilldownFrom', 'analysis', 'customerCode'].some(key => query[key] !== undefined)) throw new Error('经营总览来源参数缺失')
|
||||
return null
|
||||
}
|
||||
if (scalar(query, 'from') !== 'overview') throw new Error('未知的经营分析来源')
|
||||
const warehouse = scalar(query, 'warehouseCode', true)
|
||||
if (warehouse.length > 128) throw new Error('仓库编码无效')
|
||||
const context = parseContext(scalar(query, 'context', true))
|
||||
const returnTo = parseOverviewReturn(scalar(query, 'overviewReturn', true))
|
||||
const customerCode = scalar(query, 'customerCode') || undefined
|
||||
const sku = scalar(query, 'sku')
|
||||
if ((customerCode && customerCode.length > 128) || sku.length > 512 || (customerCode && !sku)) throw new Error('SKU/货主身份参数无效')
|
||||
const result: DashboardDrilldown = { context, customerCode, analysis: 'summary', rangePreset: 'TODAY', startDate: '', endDate: '', inventory: {}, returnTo, summary: '来自经营总览' }
|
||||
if (mode === 'sales') {
|
||||
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'
|
||||
} else if (analysis === 'SKU') {
|
||||
if (query.windowDays !== undefined) throw new Error('SKU日期下钻不能携带榜单窗口')
|
||||
const range = scalar(query, 'rangePreset', true)
|
||||
if (!rangeOptions.some(option => option.value === range)) throw new Error('销量日期枚举无效')
|
||||
result.rangePreset = range as RangePreset
|
||||
if (range === 'CUSTOM') {
|
||||
result.startDate = scalar(query, 'startDate', true); result.endDate = scalar(query, 'endDate', true)
|
||||
if (validateCustomRange(result.startDate, result.endDate, context.anchorDate)) throw new Error('销量下钻日期无效')
|
||||
} else if (query.startDate !== undefined || query.endDate !== undefined) throw new Error('非自定义范围不能携带起止日期')
|
||||
result.summary += ` · ${result.startDate || range} ${result.endDate}`
|
||||
} else throw new Error('销量类别枚举无效')
|
||||
} else {
|
||||
if (query.drilldownFrom !== undefined && scalar(query, 'drilldownFrom') !== 'DASHBOARD') throw new Error('库存下钻来源枚举无效')
|
||||
if (['analysis', 'rangePreset', 'startDate', 'endDate', 'windowDays'].some(key => query[key] !== undefined)) throw new Error('库存下钻不能携带销量日期条件')
|
||||
const bucket = scalar(query, 'coverageBucket')
|
||||
const rule = scalar(query, 'bucketRuleVersion')
|
||||
const selection = scalar(query, 'coverageSelection')
|
||||
const attention = scalar(query, 'attention') || 'ALL'
|
||||
const sort = scalar(query, 'sortBy') || 'AVAILABLE_DESC'
|
||||
if (!['ALL', 'NO_OUTBOUND_30D'].includes(attention) || !['AVAILABLE_DESC', 'COVERAGE_DAYS_DESC', 'PHYSICAL_DESC', 'OUTBOUND_30D_ASC'].includes(sort)) throw new Error('库存下钻枚举无效')
|
||||
if ((bucket && (!buckets.some(item => item.code === bucket) || rule !== 'COVERAGE_DISTRIBUTION_V1' || selection || attention !== 'ALL')) || (!bucket && rule) || (selection && (selection !== 'FINITE_POSITIVE' || attention !== 'ALL'))) throw new Error('库存分组条件无效或互斥')
|
||||
result.inventory = { attention: attention as 'ALL' | 'NO_OUTBOUND_30D', sortBy: sort as BusinessQuery['sortBy'], coverageBucket: bucket ? bucket as CoverageBucket : undefined, bucketRuleVersion: bucket ? 'COVERAGE_DISTRIBUTION_V1' : undefined, coverageSelection: selection ? 'FINITE_POSITIVE' : undefined, drilldownFrom: 'DASHBOARD' }
|
||||
result.summary += ` · ${buckets.find(item => item.code === bucket)?.label ?? (selection ? '有限正可售天数,从高到低' : attention === 'NO_OUTBOUND_30D' ? '近30日无出库,实物量从高到低' : '库存')}`
|
||||
}
|
||||
if (sku) result.summary += ` · ${customerCode ? `${customerCode} / ` : ''}${sku}`
|
||||
return result
|
||||
}
|
||||
export function expired(context: QueryContext, now = Date.now()) { return now >= Date.parse(context.queryExpiresAt) }
|
||||
export function inventoryExpired(context: QueryContext, now = Date.now()) { return expired(context, now) || !context.inventoryDrilldownExpiresAt || now >= Date.parse(context.inventoryDrilldownExpiresAt) }
|
||||
export function previousDate(exclusive: string) { return new Date(Date.parse(`${exclusive}T00:00:00Z`) - 86400000).toISOString().slice(0, 10) }
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
|
||||
| --- | --- | --- | --- |
|
||||
| /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/sales | 仓库、SKU、日期/榜单查询及分页 | 当前截图:货主控件分散筛选;本轮冻结为单一紧凑筛选区,仓库明确提示必须选择,SKU为主 | 复用 AdminPage、QueryToolbar、DataTable、高密度样式;日期/榜单切换和分页保持版本;待人工截图确认 |
|
||||
| /self-operated/sales | SKU 趋势 | 用户最新截图明确要求详情不再放列表下方;使用1120px居中AppDialog,保留整个日轴、未知断线及跨页SKU入口 | 可关闭/全屏;关闭取消请求、防止迟到回写;待人工截图确认 |
|
||||
| /self-operated/sales | SKU销量、热销、滞销切换 | 本轮并入统一筛选区的销量类别选择;独立完整日窗口 | 热销 7/30 日,滞销 30 日;待人工截图确认 |
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OverviewView from './OverviewView.vue'
|
||||
import DashboardBars from '../components/DashboardBars.vue'
|
||||
import DashboardTrend from '../components/DashboardTrend.vue'
|
||||
import { dashboardFixture } from './dashboard.fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ dashboard: vi.fn(), overview: vi.fn(), daily: vi.fn(), push: vi.fn(), query: {} as Record<string, string>, permitted: true }))
|
||||
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }), useRouter: () => ({ push: mocks.push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.permitted }) }))
|
||||
const passthrough = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
|
||||
const query = defineComponent({ props: ['modelValue'], emits: ['update:modelValue', 'search'], setup(_, { emit }) { return () => h('button', { onClick: () => emit('search') }, '查询') } })
|
||||
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
|
||||
const dialog = defineComponent({ props: ['modelValue', 'width'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', [h('button', { onClick: () => undefined }, '趋势弹框'), slots.default?.()]) : null } })
|
||||
const error = defineComponent({ props: ['message'], setup(props) { return () => h('p', props.message) } })
|
||||
const options = { global: { stubs: { AdminPage: passthrough, QueryToolbar: query, AppDialog: dialog, LoadErrorState: error, ApiErrorMeta: true, SkuTrend: true, 'el-button': button } } }
|
||||
const wrappers: ReturnType<typeof mount>[] = []
|
||||
function page() { const wrapper = mount(OverviewView, options); wrappers.push(wrapper); return wrapper }
|
||||
function findButton(wrapper: ReturnType<typeof mount>, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||||
async function choose(wrapper: ReturnType<typeof mount>, values: Record<string, string>) { const toolbar = wrapper.findComponent(query); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); mocks.query = {}; mocks.permitted = true
|
||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
|
||||
mocks.overview.mockResolvedValue({ warehouses: [], observedAt: '2026-10-13T00:00:00Z' })
|
||||
mocks.dashboard.mockResolvedValue(dashboardFixture)
|
||||
mocks.daily.mockResolvedValue({ context: dashboardFixture.context, summary: { ...dashboardFixture.salesTrend.summary, customerCode: 'OWNER-A', sku: 'SKU-X' }, points: dashboardFixture.salesTrend.points })
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
|
||||
describe('warehouse operating dashboard', () => {
|
||||
it('restores the saved warehouse, range, fixed context and scroll when returning from a drilldown', async () => {
|
||||
const scroll = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
|
||||
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'THIS_MONTH', scroll: 760 }) }
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'THIS_MONTH', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
|
||||
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'THIS_MONTH' })
|
||||
expect(scroll).toHaveBeenCalledWith({ top: 760 })
|
||||
})
|
||||
it('does not silently refresh an expired return context', async () => {
|
||||
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify({ ...dashboardFixture.context, queryExpiresAt: '2026-10-13T00:01:00Z' }), overviewReturn: JSON.stringify({ rangePreset: 'TODAY', scroll: 0 }) }
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }); vi.setSystemTime(new Date('2026-10-13T00:02:00Z'))
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(mocks.dashboard).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('原图查询已过期')
|
||||
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).not.toHaveProperty('queryAsOf')
|
||||
})
|
||||
it('keeps sales available when inventory is unavailable, with no fabricated distribution', async () => {
|
||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, inventoryDrilldownExpiresAt: null }, coverageDistribution: { ...dashboardFixture.coverageDistribution, candidateSkuCount: null, buckets: [], dataStatus: 'UNKNOWN', reasonCodes: ['INVENTORY_UNAVAILABLE'] } })
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('无可用库存分布资料')
|
||||
expect(wrapper.text()).toContain('库存资料不可用')
|
||||
expect(wrapper.findComponent(DashboardTrend).exists()).toBe(true)
|
||||
expect(findButton(wrapper, '查看Top100').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
it('keeps inventory present without published sales and never derives a daily average from partial sales', async () => {
|
||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, salesRunId: null, salesScopeHash: null }, kpis: { ...dashboardFixture.kpis, averageDailyOutboundQuantity30d: { ...dashboardFixture.kpis.averageDailyOutboundQuantity30d, value: null, knownSubtotal: null, dataStatus: 'UNKNOWN', reasonCodes: ['SALES_UNAVAILABLE'] }, physicalQuantity: { ...dashboardFixture.kpis.physicalQuantity, value: '128', dataStatus: 'COMPLETE' } }, salesTrend: { ...dashboardFixture.salesTrend, points: [] } })
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findAll('.dashboard-kpis strong')[1]!.text()).toBe('—')
|
||||
expect(wrapper.findAll('.dashboard-kpis strong')[2]!.text()).toBe('128')
|
||||
expect(wrapper.text()).toContain('销量资料不可用')
|
||||
expect(wrapper.findAllComponents(DashboardBars)[0]!.props('rows')).toHaveLength(7)
|
||||
})
|
||||
it('does not request business data without a selected warehouse and renders six metrics/five charts from one snapshot', async () => {
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(mocks.dashboard).not.toHaveBeenCalled()
|
||||
await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
|
||||
expect(wrapper.findAllComponents(DashboardBars)).toHaveLength(4)
|
||||
expect(wrapper.findAllComponents(DashboardTrend)).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('区间已采集出库量')
|
||||
expect(wrapper.text()).toContain('已知小计 128')
|
||||
expect(wrapper.text()).toContain('另 1 SKU 待核验')
|
||||
expect(wrapper.text()).not.toContain('lingxing-primary')
|
||||
})
|
||||
it('changes only trend range while pinning versions, then explicitly refreshes the whole snapshot', async () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
await choose(wrapper, { rangePreset: 'TODAY' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'TODAY', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
|
||||
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('salesRunId')
|
||||
})
|
||||
it('clears the old warehouse and ignores its late response', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
mocks.dashboard.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
await choose(wrapper, { warehouseCode: 'WH-B' })
|
||||
expect(wrapper.find('.dashboard-kpis').exists()).toBe(false)
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
finish({ ...dashboardFixture, kpis: { ...dashboardFixture.kpis, physicalQuantity: { ...dashboardFixture.kpis.physicalQuantity, value: '999999' } } }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('999999')
|
||||
expect(mocks.dashboard.mock.calls[0]?.[1].aborted).toBe(true)
|
||||
})
|
||||
it('preserves same-SKU owner identity in the 1120 dialog and cancels closed requests', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
wrapper.findAllComponents(DashboardBars)[1]!.vm.$emit('select', '1'); await flushPromises()
|
||||
expect(mocks.daily.mock.calls[0]?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-X', rangePreset: 'CUSTOM', startDate: '2026-09-13', endDate: '2026-10-12', salesRunId: '11' })
|
||||
expect(wrapper.findComponent(dialog).props('width')).toBe('1120px')
|
||||
wrapper.findComponent(dialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
finish({ context: dashboardFixture.context, summary: { ...dashboardFixture.salesTrend.summary, sku: 'LATE-SKU' }, points: [] }); await flushPromises()
|
||||
expect(mocks.daily.mock.calls[0]?.[1].aborted).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('LATE-SKU')
|
||||
})
|
||||
it('emits exact bucket/day/ranking navigation and keeps unknown day navigation disabled', async () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
wrapper.findAllComponents(DashboardBars)[0]!.vm.$emit('select', 'DAYS_GT_90'); await flushPromises()
|
||||
expect(mocks.push.mock.calls.at(-1)?.[0]).toMatchObject({ path: '/self-operated/inventory', query: { coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', from: 'overview' } })
|
||||
const chart = wrapper.findComponent(DashboardTrend)
|
||||
await chart.findAll('g')[1]!.trigger('keydown', { key: 'Enter' })
|
||||
expect(mocks.push).toHaveBeenCalledTimes(1)
|
||||
expect(chart.text()).toContain('销量未覆盖')
|
||||
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(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 () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }); vi.setSystemTime(new Date('2026-10-13T00:00:00Z'))
|
||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, inventoryDrilldownExpiresAt: '2026-10-13T00:00:01Z' } })
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(wrapper.text()).toContain('原图库存已过期或不可用')
|
||||
expect(wrapper.findAllComponents(DashboardBars)[0]!.props('disabled')).toBe(true)
|
||||
expect(findButton(wrapper, '查看Top100').attributes('disabled')).toBeUndefined()
|
||||
wrapper.findAllComponents(DashboardBars)[2]!.vm.$emit('select', '0'); await flushPromises()
|
||||
expect(mocks.daily).toHaveBeenCalledTimes(1)
|
||||
wrapper.findAllComponents(DashboardBars)[0]!.vm.$emit('select', 'DAYS_GT_90'); await flushPromises()
|
||||
expect(mocks.push).not.toHaveBeenCalled()
|
||||
})
|
||||
it('retains prior figures on failed refresh and does not render unknown quantities as zero', async () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
mocks.dashboard.mockRejectedValue(new Error('offline'))
|
||||
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('展示上次查询')
|
||||
expect(wrapper.find('.dashboard-kpis').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.dashboard-kpis strong')[2]!.text()).toBe('—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
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 { 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'
|
||||
import DashboardTrend from '../components/DashboardTrend.vue'
|
||||
import SkuTrend from '../components/SkuTrend.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
|
||||
const sourceAccount = 'lingxing-primary'
|
||||
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' })
|
||||
const context = ref<QueryContext | null>(null)
|
||||
const data = ref<Dashboard | null>(null)
|
||||
const runtime = ref<Overview | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const errorDetails = ref<ApiErrorDetails | null>(null)
|
||||
const runtimeError = ref('')
|
||||
const routeError = ref('')
|
||||
const newData = ref(false)
|
||||
const now = ref(Date.now())
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let restoreScroll: number | null = null
|
||||
let appliedRange: OverviewReturn = { rangePreset: 'LAST_15_DAYS', scroll: 0 }
|
||||
try {
|
||||
if (route.query.context !== undefined || route.query.overviewReturn !== undefined) {
|
||||
if (typeof route.query.context !== 'string' || typeof route.query.overviewReturn !== 'string' || !filters.value.warehouseCode) throw new Error('经营总览返回参数无效')
|
||||
context.value = parseContext(route.query.context)
|
||||
appliedRange = parseOverviewReturn(route.query.overviewReturn)
|
||||
Object.assign(filters.value, { rangePreset: appliedRange.rangePreset, startDate: appliedRange.startDate ?? '', endDate: appliedRange.endDate ?? '' })
|
||||
restoreScroll = appliedRange.scroll
|
||||
}
|
||||
} catch (cause) { routeError.value = cause instanceof Error ? cause.message : '经营总览返回参数无效' }
|
||||
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
|
||||
const contextExpired = computed(() => Boolean(context.value && expired(context.value, now.value)))
|
||||
const stockExpired = computed(() => Boolean(context.value && inventoryExpired(context.value, now.value)))
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 220, placeholder: '请选择仓库(必选)', 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 },
|
||||
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'startDate', type: 'date' as const, label: '开始日期', width: 160 }, { key: 'endDate', type: 'date' as const, label: '结束日期', width: 160 }] : []),
|
||||
])
|
||||
const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: boolean }[] = [
|
||||
{ key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity30d', label: '近30日日均出库' },
|
||||
{ key: 'physicalQuantity', label: '实物库存', inventory: true }, { key: 'availableQuantity', label: '正品可售', inventory: true },
|
||||
{ key: 'physicalSkuCount', label: '有库存SKU', inventory: true }, { key: 'noOutboundSkuCount30d', label: '近30日无出库SKU', inventory: true },
|
||||
]
|
||||
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 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)} 天` })) }
|
||||
const dataFence = new RequestFence()
|
||||
const runtimeFence = new RequestFence()
|
||||
const trendFence = new RequestFence()
|
||||
const trendOpen = ref(false)
|
||||
const trendLoading = ref(false)
|
||||
const trend = ref<DailyResponse | null>(null)
|
||||
const trendError = ref('')
|
||||
async function load(refresh = false) {
|
||||
if (!permitted.value || !warehouseCode.value || routeError.value) return
|
||||
if (!refresh && contextExpired.value) { error.value = '原图查询已过期,请整体刷新经营总览'; return }
|
||||
const range = String(filters.value.rangePreset) as RangePreset
|
||||
if (!rangeOptions.some(option => option.value === range)) { error.value = '请选择有效的出库趋势范围'; return }
|
||||
const start = String(filters.value.startDate ?? '')
|
||||
const end = String(filters.value.endDate ?? '')
|
||||
if (range === 'CUSTOM') { const problem = validateCustomRange(start, end, context.value?.anchorDate); if (problem) { error.value = problem; return } }
|
||||
const pinned = refresh ? null : context.value
|
||||
const request = dataFence.begin()
|
||||
trendOpen.value = false
|
||||
loading.value = true; error.value = ''; errorDetails.value = null
|
||||
try {
|
||||
const params: DashboardQuery = { sourceAccount, warehouseCode: warehouseCode.value, rangePreset: range, ...contextParams(pinned), ...(range === 'CUSTOM' ? { startDate: start, endDate: end } : {}) }
|
||||
const result = await analyticsApi.dashboard(params, request.signal)
|
||||
if (!request.current()) return
|
||||
if (pinned && !sameContext(pinned, result.context)) { error.value = '查询版本不一致,请整体刷新经营总览'; return }
|
||||
data.value = result; context.value = result.context; appliedRange = { rangePreset: range, startDate: range === 'CUSTOM' ? start : undefined, endDate: range === 'CUSTOM' ? end : undefined, scroll: 0 }; newData.value = false
|
||||
if (restoreScroll !== null) { await nextTick(); window.scrollTo({ top: restoreScroll }); restoreScroll = null }
|
||||
} catch (cause) {
|
||||
if (request.current()) { errorDetails.value = getApiErrorDetails(cause, data.value ? '刷新失败,展示上次查询;请重试' : '经营总览读取失败,请重试'); error.value = `${errorDetails.value.message}${data.value ? ';展示上次查询' : ''}` }
|
||||
} finally { if (request.current()) loading.value = false }
|
||||
}
|
||||
async function readRuntime() {
|
||||
if (!permitted.value) return false
|
||||
const request = runtimeFence.begin()
|
||||
try {
|
||||
const result = await analyticsApi.overview(sourceAccount, undefined, request.signal)
|
||||
if (!request.current()) return false
|
||||
runtime.value = result; runtimeError.value = ''
|
||||
if (context.value && result.warehouses.some(item => item.warehouseCode === warehouseCode.value && item.lastPublishedAt && Date.parse(item.lastPublishedAt) > Date.parse(context.value!.queryAsOf))) newData.value = true
|
||||
} catch { if (request.current()) runtimeError.value = '采集状态更新失败,经营图表仍保留原查询版本。' }
|
||||
return false
|
||||
}
|
||||
const poller = new RuntimePoller(readRuntime, () => runtimeFence.cancel())
|
||||
function visibility() { now.value = Date.now(); poller.setVisible(!document.hidden) }
|
||||
function drill(path: 'sales' | 'inventory', params: Record<string, string>, strictStock = true) {
|
||||
if (!context.value || expired(context.value)) { error.value = '原图查询已过期,请整体刷新经营总览'; return }
|
||||
if (path === 'inventory' && strictStock && inventoryExpired(context.value)) { error.value = '原图库存已过期,请刷新总览;可查看普通库存诊断'; return }
|
||||
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' })
|
||||
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) {
|
||||
if (!context.value || expired(context.value)) { error.value = '原图查询已过期,请整体刷新经营总览'; return }
|
||||
const fixed = context.value
|
||||
if (!fixed.fixedWindowStartDate || !fixed.fixedWindowEndDateExclusive) { error.value = '完整30日窗口尚不可用'; 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)
|
||||
if (!request.current()) return
|
||||
if (!sameContext(fixed, result.context)) { trendError.value = '趋势版本与总览不一致,请刷新总览'; return }
|
||||
trend.value = result
|
||||
} catch (cause) { if (request.current()) trendError.value = getApiErrorDetails(cause, 'SKU趋势读取失败').message }
|
||||
finally { if (request.current()) trendLoading.value = false }
|
||||
}
|
||||
function reset() { filters.value = { warehouseCode: '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' }; routeError.value = '' }
|
||||
watch(warehouseCode, () => { dataFence.cancel(); trendFence.cancel(); data.value = null; context.value = null; trendOpen.value = false; loading.value = false; error.value = ''; newData.value = false; routeError.value = ''; restoreScroll = null })
|
||||
watch(trendOpen, open => { if (!open) { trendFence.cancel(); trend.value = null; trendLoading.value = false } })
|
||||
onMounted(() => { visibility(); poller.start(); timer = setInterval(() => { now.value = Date.now() }, 1000); document.addEventListener('visibilitychange', visibility); if (context.value && !routeError.value) void load() })
|
||||
onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clearInterval(timer); document.removeEventListener('visibilitychange', visibility) })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density dashboard-page">
|
||||
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
|
||||
<template v-else>
|
||||
<div class="density-actions dashboard-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: appliedRange.rangePreset, ...(appliedRange.startDate ? { startDate: appliedRange.startDate, endDate: appliedRange.endDate! } : {}) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" ariaLabel="经营总览筛选" @search="load()" @reset="reset" />
|
||||
<div class="density-actions"><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button><span>趋势范围只影响区间出库与日趋势,其余指标使用近30个完整日。</span></div>
|
||||
<p v-if="!warehouseCode">请先选择一个仓库并查询经营数据。</p>
|
||||
<p v-if="routeError" role="alert">{{ routeError }}。请返回正常入口重新选择仓库。</p>
|
||||
<LoadErrorState v-if="error" title="经营总览查询未完成" :message="error" compact :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
|
||||
<p v-if="runtimeError" role="status">{{ runtimeError }}</p>
|
||||
<p v-if="newData" role="status">有新数据,可整体刷新;当前图表仍使用原查询版本。</p>
|
||||
<p v-if="contextExpired" role="alert">查询上下文已过期,以下图表为历史结果,请整体刷新。</p>
|
||||
<p v-else-if="stockExpired && data" role="status">原图库存已过期或不可用,库存指标为历史/待刷新资料,严格库存下钻已停用;出库趋势与热销仍可查看。</p>
|
||||
<div v-if="loading && !data" class="dashboard-placeholder" role="status" aria-busy="true">正在读取六项指标、日趋势、库存分布和三个榜单…</div>
|
||||
<template v-if="data">
|
||||
<p>销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p>
|
||||
<div class="dashboard-kpis" aria-label="本仓经营指标">
|
||||
<section v-for="metric in metricNames" :key="metric.key"><h3>{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3><strong>{{ quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity30d' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span><p>{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p><p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'">已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}</p><p v-if="data.kpis[metric.key].unknownSkuCount != null">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small>{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
|
||||
</div>
|
||||
<div class="dashboard-main-charts">
|
||||
<section class="dashboard-chart"><h3>出库趋势</h3><p>{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)</p><DashboardTrend :points="data.salesTrend.points" :disabled="contextExpired" @select="date => drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: date, endDate: date })" /></section>
|
||||
<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 :rows="rankingRows(data[ranking.key].items)" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!)" /></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>
|
||||
</template>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.dashboard-page { display: grid; gap: var(--admin-space-3); }
|
||||
.dashboard-actions { justify-content: flex-end; }
|
||||
.dashboard-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); padding: var(--admin-space-3); border-block: 1px solid var(--el-border-color); gap: var(--admin-space-3); }
|
||||
.dashboard-kpis h3, .dashboard-chart h3 { margin: 0; font-size: var(--admin-font-section); }
|
||||
.dashboard-kpis strong { font-size: var(--admin-font-title); }
|
||||
.dashboard-kpis p { margin: var(--admin-space-1) 0; }
|
||||
.dashboard-main-charts { display: grid; grid-template-columns: 2fr 1fr; gap: var(--admin-space-4); }
|
||||
.dashboard-rankings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.dashboard-chart { min-width: 0; padding: var(--admin-space-3); background: var(--admin-surface); border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); }
|
||||
.dashboard-ranking-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-2); }
|
||||
.dashboard-placeholder { min-height: 320px; padding: var(--admin-space-4); background: var(--el-fill-color-light); }
|
||||
small { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
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' }
|
||||
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 },
|
||||
kpis: { intervalOutboundQuantity: { ...kpi, value: null, outboundQuantity: null, dataStatus: 'PARTIAL' }, averageDailyOutboundQuantity30d: kpi, physicalQuantity: { ...kpi, value: null, knownSubtotal: '128', dataStatus: 'PARTIAL', unknownSkuCount: '1' }, availableQuantity: kpi, physicalSkuCount: kpi, noOutboundSkuCount30d: kpi },
|
||||
salesTrend: { summary: { sourceAccount: 'lingxing-primary', customerCode: '', warehouseCode: 'WH-A', sku: '', productName: null, quantityUnit: 'PCS', outboundQuantity: null, observedQuantity: '8', dataStatus: 'PARTIAL', reasonCodes: [], completeDayCount: 2, partialDayCount: 1, unknownDayCount: 1 }, points: [
|
||||
{ businessDate: '2026-10-10', outboundQuantity: '2', observedQuantity: '2', dataStatus: 'COMPLETE', reasonCodes: [], quantityUnit: 'PCS' },
|
||||
{ businessDate: '2026-10-11', outboundQuantity: null, observedQuantity: null, dataStatus: 'UNKNOWN', reasonCodes: ['SALES_NOT_COVERED'], quantityUnit: 'PCS' },
|
||||
{ businessDate: '2026-10-12', outboundQuantity: '5', observedQuantity: '5', dataStatus: 'COMPLETE', reasonCodes: [], quantityUnit: 'PCS' },
|
||||
{ businessDate: '2026-10-13', outboundQuantity: null, observedQuantity: '1', dataStatus: 'PARTIAL', reasonCodes: [], quantityUnit: 'PCS' },
|
||||
] },
|
||||
coverageDistribution: { bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', candidateSkuCount: '5', excludedZeroPhysicalSkuCount: '1', buckets: [{ bucketCode: 'DAYS_GT_90', skuCount: '1', knownPhysicalQuantity: '3000', unknownPhysicalSkuCount: '0' }], dataStatus: 'PARTIAL', reasonCodes: [], unknownReasons: { INVENTORY_UNKNOWN: '1' } },
|
||||
hotSkus: ranking, longCoverageSkus: { ...ranking, items: [{ ...dashboardItem, value: '300', unit: 'DAYS' }] }, noOutboundSkus: { ...ranking, items: [] },
|
||||
}
|
||||
@@ -50,6 +50,61 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
|
||||
describe('self-operated actual workbench request state', () => {
|
||||
it.each([
|
||||
{ coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' },
|
||||
{ attention: 'NO_OUTBOUND_30D', sortBy: 'PHYSICAL_DESC' },
|
||||
])('sends the complete overview ranking candidate predicate before server pagination: %j', conditions => {
|
||||
const pinned = { ...context, queryExpiresAt: '2099-10-13T00:30:00Z', inventoryDrilldownExpiresAt: '2099-10-13T00:20:00Z' }
|
||||
mocks.routeQuery = Object.fromEntries(Object.entries({ warehouseCode: 'WH-A', from: 'overview', context: JSON.stringify(pinned), overviewReturn: JSON.stringify({ rangePreset: 'TODAY', scroll: 0 }), ...conditions }).filter(([, value]) => value !== undefined)) as Record<string, string>
|
||||
mocks.api.inventory.mockResolvedValue({ context: pinned, records: [], total: 0 })
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
|
||||
expect(mocks.api.inventory.mock.calls[0]?.[0]).toMatchObject({ ...conditions, drilldownFrom: 'DASHBOARD', current: 1, size: 20 })
|
||||
})
|
||||
it('initializes exact dashboard bucket and owner/SKU filters, clears them without changing versions, and returns to the saved overview', async () => {
|
||||
const pinned = { ...context, queryExpiresAt: '2099-10-13T00:30:00Z', inventoryDrilldownExpiresAt: '2099-10-13T00:20:00Z' }
|
||||
const back = { rangePreset: 'THIS_MONTH', scroll: 550 }
|
||||
mocks.routeQuery = { warehouseCode: 'WH-A', from: 'overview', context: JSON.stringify(pinned), overviewReturn: JSON.stringify(back), coverageBucket: 'DAYS_30_60', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', customerCode: 'OWNER-B', sku: 'SKU-X' }
|
||||
mocks.api.inventory.mockResolvedValue({ context: pinned, records: [], total: 0 })
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-X', coverageBucket: 'DAYS_30_60', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', drilldownFrom: 'DASHBOARD', salesRunId: '11', queryAsOf: context.queryAsOf })
|
||||
expect(wrapper.text()).toContain('来自经营总览')
|
||||
await findButton(wrapper, '清除来源条件').trigger('click'); await flushPromises()
|
||||
const cleared = mocks.api.inventory.mock.calls.at(-1)?.[0]
|
||||
expect(cleared).toMatchObject({ salesRunId: '11', inventoryRunId: '0', queryAsOf: context.queryAsOf, attention: 'ALL' })
|
||||
expect(cleared).not.toHaveProperty('coverageBucket')
|
||||
expect(cleared).not.toHaveProperty('customerCode')
|
||||
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('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.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]).not.toHaveProperty('analysis')
|
||||
mocks.routeQuery = { ...mocks.routeQuery, analysis: 'SKU', rangePreset: 'CUSTOM', startDate: '2026-10-11', endDate: '2026-10-11' }
|
||||
delete mocks.routeQuery.windowDays
|
||||
mocks.api.summary.mockResolvedValue({ context: pinned, records: [], total: 0 })
|
||||
const dayPage = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(dayPage)
|
||||
await flushPromises()
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-11', endDate: '2026-10-11', queryAsOf: context.queryAsOf })
|
||||
})
|
||||
it('rejects malformed overview routes and expired original inventory instead of widening the query', async () => {
|
||||
mocks.routeQuery = { warehouseCode: 'WH-A', from: 'overview', context: '{}', overviewReturn: '{}' }
|
||||
const invalid = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(invalid)
|
||||
await flushPromises()
|
||||
expect(mocks.api.inventory).not.toHaveBeenCalled()
|
||||
expect(invalid.text()).toContain('已停止查询')
|
||||
mocks.routeQuery = { warehouseCode: 'WH-A', from: 'overview', context: JSON.stringify({ ...context, queryExpiresAt: '2099-10-13T00:30:00Z', inventoryDrilldownExpiresAt: '2020-01-01T00:00:00Z' }), overviewReturn: JSON.stringify({ rangePreset: 'TODAY', scroll: 0 }), coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' }
|
||||
const stale = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(stale)
|
||||
await flushPromises()
|
||||
expect(mocks.api.inventory).not.toHaveBeenCalled()
|
||||
expect(stale.text()).toContain('原图库存已过期,请刷新总览')
|
||||
})
|
||||
it('shows the pinned warehouse inventory collection interval as one full pagination observation', async () => {
|
||||
mocks.api.inventory.mockResolvedValue({ context: { ...context, inventoryObservedFrom: '2026-10-12T00:00:00Z', inventoryObservedUntil: '2026-10-12T00:12:00Z' }, records: [metric], total: 1 })
|
||||
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-12T02:00:00Z', inventoryObservedFrom: '2026-10-12T01:30:00Z', inventoryObservedUntil: '2026-10-12T01:45:00Z' } as WarehouseOverview] })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const selfOperatedRoutes = [
|
||||
{ path: 'self-operated/overview', name: 'self-operated-overview', component: () => import('./pages/OverviewView.vue'), meta: { title: '经营总览', permission: 'platform:self-operated-analytics:list', keepAlive: false } },
|
||||
{ path: 'self-operated/sales', name: 'self-operated-sales', component: () => import('./pages/SalesView.vue'), meta: { title: '销量分析', permission: 'platform:self-operated-analytics:list' } },
|
||||
{ path: 'self-operated/inventory', name: 'self-operated-inventory', component: () => import('./pages/InventoryView.vue'), meta: { title: '库存分析', permission: 'platform:self-operated-analytics:list' } },
|
||||
{ path: 'self-operated/collection', name: 'self-operated-collection', component: () => import('./pages/CollectionView.vue'), meta: { title: '数据采集', permission: 'platform:self-operated-analytics:list' } },
|
||||
|
||||
@@ -65,6 +65,9 @@ export const statusLabels: Record<string, string> = {
|
||||
INVENTORY_STALE: '库存过期', INVENTORY_UNKNOWN: '库存未知',
|
||||
}
|
||||
export const reasonLabels: Record<string, string> = {
|
||||
UNASSESSABLE_SKUS: '存在待评估SKU', INVENTORY_UNAVAILABLE: '库存资料不可用', SALES_UNAVAILABLE: '销量资料不可用',
|
||||
PHYSICAL_INVENTORY_UNKNOWN: '实物库存待核验', AVAILABLE_INVENTORY_UNKNOWN: '可售库存待核验',
|
||||
SALES_QUANTITY_OR_UNIT_INVALID: '出库数量或单位异常', NO_ELIGIBLE_SKUS: '暂无符合条件的SKU',
|
||||
NO_OUTBOUND_30D: '近30天无出库', COVERAGE_GT_THRESHOLD: '可售天数超过本次阈值',
|
||||
FROZEN_STOCK: '存在冻结库存', DEFECTIVE_STOCK: '存在次品实物库存', UNASSESSABLE: '数据不足/待观察',
|
||||
THRESHOLD_NOT_SET: '未设置查询阈值', NO_PHYSICAL_INVENTORY: '无实物库存',
|
||||
|
||||
@@ -2,6 +2,7 @@ export type Quantity = string | number | null
|
||||
export type RangePreset = 'TODAY' | 'LAST_3_DAYS' | 'THIS_WEEK' | 'LAST_15_DAYS' | 'THIS_MONTH' | 'CUSTOM'
|
||||
export type InventoryAttention = 'ALL' | 'NO_OUTBOUND_30D' | 'COVERAGE_GT_THRESHOLD' | 'FROZEN_STOCK' | 'DEFECTIVE_STOCK' | 'UNASSESSABLE'
|
||||
export type InventorySort = 'AVAILABLE_DESC' | 'COVERAGE_DAYS_DESC' | 'PHYSICAL_DESC' | 'OUTBOUND_30D_ASC'
|
||||
export type CoverageBucket = 'UNKNOWN' | 'NO_AVAILABLE' | 'NO_OUTBOUND_30D' | 'DAYS_0_30' | 'DAYS_30_60' | 'DAYS_60_90' | 'DAYS_GT_90'
|
||||
export interface QueryContext {
|
||||
salesRunId: string | null
|
||||
inventoryRunId: string | null
|
||||
@@ -21,6 +22,11 @@ export interface QueryContext {
|
||||
completeDayFrom?: string | null
|
||||
completeDayUntil?: string | null
|
||||
calculationWindowDays?: number | null
|
||||
inventoryFreshUntil?: string | null
|
||||
inventoryDrilldownExpiresAt?: string | null
|
||||
fixedWindowStartDate?: string
|
||||
fixedWindowEndDateExclusive?: string
|
||||
fixedWindowDays?: number
|
||||
salesStatus?: string
|
||||
inventoryStatus?: string
|
||||
rebuildRequired?: boolean
|
||||
@@ -28,6 +34,20 @@ export interface QueryContext {
|
||||
reasonCodes?: string[]
|
||||
}
|
||||
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 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 {
|
||||
context: QueryContext
|
||||
kpis: Record<'intervalOutboundQuantity' | 'averageDailyOutboundQuantity30d' | 'physicalQuantity' | 'availableQuantity' | 'physicalSkuCount' | 'noOutboundSkuCount30d', DashboardKpi>
|
||||
salesTrend: { summary: SalesSummary; points: DailyPoint[] }
|
||||
coverageDistribution: { bucketRuleVersion: string; candidateSkuCount: Quantity; excludedZeroPhysicalSkuCount: Quantity; buckets: DashboardBucket[]; dataStatus: string; reasonCodes: string[]; unknownReasons: Record<string, Quantity> }
|
||||
hotSkus: DashboardRanking
|
||||
longCoverageSkus: DashboardRanking
|
||||
noOutboundSkus: DashboardRanking
|
||||
}
|
||||
export interface BusinessQuery extends Scope {
|
||||
customerCode?: string
|
||||
sku?: string
|
||||
@@ -40,6 +60,10 @@ export interface BusinessQuery extends Scope {
|
||||
attention?: InventoryAttention
|
||||
sortBy?: InventorySort
|
||||
coverageDaysThreshold?: number
|
||||
coverageBucket?: CoverageBucket
|
||||
bucketRuleVersion?: 'COVERAGE_DISTRIBUTION_V1'
|
||||
coverageSelection?: 'FINITE_POSITIVE'
|
||||
drilldownFrom?: 'DASHBOARD'
|
||||
salesRunId?: string | null
|
||||
inventoryRunId?: string | null
|
||||
salesScopeHash?: string | null
|
||||
|
||||
@@ -91,7 +91,7 @@ const notificationConsumers = [
|
||||
]
|
||||
const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue']
|
||||
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue', 'self-operated/pages/CollectionView.vue']
|
||||
const selfOperatedConsumers = [...selfOperatedPages, 'self-operated/components/SkuTrend.vue']
|
||||
const selfOperatedConsumers = [...selfOperatedPages, 'self-operated/components/SkuTrend.vue', 'self-operated/pages/OverviewView.vue']
|
||||
const consumers = [
|
||||
...selfOperatedConsumers,
|
||||
'overseas-business/components/FleetArrangementSummary.vue',
|
||||
@@ -202,6 +202,16 @@ describe('approved Admin density ownership', () => {
|
||||
expect(dashboard).not.toContain('density-fields')
|
||||
})
|
||||
|
||||
it('registers the self-operated dashboard with an explicit density root and chart layout', () => {
|
||||
const source = read('modules/self-operated/pages/OverviewView.vue')
|
||||
expect(source).toContain("@/styles/adminDensity.css")
|
||||
expect(source).toContain('<AdminPage class="admin-density dashboard-page">')
|
||||
expect(source).toContain('class="dashboard-kpis"')
|
||||
expect(source).toContain('class="dashboard-main-charts"')
|
||||
expect(source).not.toContain('density-fields')
|
||||
expect(getNavigationLeaves(adminNavigation).map(node => node.path)).toContain('/self-operated/overview')
|
||||
})
|
||||
|
||||
it('opts application-shell confirmations in even before a business page loads', () => {
|
||||
const entries = ['app/layouts/AppLayout.vue', 'app/components/WorkspaceTabs.vue', 'app/router/index.ts', 'main.ts']
|
||||
let count = 0
|
||||
|
||||
Reference in New Issue
Block a user