@@ -47,7 +47,6 @@ const fields = computed<QueryField[]>(() => [
{ key : 'groupBy' , label : '分组' , type : 'select' , width : 120 , options : [ { value : 'SKU' , label : '按SKU' } , { value : 'OWNER' , label : '按货主' } ] , props : { clearable : false } } ,
... ( filters . value . groupBy === 'SKU' ? [ { key : 'sku' , label : 'SKU' , type : 'input' as const , width : 175 , placeholder : 'SKU精确查询' } ] : [ ] ) ,
{ key : 'focus' , label : '关注条件' , type : 'select' , width : 165 , props : { clearable : false } , options : [ { value : 'ALL' , label : '全部' } , { value : 'NO_OUTBOUND_IN_WINDOW' , label : '所选时段无出库' } , { value : 'COVERAGE_GT' , label : '消化较慢' } , { value : 'COVERAGE_LE' , label : '消化较快' } , { value : 'FROZEN' , label : '有冻结库存' } , { value : 'DEFECTIVE' , label : '有次品库存' } , { value : 'UNASSESSABLE' , label : '待评估' } ] } ,
{ key : 'coverageDaysThreshold' , label : '快慢阈值(天)' , type : 'input' , width : 145 , placeholder : '正整数,可选' } ,
{ key : 'turnoverStatus' , label : '周转样本' , type : 'select' , width : 160 , options : [ { value : '' , label : '全部样本状态' } , { value : 'INSUFFICIENT' , label : '周转样本不足' } ] } ,
{ key : 'sortBy' , label : '排序' , type : 'select' , width : 190 , props : { clearable : false } , options : turnoverSortOptions . filter ( ( { value } ) => filters . value . groupBy === 'OWNER' ? ! value . startsWith ( 'COVERAGE_' ) : value !== 'NO_OUTBOUND_PHYSICAL_DESC' ) } ,
] )
@@ -79,6 +78,7 @@ const cards = computed(() => data.value?.summary ? [
{ name : '实物件数' , value : data . value . summary . physicalQuantity } , { name : '正品可售件数' , value : data . value . summary . availableQuantity } ,
{ name : '所选时段无出库SKU' , value : data . value . summary . noOutboundSkuCount } , { name : '待评估SKU' , value : data . value . summary . unassessableSkuCount } ,
] : [ ] )
const incompleteDailyWindows = computed ( ( ) => data . value ? . charts ? . averageDaily . filter ( w => w . dataStatus !== 'COMPLETE' || w . averageDailyOutboundQuantity == null ) ? ? [ ] )
const runtimeError = ref ( '' )
const inventoryRuntime = computed ( ( ) => runtime . value ? . warehouses . find ( w => w . warehouseCode === applied . value ? . warehouseCode && w . dataFamily === 'INVENTORY' ) )
const collectionNotice = computed ( ( ) => ! data . value || runtimeError . value ? '' : inventoryCollectionNotice ( inventoryRuntime . value ) )
@@ -107,7 +107,7 @@ function query(refresh = false): TurnoverQuery {
const anchor = ! refresh && data . value ? beijingDate ( Date . parse ( data . value . context . queryAsOf ) ) : beijingDate ( Date . now ( ) )
const range = overviewRange ( filters . value . rangePreset as RangePreset , anchor , String ( custom [ 0 ] ? ? '' ) , String ( custom [ 1 ] ? ? '' ) )
const days = Math . round ( ( Date . parse ( range . endDate ) - Date . parse ( range . startDate ) ) / 86400000 ) + 1
return { sourceAccount : 'lingxing-primary' , warehouseCode : String ( filters . value . warehouseCode || '' ) , endDate : range . endDate , baseWindowDays : days , groupBy : filters . value . groupBy as TurnoverQuery [ 'groupBy' ] , customerCode : String ( filters . value . customerCode || '' ) || undefined , sku : String ( filters . value . sku || '' ) || undefined , focus : filters . value . focus as TurnoverQuery [ 'focus' ] , coverageDaysThreshold : filters . value . coverageDaysThreshold === '' || filters . value . coverageDaysThreshold == null ? undefined : Number ( filters . value . coverageDaysThreshold ) , turnoverStatus : ( filters . value . turnoverStatus || undefined ) as TurnoverQuery [ 'turnoverStatus' ] , sortBy : filters . value . sortBy as TurnoverSort , current : page . value , size : size . value , includeOverview : true }
return { sourceAccount : 'lingxing-primary' , warehouseCode : String ( filters . value . warehouseCode || '' ) , endDate : range . endDate , baseWindowDays : days , groupBy : filters . value . groupBy as TurnoverQuery [ 'groupBy' ] , customerCode : String ( filters . value . customerCode || '' ) || undefined , sku : String ( filters . value . sku || '' ) || undefined , focus : filters . value . focus as TurnoverQuery [ 'focus' ] , coverageDaysThreshold : ! [ 'COVERAGE_GT' , 'COVERAGE_LE' ] . includes ( String ( filters . value . focus ) ) || filters . value . coverageDaysThreshold === '' || filters . value . coverageDaysThreshold == null ? undefined : Number ( filters . value . coverageDaysThreshold ) , turnoverStatus : ( filters . value . turnoverStatus || undefined ) as TurnoverQuery [ 'turnoverStatus' ] , sortBy : filters . value . sortBy as TurnoverSort , current : page . value , size : size . value , includeOverview : true }
}
function scopeKey ( q : TurnoverQuery ) { return [ q . warehouseCode , q . endDate , q . baseWindowDays ] . join ( '|' ) }
function overviewKey ( q : TurnoverQuery ) { return [ scopeKey ( q ) , q . customerCode , q . groupBy ] . join ( '|' ) }
@@ -244,7 +244,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
< div class = "turnover-context" > < el-button link @click ="chartsOpen = !chartsOpen" > {{ chartsOpen ? ' 收起图表 ' : ' 展开图表 ' }} < / el -button > < span > 图表与指标为 { { applied ? . customerCode ? '本货主' : '本仓' } } 整体 , 不随列表SKU或关注筛选改变 < / span > < / div >
< div v-if = "chartsOpen" class="turnover-charts" >
< section > < header class = "turnover-chart-header" > < h3 > 货主实物件数 Top10 < / h3 > < ChartDetailsDialog inline button -label = " 查看明细 " title = "货主实物件数明细" :reset-on = "data.context.snapshotContext" > < table > < thead > < tr > < th > 货主 < / th > < th > 实物件数 < / th > < / tr > < / thead > < tbody > < tr v-for = "owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note" > {{ data.charts.unknownOwnerCount }} 个货主未能完整计算 < / p > < / section >
< section > < header class = "turnover-chart-header" > < h3 > 日均出库对比 ( 含所选时段 ) < / h3 > < ChartDetailsDialog inline button -label = " 查看明细 " title = "日均出库与覆盖明细" :reset-on = "data.context.snapshotContext" > < p v-for = "window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }}; {{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div class="turnover-window-summary"><div v-for="window in data.charts.averageDaily" :key="window.days"><span>{{ window.days }}日日均</span><strong>{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : '覆盖不足' }}</strong><small v-if="window.dataStatus !== 'COMPLETE'" > 完整 {{ window.completeDayCount }} / {{ window.days }} 日 < / small > < / div > < / div > < / section >
< section > < header class = "turnover-chart-header" > < h3 > 日均出库对比 ( 含所选时段 ) < / h3 > < ChartDetailsDialog inline button -label = " 查看明细 " title = "日均出库与覆盖明细" :reset-on = "data.context.snapshotContext" > < p v-for = "window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }}; {{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days" > {{ window.days }} 日 : {{ window.dataStatus = = = ' COMPLETE ' ? ' 日均暂不可用 ' : ' 覆盖不足 ' }} ( 完整 {{ window.completeDayCount }} / {{ window.days }} 日 ) < / span > < / div > < / section >
< / div >
< / template >
< nav class = "turnover-focus" aria -label = " 库存关注快捷筛选 " >
@@ -255,9 +255,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
< button type = "button" : aria -pressed = " applied ? .focus = = = ' UNASSESSABLE ' " @click ="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')" > 待评估 < / button >
< / nav >
< div v-if = "['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold" >
< label > 快慢 阈值 < input v-model = "filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="快慢阈值天数 " @keydown.enter.prevent="search()" / > 天 < / label >
< label > 可售天数 阈值 < input v-model = "filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="可售天数阈值(天) " @keydown.enter.prevent="search()" / > 天 < / label >
< el-button @click ="search()" > 应用 < / el -button >
< span > 预计可售天数大于阈值为较慢 , 不超过阈值为较快 ; 首次使用默认30天 , 可修改 。 数据不足的商品请查看 “ 待评估 ” 。 < / span >
< span > 按当前可售库存预计还能销售的天数筛选 : 超过阈值为较慢 , 不超过阈值为较快 。 此数值不是上方的统计时间范围 ; 快捷筛选首次默认30天 , 可修改 。 零可售或无出库不归为较快 。 < / span >
< / div >
< p v-if = "inputError" class="turnover-filter-feedback" role="alert" > {{ inputError }} < / p >
< p v-else-if = "loading" class="turnover-filter-feedback" role="status" > 正在更新筛选结果 … < / p >
@@ -312,11 +312,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
. turnover - chart - empty { display : flex ; align - items : center ; justify - content : center ; flex - direction : column ; gap : 12 px ; color : var ( -- el - text - color - secondary ) ; background : var ( -- el - fill - color - lighter ) ; border - radius : 8 px ; margin - top : 12 px ; }
. turnover - chart - empty strong { color : var ( -- el - text - color - regular ) ; }
. turnover - chart - note { color : var ( -- el - text - color - secondary ) ; font - size : var ( -- admin - font - caption ) ; margin : 8 px 0 0 ; }
. turnover - window - summary { flex - shrink : 0 ; display : grid ; grid - template - columns : repeat ( 3 , 1 fr ) ; gap : 8 px ; margin : 12 px 0 0 ; }
. turnover - window - summary > div { background : var ( -- el - fill - color - lighter ) ; padding : 8 px 12 px ; border - radius : 6 px ; }
. turnover - window - summary span , . turnover - window - summary strong { display : block ; }
. turnover - window - summary span { color : var ( -- el - text - color - secondary ) ; font - size : var ( -- admin - font - caption ) ; margin - bottom : 4 px ; }
. turnover - window - summary strong { font - variant - numeric : tabular - nums ; }
. turnover - window - gaps { flex - shrink : 0 ; display : flex ; flex - wrap : wrap ; gap : 8 px 16 px ; margin - top : 8 px ; color : var ( -- el - text - color - secondary ) ; font - size : var ( -- admin - font - caption ) ; }
. turnover - focus { display : flex ; flex - wrap : wrap ; gap : 24 px ; margin : 4 px 0 0 ; border - bottom : 1 px solid var ( -- el - border - color - light ) ; }
. turnover - focus button { border : 0 ; border - bottom : 2 px solid transparent ; background : transparent ; padding : 12 px 4 px ; font : inherit ; color : var ( -- el - text - color - regular ) ; cursor : pointer ; }
. turnover - focus button [ aria - pressed = "true" ] { color : var ( -- el - color - primary ) ; border - bottom - color : var ( -- el - color - primary ) ; font - weight : 600 ; }