refactor(self-operated): focus analytics pages on SKU metrics

This commit is contained in:
2026-10-12 21:36:24 +08:00
parent 533273769d
commit 449d0c4965
4 changed files with 163 additions and 106 deletions
@@ -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<QueryModel>({ 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<QueryModel>(initialFilters())
const applied = ref({ sku: String(filters.value.sku) })
const overview = ref<Overview | null>(null)
const context = ref<QueryContext | null>(null)
const rows = ref<(SalesSummary | Metric)[]>([])
const daily = ref<DailyResponse | null>(null)
const ownerOverview = ref<OwnerOverview | null>(null)
const attention = ref<InventoryAttention>('ALL')
const sortBy = ref<InventorySort>('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<RangePreset>('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<QueryField[]>(() => [
{ 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<QueryField[]>(() => {
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<DataTableColumn[]>(() => [
...(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<DataTableColumn[]>(() => [
] : [{ 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
<AdminPage class="admin-density">
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
<ListPageLayout v-else>
<p>来源账号:{{ sourceAccount }} · 北京时间 Asia/Shanghai · 数量为每个 SKU 的件数 PCS</p>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营分析筛选" :loading="loading" @search="search" @reset="search" />
<OwnerPicker :model-value="String(filters.customerCode ?? '')" :source-account="sourceAccount" :warehouse-code="warehouseCode" :context="context" @update:model-value="filters.customerCode = $event; search()" />
<div v-if="mode === 'inventory'" class="density-actions">
<label>库存关注 <el-select v-model="attention" aria-label="库存关注"><el-option v-for="item in [{ 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="item.value" :value="item.value" :label="item.label" /></el-select></label>
<label>可售天数阈值 <el-input v-model="thresholdInput" aria-label="可售天数阈值" placeholder="可选,正整数天" /></label>
<label>排序 <el-select v-model="sortBy" aria-label="库存排序"><el-option v-for="item in [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, { value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }]" :key="item.value" :value="item.value" :label="item.label" /></el-select></label>
<el-button :disabled="!warehouseCode || loading" @click="search">应用库存关注</el-button>
</div>
<p v-if="!warehouseCode">请先选择仓库。货主默认为全部已授权范围。</p>
<p v-if="runtimeError" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" />
<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>
<div v-if="mode === 'sales'" class="density-tabs" role="tablist" aria-label="销量分析类别">
<button v-for="item in [{ key: 'summary', name: 'SKU销量' }, { key: 'hot', name: '热销Top100' }, { key: 'slow', name: '滞销Top100' }]" :key="item.key" type="button" role="tab" :aria-selected="tab === item.key" :class="{ 'is-active': tab === item.key }" @click="tab = item.key as typeof tab">{{ item.name }}</button>
</div>
<div v-if="summaryMode" class="density-actions">
<el-button v-for="option in [{ key: 'TODAY', label: '今日' }, { key: 'LAST_3_DAYS', label: '近3天' }, { key: 'THIS_WEEK', label: '本周' }, { key: 'LAST_15_DAYS', label: '近15天' }, { key: 'THIS_MONTH', label: '本月' }, { key: 'CUSTOM', label: '自定义' }]" :key="option.key" :type="preset === option.key ? 'primary' : 'default'" @click="preset = option.key as RangePreset">{{ option.label }}</el-button>
<template v-if="preset === 'CUSTOM'"><el-date-picker v-model="customStart" type="date" value-format="YYYY-MM-DD" aria-label="开始日期" /><el-date-picker v-model="customEnd" type="date" value-format="YYYY-MM-DD" aria-label="结束日期" /><el-button @click="search">应用日期</el-button></template>
</div>
<div v-else-if="mode === 'sales' && tab === 'hot'"><label>完整日窗口 <el-select v-model="windowDays" aria-label="热销完整日窗口"><el-option :value="7" label="7个完整自然日" /><el-option :value="30" label="30个完整自然日" /></el-select></label></div>
<p v-else>固定使用截至昨日的 30 个完整自然日窗口。{{ mode === 'inventory' ? '实物含全品质,可售仅正品;冻结及次品不重复计入实物,在途不计当前占仓。日均量及可售天数由服务端计算,阈值仅用于本次查询。' : '滞销观察资格由服务端判断。' }}</p>
<p v-if="context">查询基准:{{ 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 ? '修订待重建' : '' }}</p>
<p v-if="newData" role="status">有新数据,可整体刷新。</p>
<p v-if="newDay" role="status">北京时间已跨日;当前范围仍按原查询基准日,整体刷新后取得新日期和版本。</p>
<p v-if="context && !summaryMode">指标计算窗口:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(结束边界不含);{{ context.calculationWindowDays ?? (tab === 'hot' ? windowDays : 30) }} 天。</p>
<p v-for="state in (overview?.warehouses ?? []).filter(item => item.warehouseCode === warehouseCode)" :key="state.dataFamily">{{ state.dataFamily === 'INVENTORY' ? '库存' : '销量' }}当前时效:{{ label(state.dataFreshness) }};最近发布时间 {{ beijingTime(state.lastPublishedAt) }}。运行状态按最近观测判断。</p>
<OwnerOverviewPanel v-if="mode === 'inventory' && ownerOverview" :data="ownerOverview" />
<p v-else-if="mode === 'inventory'">{{ applied.customerCode ? '当前货主尚无可用经营概览。' : '选择一个具体货主后显示全量经营概览;当前可浏览全仓 SKU 明细。' }}</p>
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" empty-text="所选范围无记录或尚无可用数据">
<template #toolbar-actions><el-button :disabled="!warehouseCode || loading" @click="load(true)">整体刷新</el-button><el-button :disabled="!warehouseCode" @click="link('/self-operated/collection')">查看采集状态</el-button><el-button :disabled="!warehouseCode" @click="link(mode === 'sales' ? '/self-operated/inventory' : '/self-operated/sales')">{{ mode === 'sales' ? '查看库存' : '查看销量' }}</el-button></template>
<template #actions="{ row }"><el-button link @click="showTrend(asSummary(row))">每日趋势</el-button></template>
<template #sku="{ row }"><el-button link :aria-label="`查看 ${asMetric(row).customerCode} ${asMetric(row).sku} 每日趋势`" @click="showTrend(asMetric(row))">{{ asMetric(row).sku }}</el-button></template>
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。</p>
<details class="analytics-rules">
<summary>统计说明 · 北京时间/件</summary>
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
<p v-if="mode === 'inventory'">实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。</p>
<p v-if="context">查询时间:{{ beijingTime(context.queryAsOf) }};有效至 {{ beijingTime(context.queryExpiresAt) }}。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }}。{{ context.rebuildRequired ? '数据修订待重建。' : '' }}</p>
<p v-if="context">实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含)<template v-if="context.calculationWindowDays != null">;{{ context.calculationWindowDays }} 天</template>。</p>
<p v-for="state in (overview?.warehouses ?? []).filter(item => item.warehouseCode === warehouseCode)" :key="state.dataFamily">{{ state.dataFamily === 'INVENTORY' ? '库存' : '销量' }}数据:{{ label(state.dataFreshness) }};最近发布时间 {{ beijingTime(state.lastPublishedAt) }}。</p>
</details>
<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 #toolbar-actions><el-button :disabled="!warehouseCode" @click="link('/self-operated/collection')">查看采集状态</el-button><el-button :disabled="!warehouseCode" @click="link(mode === 'sales' ? '/self-operated/inventory' : '/self-operated/sales')">{{ mode === 'sales' ? '查看库存' : '查看销量' }}</el-button></template>
<template #actions="{ row }"><el-button class="density-action" @click="showTrend(asSummary(row))">每日趋势</el-button></template>
<template #sku="{ row }"><el-button class="density-action" :aria-label="`查看 ${asMetric(row).customerCode} ${asMetric(row).sku} 每日趋势`" @click="showTrend(asMetric(row))">{{ asMetric(row).sku }}</el-button></template>
<template #owner="{ row }"><span>{{ asMetric(row).customerName || asMetric(row).customerCode || '—' }}</span><small v-if="asMetric(row).customerName && asMetric(row).customerCode" class="analytics-owner-code">{{ asMetric(row).customerCode }}</small></template>
<template #unit="{ row }">{{ asMetric(row).quantityUnit === 'PCS' ? '件' : asMetric(row).quantityUnit || '—' }}</template>
<template #lastOutbound="{ row }">{{ asMetric(row).lastObservedOutboundDate ?? '已采集历史内未见出库' }}<br />历史覆盖起点:{{ asMetric(row).observedHistoryStartDate ?? '—' }}</template>
<template #attention="{ row }">{{ reasonsText(asMetric(row).attentionReasons) }}</template>
<template #quantity="{ row }">{{ quantityText(summaryMode ? shownQuantity(asSummary(row)) : asMetric(row).outboundQuantity) }} {{ summaryMode && asSummary(row).dataStatus !== 'COMPLETE' ? '(已采集,未完整)' : '' }}</template>
@@ -216,9 +209,15 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'outboundQuantity30d', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'] as const" :key="key" #[key]="{ row }">{{ quantityText(asMetric(row)[key]) }}<span v-if="asMetric(row).quantityStatuses?.[key] && asMetric(row).quantityStatuses?.[key] !== 'VALID'">({{ label(asMetric(row).quantityStatuses?.[key]) }})</span></template>
</DataTable>
<template v-if="daily && (summaryMode || mode === 'inventory')">
<el-button @click="router.push({ path: mode === 'inventory' ? '/self-operated/sales' : '/self-operated/inventory', query: { warehouseCode, customerCode: daily.summary.customerCode, sku: daily.summary.sku } })">{{ mode === 'inventory' ? '在销量页查看该 SKU' : '在库存页查看该 SKU' }}</el-button>
<el-button @click="router.push({ path: mode === 'inventory' ? '/self-operated/sales' : '/self-operated/inventory', query: { warehouseCode, sku: daily.summary.sku } })">{{ mode === 'inventory' ? '在销量页查看该SKU(全部货主)' : '在库存页查看该SKU(全部货主)' }}</el-button>
<SkuTrend :data="daily" />
</template>
</ListPageLayout>
</AdminPage>
</template>
<style scoped>
.analytics-note, .analytics-rules { margin: 0; font-size: var(--admin-font-caption); }
.analytics-rules summary { cursor: pointer; }
.analytics-rules p { margin: var(--admin-space-2) 0; }
.analytics-owner-code { display: block; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DailyResponse } from '../types'
import { label, quantityText, shownQuantity, trendSegments } from '../state'
import { label, quantityText, shownQuantity, trendSegments, reasonsText } from '../state'
const props = defineProps<{ data: DailyResponse }>()
const segments = computed(() => trendSegments(props.data.points))
const maximum = computed(() => Math.max(1, ...props.data.points.map(point => Number(shownQuantity(point) ?? 0))))
@@ -10,16 +10,17 @@ function y(value: string | number | null) { return 175 - Number(value ?? 0) / ma
</script>
<template>
<section aria-label="SKU每日出库趋势">
<h3>{{ data.summary.customerCode }} · {{ data.summary.sku }} · 每日出库趋势</h3>
<p>{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '该版本已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit ?? '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计,非实时值。</p>
<h3>{{ data.summary.sku }} · 每日出库趋势</h3>
<p>所属货主:{{ data.summary.customerCode || '—' }} · {{ data.summary.productName || '—' }}</p>
<p>{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit === 'PCS' ? '件' : '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计。</p>
<p v-if="!segments.length">所选范围尚无可靠数据,未知日期不作为零销量。</p>
<svg v-else viewBox="0 0 800 220" role="img" aria-label="每日出库件数;虚线点表示未完整,缺失日期断线" class="sku-trend">
<line x1="30" y1="175" x2="770" y2="175" stroke="currentColor" />
<text x="30" y="20">{{ maximum }} PCS</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 data.points" :key="point.businessDate">
<circle v-if="shownQuantity(point) != null && point.dataStatus !== 'UNKNOWN'" :cx="x(index)" :cy="y(shownQuantity(point))" r="4" :fill="point.dataStatus === 'COMPLETE' ? 'var(--el-color-primary)' : 'white'" stroke="var(--el-color-primary)">
<title>{{ point.businessDate }}:{{ quantityText(shownQuantity(point)) }} {{ point.quantityUnit ?? '单位未知' }},{{ label(point.dataStatus) }};覆盖截止 {{ point.coveredUntil ?? '未知' }}</title>
<title>{{ point.businessDate }}:{{ quantityText(shownQuantity(point)) }} {{ point.quantityUnit === 'PCS' ? '件' : '单位未知' }},{{ label(point.dataStatus) }};覆盖截止 {{ point.coveredUntil ?? '未知' }}</title>
</circle>
<text v-if="data.points.length <= 15 || index === 0 || index === data.points.length - 1" :x="x(index)" y="205" text-anchor="middle">{{ point.businessDate.slice(5) }}</text>
</g>
@@ -27,7 +28,7 @@ function y(value: string | number | null) { return 175 - Number(value ?? 0) / ma
<details>
<summary>每日数量、完整性及覆盖证据</summary>
<table class="density-table"><thead><tr><th>北京时间日期</th><th>完整出库量</th><th>已采集量</th><th>状态</th><th>覆盖截止</th><th>缺口原因</th></tr></thead><tbody>
<tr v-for="point in data.points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ point.coveredUntil ?? '—' }}</td><td>{{ point.reasonCodes.join('、') || '—' }}</td></tr>
<tr v-for="point in data.points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ point.coveredUntil ?? '—' }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr>
</tbody></table>
</details>
</section>
@@ -1,19 +1,21 @@
# 自营分析逐操作矩阵
依据:`docs/自营业务/领星一件代发数据统计一期方案.md` 的“Admin 菜单与采集页面”“SKU 销量列表与趋势图”“页面交付和视觉边界”。本模块没有已确认视觉原型。
依据:2026-10-12 用户提供的销量/库存当前页面截图及“主要统计SKU,在看当前SKU是哪个货主就行,货主不重要”的明确纠正,优先于一期方案中的货主经营面板目标;密度沿用 `docs/Admin高密度界面规范.md`。截图仅证明修改前页面,修改后的视觉仍待人工 1920×1080、100% 截图。
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
| --- | --- | --- | --- |
| /self-operated/sales | 仓库、货主、SKU、周期查询及分页 | 已确认主页面;服务端固定版本与北京时间日期 | 复用 AdminPage、QueryToolbar、DataTable、高密度样式;待人工截图确认 |
| /self-operated/sales | SKU 趋势 | 主页面内嵌单 SKU 日趋势,保留整个日轴,未知断线 | 不新建详情路由或浮层;待人工截图确认 |
| /self-operated/sales | 热销、滞销页签 | 主页面页签;独立完整日窗口 | 热销 7/30 日,滞销 30 日;待人工截图确认 |
| /self-operated/inventory | 货主概览、SKU 占仓关注及排序 | 方案“货主概览与库存关注”“Admin 菜单与采集页面”已确认同页联动;概览保持货主全量,SKU/关注/分页仅作用明细 | 复用连续表格,未知与部分小计分开;件数不是空间;待人工截图确认 |
| /self-operated/sales | 仓库、SKU、日期/榜单查询及分页 | 当前截图:货主控件分散筛选;本轮冻结为单一紧凑筛选区,仓库明确提示必须选择,SKU为主 | 复用 AdminPage、QueryToolbar、DataTable、高密度样式;日期/榜单切换和分页保持版本;待人工截图确认 |
| /self-operated/sales | SKU 趋势 | 主页面内嵌单 SKU 日趋势,保留整个日轴,未知断线;跨页明确查看该SKU全部货主,不带隐藏货主筛选 | 不新建详情路由或浮层;待人工截图确认 |
| /self-operated/sales | SKU销量、热销、滞销切换 | 本轮并入统一筛选区的销量类别选择;独立完整日窗口 | 热销 7/30 日,滞销 30 日;待人工截图确认 |
| /self-operated/inventory | SKU 库存关注、阈值及排序 | 当前截图:库存筛选散落且货主优先;本轮冻结为仓库、SKU、关注、阈值、排序同一筛选区;移除货主经营概览 | SKU、商品名称靠前,所属货主为一辅助列;零与未知保持区分;待人工截图确认 |
| /self-operated/inventory | SKU 每日趋势 | 方案“货主概览与库存关注”确认点击 SKU 沿 daily 接口同页下钻 | 点击 SKU 展示内嵌趋势,不新增操作列、菜单或浮层;待人工截图确认 |
| 销量/库存 | 货主选择 | 方案“货主概览与库存关注接口合同”确认本仓固定版本服务器分页选项 | 编码前缀检索、翻页、精确选择;待人工截图确认 |
| 销量/库存 | 所属货主及同SKU趋势 | 删除独立货主选择、货主翻页和经营概览,不请求owners/ownerOverview;所属货主名称优先、编码可辅助 | 同SKU跨货主保持分行,趋势仍传本行customerCode;不改后端统计键 |
| 销量/库存 | 规则说明及数据状态 | 当前截图:来源账号、技术时区和长段规则占主页面;本轮冻结为短说明和原生details折叠 | 主文案仅北京时间/件;未选仓库、暂无数据、查询失败分别显示;不新增浮层 |
| /self-operated/collection | 每日库存观测积累 | 方案“每日库存观测留存”确认采集页显示近30完整日样本、缺口、起点 | 服务端结果直出,不计算周转率;待人工截图确认 |
| /self-operated/collection | 运行概览、逻辑任务列表与分页 | 已确认主页面,执行/发布/覆盖独立展示 | 不提供整体百分比;待人工截图确认 |
| /self-operated/collection | 任务详情和历史尝试 | 用户本轮确认采集主页面内嵌只读详情和历史尝试;总控已冻结 | 不新增弹框、抽屉、独立详情;待人工截图确认 |
| /self-operated/collection | 恢复任务确认 | 用户本轮确认内嵌原因表单+显式确认步骤;RETRY 单一命令 | 不通过前端推断资格;受理不是采集成功;待人工截图确认 |
| 三页 | 新增/编辑/审核/删除/文件预览 | 不在一期页面业务范围 | 不创建操作入口 |
| 三页 | 公共搜索栏样式 | 用户提供异常订单公共搜索栏截图并要求自营统一;QueryToolbar放入集成DataTable的filters插槽 | 复用公共灰底筛选带、控件及查询/重置按钮,启用筛选布局设置;不复制CSS,不改查询和权限语义;待人工截图确认 |
自动测试与人工视觉验收分别记录;无真实截图不宣称视觉通过。
@@ -5,10 +5,10 @@ import AnalyticsView from '../components/AnalyticsView.vue'
import CollectionView from './CollectionView.vue'
import type { Metric, Overview, OwnerOverview, QueryContext, SalesSummary, Task, WarehouseOverview } from '../types'
const mocks = vi.hoisted(() => ({ api: { owners: vi.fn(), ownerOverview: vi.fn(), summary: vi.fn(), daily: vi.fn(), hot: vi.fn(), slow: vi.fn(), inventory: vi.fn(), overview: vi.fn(), tasks: vi.fn(), task: vi.fn(), runs: vi.fn(), retry: vi.fn() }, canSync: true }))
const mocks = vi.hoisted(() => ({ api: { owners: vi.fn(), ownerOverview: vi.fn(), summary: vi.fn(), daily: vi.fn(), hot: vi.fn(), slow: vi.fn(), inventory: vi.fn(), overview: vi.fn(), tasks: vi.fn(), task: vi.fn(), runs: vi.fn(), retry: vi.fn() }, canSync: true, routeQuery: { warehouseCode: 'WH-A' } as Record<string, string>, push: vi.fn() }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks.api }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') || mocks.canSync }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: { warehouseCode: 'WH-A' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.routeQuery }), useRouter: () => ({ push: mocks.push }) }))
const context: QueryContext = { salesRunId: '11', inventoryRunId: '0', salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: '2026-10-12T01:00:00Z', queryExpiresAt: '2026-10-12T01:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12', completeDayFrom: '2026-08-13', completeDayUntil: '2026-10-12', rangeStartDate: '2026-09-12', rangeEndDateExclusive: '2026-10-12' }
const quantity = { overallTotal: null, knownSubtotal: '128', unknownSkuCount: 1, dataStatus: 'PARTIAL', reasonCodes: ['INVENTORY_UNKNOWN'] }
@@ -19,19 +19,26 @@ const summary: SalesSummary = { sourceAccount: 'lingxing-primary', warehouseCode
const task: Task = { taskNo: 'COL-A', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'DEAD', applyState: 'BLOCKED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: ['RETRY'], actionBlockReason: null, progress: null, queryFrom: null, queryUntil: null }
const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'OFF', observedAt: '2026-10-12T01:01:00Z', warehouses: [] }
const passthrough = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const queryStub = defineComponent({ props: ['modelValue'], emits: ['update:modelValue', 'search'], setup(props, { emit }) { return () => h('button', { 'data-test': 'switch-warehouse', onClick: () => { emit('update:modelValue', { ...props.modelValue, warehouseCode: 'WH-B' }); emit('search') } }, '切换仓库') } })
const tableStub = defineComponent({ props: ['data'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.sku?.({ row })]))]) } })
const queryStub = defineComponent({ props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup(props, { emit }) { return () => h('div', [
h('button', { 'data-test': 'switch-warehouse', onClick: () => { emit('update:modelValue', { ...props.modelValue, warehouseCode: 'WH-B' }); emit('search') } }, '切换仓库'),
h('button', { onClick: () => emit('search') }, '查询'), h('button', { onClick: () => emit('reset') }, '重置'),
]) } })
const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), !(props.data ?? []).length ? props.emptyText : null, ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row })]))]) } })
const button = defineComponent({ props: ['disabled', 'nativeType'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, type: props.nativeType || 'button', onClick: () => emit('click') }, slots.default?.()) } })
const input = defineComponent({ props: ['modelValue', 'id'], emits: ['update:modelValue'], setup(props, { emit }) { return () => h('textarea', { id: props.id, value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value) }) } })
const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, SkuTrend: true, OwnerPicker: true, 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } }
const loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } })
const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, SkuTrend: true, OwnerPicker: true, 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } }
const wrappers: ReturnType<typeof mount>[] = []
function findButton(wrapper: ReturnType<typeof mount>, text: string) { const result = wrapper.findAll('button').find(item => item.text() === text); if (!result) throw new Error(`missing button ${text}`); return result }
async function setFilters(wrapper: ReturnType<typeof mount>, values: Record<string, string | number>) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
beforeEach(() => {
vi.clearAllMocks()
mocks.canSync = true
mocks.routeQuery = { warehouseCode: 'WH-A' }
mocks.api.overview.mockResolvedValue(overview)
mocks.api.summary.mockResolvedValue({ context, records: [summary], total: 1 })
mocks.api.hot.mockResolvedValue({ context, records: [], total: 0 })
mocks.api.slow.mockResolvedValue({ context, records: [], total: 0 })
mocks.api.inventory.mockResolvedValue({ context, records: [metric], total: 45 })
mocks.api.ownerOverview.mockResolvedValue(owner)
mocks.api.daily.mockResolvedValue({ context, summary, points: [] })
@@ -42,30 +49,30 @@ beforeEach(() => {
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('self-operated actual workbench request state', () => {
it('keeps owner overview full-scope and fixed-version when inventory attention and pagination change', async () => {
it('uses one SKU filter workspace and no owner query, keeping pagination and attention scoped to the fixed versions', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
wrapper.findComponent({ name: 'OwnerPicker' }).vm.$emit('update:modelValue', 'OWNER-A')
await flushPromises()
expect(mocks.api.ownerOverview.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({ customerCode: 'OWNER-A', salesRunId: '11', inventoryRunId: '0', queryAsOf: context.queryAsOf }))
const selects = wrapper.findAllComponents(passthrough).filter(item => item.attributes('aria-label') === '库存关注')
selects[0]?.vm.$emit('update:modelValue', 'FROZEN_STOCK')
await findButton(wrapper, '应用库存关注').trigger('click'); await flushPromises()
expect(mocks.api.owners).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'OwnerPicker' }).exists()).toBe(false)
const fields = wrapper.findComponent(queryStub).props('fields')
expect(fields.map((field: { key: string }) => field.key)).toEqual(['warehouseCode', 'sku', 'attention', 'threshold', 'sortBy'])
expect(fields[0].placeholder).toBe('请选择仓库(必选)')
expect(wrapper.findComponent(tableStub).props('columns').slice(0, 3).map((column: { prop?: string; key?: string }) => column.prop || column.key)).toEqual(['sku', 'productName', 'owner'])
await setFilters(wrapper, { attention: 'FROZEN_STOCK' })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ current: 2, customerCode: 'OWNER-A', attention: 'FROZEN_STOCK', queryAsOf: context.queryAsOf })
const params = mocks.api.ownerOverview.mock.calls.at(-1)?.[0]
for (const key of ['sku', 'attention', 'current', 'size', 'rangePreset', 'sortBy']) expect(params).not.toHaveProperty(key)
expect(wrapper.text()).toContain('已知有效小计(部分资料)')
expect(wrapper.text()).toContain('未设置查询阈值')
expect(wrapper.text()).toContain('— · 未完整')
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ current: 2, attention: 'FROZEN_STOCK', queryAsOf: context.queryAsOf })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode')
expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('货主经营')
expect(wrapper.find('details').attributes('open')).toBeUndefined()
})
it('rejects missing threshold and drills inventory SKU to fixed 30-day daily data', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
const select = wrapper.findAllComponents(passthrough).find(item => item.attributes('aria-label') === '库存关注')
select?.vm.$emit('update:modelValue', 'COVERAGE_GT_THRESHOLD')
await findButton(wrapper, '应用库存关注').trigger('click'); await flushPromises()
await setFilters(wrapper, { attention: 'COVERAGE_GT_THRESHOLD' })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.api.inventory).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('选择超过阈值关注时必须填写')
await findButton(wrapper, 'SKU-A').trigger('click'); await flushPromises()
@@ -74,6 +81,42 @@ describe('self-operated actual workbench request state', () => {
expect(mocks.api.daily.mock.calls.at(-1)?.[0]).not.toHaveProperty('attention')
expect(wrapper.findComponent({ name: 'SkuTrend' }).exists()).toBe(true)
})
it('keeps identical SKUs in separate owner rows and sends the clicked owner to daily queries', async () => {
const second = { ...metric, customerCode: 'OWNER-B', customerName: '货主乙' }
mocks.api.inventory.mockResolvedValue({ context, records: [metric, second], total: 2 })
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
expect(wrapper.findComponent(tableStub).props('data')).toHaveLength(2)
expect(wrapper.text()).toContain('货主乙')
const skuButtons = wrapper.findAll('button').filter(item => item.text() === 'SKU-A')
await skuButtons[0]?.trigger('click'); await flushPromises()
expect(mocks.api.daily.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-A', sku: 'SKU-A' })
mocks.api.daily.mockResolvedValue({ context, summary: { ...summary, customerCode: 'OWNER-B' }, points: [] })
await skuButtons[1]?.trigger('click'); await flushPromises()
expect(mocks.api.daily.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-A' })
await findButton(wrapper, '在销量页查看该SKU(全部货主)').trigger('click')
expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/sales', query: { warehouseCode: 'WH-A', sku: 'SKU-A' } })
expect(mocks.api.owners).not.toHaveBeenCalled()
expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
})
it('separates unselected warehouse, no results and failure; reset removes warehouse and query criteria', async () => {
mocks.routeQuery = {}
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
expect(mocks.api.inventory).not.toHaveBeenCalled()
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('请选择仓库后查询SKU')
mocks.api.inventory.mockResolvedValue({ context, records: [], total: 0 })
await setFilters(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('暂无符合条件的SKU数据')
mocks.api.inventory.mockRejectedValue(new Error('读取失败'))
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('查询失败,请重试')
expect(wrapper.find('[role="alert"]').text()).toContain('查询失败,请重新查询')
await setFilters(wrapper, { sku: 'SKU-A', attention: 'FROZEN_STOCK', threshold: '10' })
await findButton(wrapper, '重置').trigger('click'); await flushPromises()
expect(wrapper.findComponent(queryStub).props('modelValue')).toMatchObject({ warehouseCode: '', sku: '', attention: 'ALL', threshold: '' })
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('请选择仓库后查询SKU')
})
it('shows actual inventory daily sample gaps without inventing an accumulation date', async () => {
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', protection: {}, inventoryDailySamples: { windowFrom: '2026-09-12', windowUntil: '2026-10-12', expectedDays: 30, sampleCount: 0, missingDates: ['2026-09-12'], accumulationStartedOn: null } } as WarehouseOverview] })
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
@@ -85,9 +128,9 @@ describe('self-operated actual workbench request state', () => {
it('pins versions and date across range and ranking switches, ranking omits chart parameters', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
await flushPromises()
await findButton(wrapper, '本月').trigger('click'); await flushPromises()
await setFilters(wrapper, { rangePreset: 'THIS_MONTH' })
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'THIS_MONTH', queryAsOf: context.queryAsOf, salesRunId: '11', inventoryRunId: '0' })
await findButton(wrapper, '热销Top100').trigger('click'); await flushPromises()
await setFilters(wrapper, { analysis: 'hot' })
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf, windowDays: 30 })
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('rangePreset')
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode', '')
@@ -103,6 +146,18 @@ describe('self-operated actual workbench request state', () => {
expect(wrapper.text()).not.toContain('SKU-A')
expect(mocks.api.summary.mock.calls[0]?.[1].aborted).toBe(true)
})
it('loads rankings with the fixed context when an unfinished CUSTOM date selection exists', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
await flushPromises()
await setFilters(wrapper, { rangePreset: 'CUSTOM' })
expect(mocks.api.summary).toHaveBeenCalledTimes(1)
await setFilters(wrapper, { analysis: 'hot' })
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf, windowDays: 30 })
await setFilters(wrapper, { analysis: 'slow' })
expect(mocks.api.slow.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf })
expect(mocks.api.slow.mock.calls.at(-1)?.[0]).not.toHaveProperty('rangePreset')
expect(wrapper.find('.analytics-rules').text()).toContain('实际计算区间:2026-09-12 至 2026-10-12(截止日不含)')
})
it('failed runtime polling preserves prior available data and leaves fixed context untouched', async () => {
mocks.api.overview.mockRejectedValue(new Error('offline'))
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)