@@ -12,7 +12,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { analyticsApi } from '../api/analytics'
import type { Dashboard , DashboardItem , DailyResponse , DashboardQuery , Overview , QueryContext , RangePreset } from '../types'
import type { Dashboard , DashboardItem , DashboardRanking , DailyResponse , DashboardQuery , Overview , QueryContext , RangePreset } from '../types'
import { beijingTime , contextParams , label , quantityText , reasonsText , RequestFence , RuntimePoller , sameContext , validateCustomRange } from '../state'
import { buckets , expired , inventoryExpired , parseContext , parseOverviewReturn , previousDate , rangeOptions , type OverviewReturn } from '../dashboardNavigation'
import DashboardBars , { type BarRow } from '../components/DashboardBars.vue'
@@ -55,7 +55,7 @@ const contextExpired = computed(() => Boolean(context.value && expired(context.v
const stockExpired = computed ( ( ) => Boolean ( context . value && inventoryExpired ( context . value , now . value ) ) )
const fields = computed < QueryField [ ] > ( ( ) => [
{ key : 'warehouseCode' , type : 'select' , label : '仓库' , width : 220 , placeholder : '切换仓库' , props : { clearable : false } , options : [ ... new Map ( ( runtime . value ? . warehouses ? ? [ ] ) . map ( item => [ item . warehouseCode , { value : item . warehouseCode , label : item . warehouseName || item . warehouseCode } ] ) ) . values ( ) ] } ,
{ key : 'rangePreset' , type : 'select' , label : '出库 趋势范围' , width : 180 , props : { clearable : false } , options : rangeOptions } ,
{ key : 'rangePreset' , type : 'select' , label : '趋势与热销 范围' , width : 180 , props : { clearable : false } , options : rangeOptions } ,
... ( filters . value . rangePreset === 'CUSTOM' ? [ { key : 'dateRange' , type : 'daterange' as const , label : '日期范围' , width : 300 } ] : [ ] ) ,
] )
const metricNames : { key : keyof Dashboard [ 'kpis' ] ; label : string ; inventory ? : boolean } [ ] = [
@@ -65,12 +65,12 @@ const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: bo
]
const metricIcons = [ ArrowUpRight , Activity , Boxes , Package , Layers , Clock3 ]
const metricColors = [ '#5575ed' , '#3aa9ba' , '#8871ce' , '#35a987' , '#c6923e' , '#d67b91' ]
const rankingNames = [ { key : 'hotSkus' , label : '近30日 热销Top10' , unit : '件' , all : '查看Top100' } , { key : 'longCoverageSkus' , label : '预计消化时间最长Top10' , unit : '天' , all : '查看全部' } , { key : 'noOutboundSkus' , label : '近30日无出库库存Top10' , unit : '件' , all : '查看全部' } ] as const
const rankingNames = [ { key : 'hotSkus' , label : '所选时段 热销Top10' , unit : '件' , all : '查看Top100' } , { key : 'longCoverageSkus' , label : '预计消化时间最长Top10' , unit : '天' , all : '查看全部' } , { key : 'noOutboundSkus' , label : '近30日无出库库存Top10' , unit : '件' , all : '查看全部' } ] as const
const bucketRows = computed < BarRow [ ] > ( ( ) => buckets . map ( bucket => {
const item = data . value ? . coverageDistribution . buckets . find ( row => row . bucketCode === bucket . code )
return { key : bucket . code , label : bucket . label , value : item ? . skuCount ? ? null , detail : ` 已知实物小计 ${ quantityText ( item ? . knownPhysicalQuantity ) } 件;实物未知 ${ quantityText ( item ? . unknownPhysicalSkuCount ) } SKU ` }
} ) )
function rankingRows ( items : DashboardItem [ ] ) : BarRow [ ] { return items . map ( ( item , index ) => ( { key : String ( index ) , label : item . sku , secondary : ` ${ item . productName || '—' } · 货主 ${ item . customerCode } ` , value : item . value , detail : ` SKU ${ item . sku } ;货主 ${ item . customerCode } ;实物 ${ quantityText ( item . physicalQuantity ) } 件,可售 ${ quantityText ( item . availableQuantity ) } 件,近30日出库 ${ quantityText ( item . outboundQuantity30d ) } 件,日均 ${ quantityText ( item . averageDailyOutboundQuantity ) } 件,可售 ${ quantityText ( item . inventoryCoverageDays ) } 天 ` } ) ) }
function rankingRows ( ranking : DashboardRanking ) : BarRow [ ] { return ranking . items . map ( ( item , index ) => ( { key : String ( index ) , label : item . sku , secondary : ` ${ item . productName || '—' } · 货主 ${ item . customerCode } ` , value : item . value , detail : ` SKU ${ item . sku } ;货主 ${ item . customerCode } ;实物 ${ quantityText ( item . physicalQuantity ) } 件,可售 ${ quantityText ( item . availableQuantity ) } 件, ${ ranking . calculationWindowDays } 天窗口 ${ ranking . dataStatus === 'COMPLETE' ? '出库' : '已采集出库' } ${ quantityText ( item . windowOutboundQuantity ) } 件,窗口 日均 ${ quantityText ( item . averageDailyOutboundQuantity ) } 件,可售 ${ quantityText ( item . inventoryCoverageDays ) } 天 ` } ) ) }
const dataFence = new RequestFence ( )
const runtimeFence = new RequestFence ( )
const trendFence = new RequestFence ( )
@@ -127,18 +127,21 @@ function drill(path: 'sales' | 'inventory', params: Record<string, string>, stri
void router . push ( { path : ` /self-operated/ ${ path } ` , query : { warehouseCode : warehouseCode . value , from : 'overview' , context : JSON . stringify ( context . value ) , overviewReturn : JSON . stringify ( { ... appliedRange , scroll : window . scrollY } ) , ... params } } )
}
function allRanking ( key : typeof rankingNames [ number ] [ 'key' ] ) {
if ( key === 'hotSkus' ) drill ( 'sales' , { analysis : 'HOT' , windowDays : '30' } )
if ( key === 'hotSkus' ) {
if ( ! data . value ) return
drill ( 'sales' , { analysis : 'HOT' , rangePreset : 'CUSTOM' , startDate : data . value . hotSkus . windowStartDate , endDate : previousDate ( data . value . hotSkus . windowEndDateExclusive ) } )
}
else if ( key === 'longCoverageSkus' ) drill ( 'inventory' , { coverageSelection : 'FINITE_POSITIVE' , sortBy : 'COVERAGE_DAYS_DESC' } )
else drill ( 'inventory' , { attention : 'NO_OUTBOUND_30D' , sortBy : 'PHYSICAL_DESC' } )
}
async function showTrend ( item : DashboardItem ) {
async function showTrend ( item : DashboardItem , ranking : DashboardRanking ) {
if ( ! context . value || expired ( context . value ) ) { error . value = '原图查询已过期,请整体刷新经营总览' ; return }
const fixed = context . value
if ( ! fixed . fixedWindowStartDate || ! fixed . fixedWindowEndDateExclusive ) { error . value = '完整30日窗口 尚不可用' ; return }
if ( ! ranking . windowStartDate || ! ranking . windowEndDateExclusive ) { error . value = '榜单日期范围 尚不可用' ; return }
trendOpen . value = true ; trendLoading . value = true ; trend . value = null ; trendError . value = ''
const request = trendFence . begin ( )
try {
const result = await analyticsApi . daily ( { sourceAccount , warehouseCode : warehouseCode . value , customerCode : item . customerCode , sku : item . sku , current : 1 , size : 20 , ... contextParams ( fixed ) , rangePreset : 'CUSTOM' , startDate : fixed . fixedWindowStartDate , endDate : previousDate ( fixed . fixedWindowEndDateExclusive ) } , request . signal )
const result = await analyticsApi . daily ( { sourceAccount , warehouseCode : warehouseCode . value , customerCode : item . customerCode , sku : item . sku , current : 1 , size : 20 , ... contextParams ( fixed ) , rangePreset : 'CUSTOM' , startDate : ranking . windowStartDate , endDate : previousDate ( ranking . windowEndDateExclusive ) } , request . signal )
if ( ! request . current ( ) ) return
if ( ! sameContext ( fixed , result . context ) ) { trendError . value = '趋势版本与总览不一致,请刷新总览' ; return }
trend . value = result
@@ -175,7 +178,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
< template # toolbar -end >
< el-popover placement = "bottom-end" trigger = "click" :width = "460" title = "统计口径与采集时间" >
< template # reference > < el-button circle aria -label = " 统计口径与采集时间 " title = "统计口径与采集时间" > < Info :size = "16" aria -hidden = " true " / > < / el-button > < / template >
< div class = "dashboard-data-notes admin-density" > < p > 趋势范围只影响区间出库与日趋势 , 其余指标使用 近30个完整日。 < / p > < p v-if = "data" > 销量覆盖截至 {{ beijingTime ( data.context.salesCoveredUntil ) }} ; 库存采集于 {{ beijingTime ( data.context.inventoryObservedFrom ) }} 至 {{ beijingTime ( data.context.inventoryObservedUntil ) }} ( 北京时间 , 本仓一轮完整分页观测 ) 。 < / p > < p v-else > 查询仓库数据后显示对应采集时间 。 < / p > < / div >
< div class = "dashboard-data-notes admin-density" > < p > 时间筛选同时影响区间出库 、 日趋势与热销榜 ; 热销按所选时段已采集量排名 , 未采齐会明确提示 。 库存消化与无出库评估仍需 近30个完整日。 < / p > < p v-if = "data" > 销量覆盖截至 {{ beijingTime ( data.context.salesCoveredUntil ) }} ; 库存采集于 {{ beijingTime ( data.context.inventoryObservedFrom ) }} 至 {{ beijingTime ( data.context.inventoryObservedUntil ) }} ( 北京时间 , 本仓一轮完整分页观测 ) 。 < / p > < p v-else > 查询仓库数据后显示对应采集时间 。 < / p > < / div >
< / el -popover >
< / template >
< template # filters > < QueryToolbar v-model = "filters" :fields="fields" :loading="loading" :search-on-reset="false" preference-key="self-operated-overview-query" ariaLabel="经营总览筛选" @search="load()" @reset="reset" / > < / template >
@@ -198,7 +201,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
< section class = "dashboard-chart" > < h3 > 库存消化天数分布 < / h3 > < p > 有实物库存或库存待核验 SKU ; { { data . context . fixedWindowStartDate } } 至 { { data . context . fixedWindowEndDateExclusive } } ( 截止日不含 ) 。 < / p > < p > 候选 { { quantityText ( data . coverageDistribution . candidateSkuCount ) } } ; 已排除确定零实物 { { quantityText ( data . coverageDistribution . excludedZeroPhysicalSkuCount ) } } SKU 。 { { label ( data . coverageDistribution . dataStatus ) } } · { { reasonsText ( data . coverageDistribution . reasonCodes ) } } < / p > < p v-if = "data.coverageDistribution.candidateSkuCount == null">无可用库存分布资料;销售候选不作为库存总量。</p><DashboardBars v-else :rows="bucketRows" title="库存消化天数分布" unit="SKU" :disabled="stockExpired || contextExpired" @select="code => drill('inventory', { coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' })" /><details><summary>分组说明与待核验原因</summary><p>30/ 60/ 90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason" > {{ reasonsText ( [ String ( reason ) ] ) }} : {{ quantityText ( count ) }} SKU < / p > < / details > < / section >
< / div >
< div class = "dashboard-rankings" >
< section v-for = "ranking in rankingNames" :key="ranking.key" class="dashboard-chart"><div class="dashboard-ranking-heading"><h3>{{ ranking.label }}</h3><el-button :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button></div><p>{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。</p><p>{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}</p><DashboardBars ranked :rows="rankingRows(data[ranking.key].items )" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!)" / > < / section >
< section v-for = "ranking in rankingNames" :key="ranking.key" class="dashboard-chart"><div class="dashboard-ranking-heading"><h3>{{ ranking.label }}</h3><el-button :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button></div><p>{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。</p><p>{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}</p><p v-if="ranking.key === 'hotSkus' && data.hotSkus.dataStatus !== 'COMPLETE'">按所选时段已采集量排名,数据尚未采齐,排名可能随采集变化;未采集日期不计为零出库。</p><p v-else-if="ranking.key !== 'hotSkus' && data[ranking.key].dataStatus !== 'COMPLETE'">本榜仍需完整近30日销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。</p>< DashboardBars ranked :rows="rankingRows(data[ranking.key])" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!, data![ranking.key] )" / > < / section >
< / div >
< / template >
< AppDialog v-model = "trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable><div class="admin-density"><p v-if="trendError" role="alert">{{ trendError }}</p><SkuTrend v-if="trend" :data="trend" / > < / div > < / AppDialog >