2026-10-14 00:07:05 +08:00
< script setup lang = "ts" >
2026-10-20 15:48:23 +08:00
import InlineHelp from '@/shared/components/InlineHelp.vue'
2026-10-14 00:07:05 +08:00
import '@/styles/adminDensity.css'
2026-10-20 19:16:47 +08:00
import '@/styles/adminWorkbench.css'
2026-10-14 00:07:05 +08:00
import { computed , onActivated , onDeactivated , onMounted , onUnmounted , ref , watch } from 'vue'
import { useRoute , useRouter } from 'vue-router'
2026-10-20 19:16:47 +08:00
import { Boxes , ChartNoAxesCombined , Info , Ruler } from '@lucide/vue'
2026-10-14 00:07:05 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.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 { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { analyticsApi } from '../api/analytics'
2026-10-14 01:52:20 +08:00
import { RequestFence , beijingTime , contextParams } from '../state'
2026-10-14 00:07:05 +08:00
import type { DailyResponse , Overview } from '../types'
2026-10-14 12:39:16 +08:00
import { overviewRange , overviewRangeOptions , beijingDate } from '../overviewRange'
import type { RangePreset } from '../types'
2026-10-14 18:19:27 +08:00
import type { TurnoverQuery , TurnoverResponse , TurnoverRow , TurnoverSort , TurnoverWindow , TurnoverTotal , TurnoverVolume } from '../turnoverTypes'
2026-10-15 11:14:35 +08:00
import { normalizeTurnoverOwners , turnoverRoute , validateTurnover , yesterdayBeijing } from '../turnoverNavigation'
2026-10-14 11:02:56 +08:00
import { turnoverSortColumns , turnoverSortOptions } from '../turnoverSorting'
2026-10-14 18:19:27 +08:00
import { totalText , turnoverTotalNote , volumeText , volumeNote , dimensionSourceText , inventoryCollectionNotice , turnoverChart , turnoverNumber , windowText , turnoverReasonsText as reasonsText } from '../turnoverPresentation'
2026-10-14 00:07:05 +08:00
import AnalyticsChart from './AnalyticsChart.vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import SkuTrend from './SkuTrend.vue'
2026-10-14 16:15:40 +08:00
import { useOwnerDirectory } from '../useOwnerDirectory'
2026-10-15 14:59:44 +08:00
import { turnoverPercent } from '../turnoverPresentation'
import { useExportTask } from '@/modules/downloads'
2026-10-14 00:07:05 +08:00
const route = useRoute (), router = useRouter (), access = useAccessPreviewStore ()
2026-10-14 16:15:40 +08:00
const ownerDirectory = useOwnerDirectory ()
2026-10-14 00:07:05 +08:00
const emit = defineEmits < { warehouseSelected : [ code : string ] } > ()
class TurnoverContractError extends Error {}
const permitted = computed (() => access . can ( 'platform:self-operated-analytics:list' ))
const runtime = ref < Overview | null >( null ), data = ref < TurnoverResponse | null >( null )
const error = ref < ApiErrorDetails | null >( null ), inputError = ref ( '' ), notice = ref ( '' ), loading = ref ( false )
2026-10-15 11:14:35 +08:00
const filters = ref < QueryModel >({ warehouseCode : '' , endDate : yesterdayBeijing (), baseWindowDays : 30 , rangePreset : 'LAST_30_DAYS' , customRange : [], groupBy : 'SKU' , hideZeroInventory : true , sku : '' , customerCodes : [], focus : 'ALL' , coverageDaysThreshold : '' , turnoverStatus : '' , sortBy : 'PHYSICAL_DESC' })
2026-10-14 00:07:05 +08:00
const page = ref ( 1 ), size = ref ( 20 ), expanded = ref ( false ), chartsOpen = ref ( true )
2026-10-14 18:19:27 +08:00
const rightChart = ref < 'volume' | 'daily' > ( 'volume' )
2026-10-14 00:07:05 +08:00
const fence = new RequestFence (), runtimeFence = new RequestFence (), trendFence = new RequestFence ()
const trendOpen = ref ( false ), trendLoading = ref ( false ), daily = ref < DailyResponse | null >( null ), trendError = ref < ApiErrorDetails | null >( null )
const evidence = ref < TurnoverRow | null >( null ), evidenceOpen = ref ( false )
const applied = ref < TurnoverQuery | null >( null )
2026-10-15 14:59:44 +08:00
const exporting = ref ( false ), exportFeedback = ref ( '' )
const submitExport = useExportTask ()
const canExport = computed (() => access . can ( 'platform:self-operated-analytics:export' ))
2026-10-15 11:14:35 +08:00
async function exportRows () {
2026-10-15 14:59:44 +08:00
if ( exporting . value || loading . value || ! canExport . value || ! applied . value || ! data . value ) return
const { current : _current , size : _size , includeOverview : _overview , ... query } = applied . value
const parameters = { ... query , customerCodes : [...( query . customerCodes ?? [])], snapshotContext : data . value . context . snapshotContext ,
columns : columns . value . map ( c => String ( c . prop )), expectedTotal : Number ( data . value . total ) }
exporting . value = true ; exportFeedback . value = ''
2026-10-15 11:14:35 +08:00
try {
2026-10-15 14:59:44 +08:00
await submitExport ( 'SELFOP_INVENTORY_TURNOVER' , `库存周转_ ${ query . warehouseCode } _ ${ query . endDate } ` , parameters )
exportFeedback . value = '导出任务已提交,请到下载中心查看进度和下载 Excel'
} catch ( cause ) { exportFeedback . value = getApiErrorDetails ( cause , '导出任务提交失败,请重试' ). message }
finally { exporting . value = false }
2026-10-15 11:14:35 +08:00
}
2026-10-14 00:07:05 +08:00
let linkedSnapshot : string | undefined
2026-10-14 12:39:16 +08:00
let ownerReturn : { query : TurnoverQuery ; result : TurnoverResponse ; filters : QueryModel } | null = null
2026-10-14 00:07:05 +08:00
let initialized = false , active = true
const warehouses = computed (() => [... new Map (( runtime . value ? . warehouses ?? []). map ( w => [ w . warehouseCode , { value : w . warehouseCode , label : w . warehouseName || w . warehouseCode }])). values ()])
const fields = computed < QueryField [] >(() => [
{ key : 'warehouseCode' , label : '仓库' , type : 'select' , width : 170 , options : warehouses . value , props : { clearable : false } },
2026-10-14 12:39:16 +08:00
{ key : 'rangePreset' , label : '统计时间范围' , type : 'select' , width : 180 , props : { clearable : false }, options : overviewRangeOptions },
...( filters . value . rangePreset === 'CUSTOM' ? [{ key : 'customRange' , label : '日期范围' , type : 'daterange' as const , width : 300 , props : { disabledDate : ( date : Date ) => date . toLocaleDateString ( 'en-CA' ) > yesterdayBeijing () } }] : []),
2026-10-14 00:07:05 +08:00
{ key : 'groupBy' , label : '分组' , type : 'select' , width : 120 , options : [{ value : 'SKU' , label : '按SKU' }, { value : 'OWNER' , label : '按货主' }], props : { clearable : false } },
2026-10-15 11:14:35 +08:00
{ key : 'customerCodes' , label : '货主' , type : 'select' , width : 260 , options : ownerDirectory . options . value , placeholder : '全部货主(可多选)' , props : { multiple : true , multipleLimit : 100 , collapseTags : true , collapseTagsTooltip : true , maxCollapseTags : 1 , filterable : true , clearable : true , onChange : changeOwners } },
2026-10-14 00:07:05 +08:00
...( filters . value . groupBy === 'SKU' ? [{ key : 'sku' , label : 'SKU' , type : 'input' as const , width : 175 , placeholder : 'SKU精确查询' }] : []),
2026-10-20 19:16:47 +08:00
{ 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 : 'turnoverStatus' , label : '周转样本' , type : 'select' , width : 160 , placeholder : '全部样本状态' , options : [{ value : '' , label : '全部样本状态' }, { value : 'INSUFFICIENT' , label : '周转样本不足' }] },
2026-10-14 18:19:27 +08:00
{ key : 'sortBy' , label : '排序' , type : 'select' , width : 190 , props : { clearable : false }, options : turnoverSortOptions . filter (({ value }) => filters . value . groupBy === 'OWNER' ? ! value . startsWith ( 'COVERAGE_' ) && ! value . startsWith ( 'UNIT_VOLUME_' ) : value !== 'NO_OUTBOUND_PHYSICAL_DESC' ) },
2026-10-14 00:07:05 +08:00
])
2026-10-15 11:14:35 +08:00
const selectedOwners = computed (() => Array . isArray ( filters . value . customerCodes ) ? filters . value . customerCodes as string [] : [])
function ownerLabel ( code : string ) { return ownerDirectory . options . value . find ( option => option . value === code ) ? . label ?? code }
function changeOwners ( codes : string []) {
filters . value = { ... filters . value , customerCodes : [... codes ] }
void search ()
}
2026-10-14 00:07:05 +08:00
const ownerMode = computed (() => applied . value ? . groupBy === 'OWNER' )
const columns = computed < DataTableColumn [] >(() => [
2026-10-20 19:16:47 +08:00
...( ownerMode . value ? [] : [{ prop : 'sku' , label : 'SKU' , slotName : 'sku' , minWidth : 155 , fixed : 'left' }, { prop : 'productName' , label : '商品名称' , minWidth : 170 }]),
{ prop : 'customerCode' , label : '所属货主' , slotName : 'owner' , minWidth : 160 , ...( ownerMode . value ? { fixed : 'left' } : {}) },
2026-10-14 00:07:05 +08:00
...( ownerMode . value ? [{ prop : 'physicalSkuCount' , label : '有实物SKU数' , minWidth : 125 }] : []),
2026-10-14 09:23:29 +08:00
{ prop : 'physicalQuantity' , label : '实物件数' , slotName : 'physicalQuantity' , minWidth : 145 }, { prop : 'availableQuantity' , label : '正品可售' , slotName : 'availableQuantity' , minWidth : 145 },
2026-10-14 18:19:27 +08:00
{ prop : 'inventoryVolumeM3' , label : '库存立方数' , slotName : 'inventoryVolume' , minWidth : 145 },
2026-10-20 19:16:47 +08:00
{ prop : 'outboundVolumesM3' , label : ` ${ applied . value ? . baseWindowDays ?? 30 } 日出库立方数(估算)` , slotName : 'outboundVolume' , minWidth : 170 },
2026-10-14 18:19:27 +08:00
...( ownerMode . value ? [] : [{ prop : 'unitVolumeM3' , label : '单件体积' , slotName : 'unitVolume' , minWidth : 135 }]),
2026-10-14 14:09:24 +08:00
{ prop : 'transitQuantity' , label : '在途' , slotName : 'transitQuantity' , minWidth : 125 },
2026-10-14 09:23:29 +08:00
...([ 7 , 15 , 30 ] as const ). map ( n => ({ prop : `outbound ${ n } ` , label : ` ${ n } 日出库` , slotName : `outbound ${ n } ` , minWidth : 160 })),
{ prop : 'averageDailyOutboundQuantity' , label : ` ${ applied . value ? . baseWindowDays ?? 30 } 日日均` , slotName : 'daily' , minWidth : 110 },
2026-10-15 14:31:24 +08:00
{ prop : 'quantityTurnoverRate' , label : '期间周转率' , slotName : 'turnoverRate' , minWidth : 180 },
2026-10-14 12:39:16 +08:00
...( ownerMode . value ? [{ prop : 'noOutboundInWindow' , label : '无出库SKU / 实物' , slotName : 'noOutbound' , minWidth : 190 }, { prop : 'slowCoverage' , label : '慢消化SKU / 待判断' , slotName : 'slow' , minWidth : 175 }] : [{ prop : 'inventoryCoverageDays' , label : '预计可售天数' , slotName : 'coverage' , minWidth : 150 }]),
2026-10-14 09:23:29 +08:00
...( expanded . value ? [{ prop : 'frozenQuantity' , label : '冻结件数' , slotName : 'frozenQuantity' , minWidth : 125 }, { prop : 'defectiveQuantity' , label : '次品实物' , slotName : 'defectiveQuantity' , minWidth : 125 }, { prop : 'averagePhysicalQuantity' , label : '期间平均实物' , slotName : 'averageStock' , minWidth : 145 }, { prop : 'quantityTurnoverCount' , label : '期间件数周转' , slotName : 'turnover' , minWidth : 145 }, { prop : 'lastObservedOutboundDate' , label : '最近已观测出库日' , slotName : 'lastDate' , minWidth : 180 }] : []),
2026-10-14 11:02:56 +08:00
]. map ( column => ({ ... column , headerSlotName : turnoverSortColumns [ column . prop ] ? 'sortHeader' : undefined })))
function columnSortDirection ( prop : string ) {
const metric = turnoverSortColumns [ prop ]
if ( ! metric ) return undefined
return applied . value ? . sortBy === ` ${ metric } _ASC` ? 'ASC' : applied . value ? . sortBy === ` ${ metric } _DESC` ? 'DESC' : undefined
}
async function sortColumn ( prop : string ) {
const metric = turnoverSortColumns [ prop ]
if ( ! metric || loading . value ) return
const sortBy : TurnoverSort = ` ${ metric } _ ${ columnSortDirection ( prop ) === 'DESC' ? 'ASC' : 'DESC' } `
filters . value = { ... filters . value , sortBy }
await search ()
}
2026-10-14 14:00:35 +08:00
const incompleteDailyWindows = computed (() => data . value ? . charts ? . averageDaily . filter ( w => w . dataStatus !== 'COMPLETE' || w . averageDailyOutboundQuantity == null ) ?? [])
2026-10-14 18:19:27 +08:00
const summaryGroups = computed (() => {
const summary = data . value ? . summary
if ( ! summary ) return []
2026-10-20 19:16:47 +08:00
const quantity = ( key : string , name : string , value : TurnoverTotal , tone : string ) => ({ key , name , kind : 'quantity' , tone , text : turnoverNumber ( value . value ), note : value . value == null ? turnoverTotalNote ( value ) : '' , title : undefined as string | undefined })
const volume = ( key : string , name : string , value : TurnoverVolume | undefined , outbound : boolean , tone : string ) => ({ key , name , kind : 'volume' , tone , text : turnoverNumber ( value ? . value , 2 ), note : volumeWarning ( value , 2 ), title : [ value ? . value != null ? volumeText ( value . value ) : '' , volumeNote ( value , outbound )]. filter ( Boolean ). join ( '; ' ) })
2026-10-14 18:19:27 +08:00
const days = applied . value ? . baseWindowDays ?? 30
return [
2026-10-20 19:16:47 +08:00
{ key : 'stock' , name : '当前库存' , cards : [
quantity ( 'physical' , '实物件数' , summary . physicalQuantity , 'blue' ),
2026-10-14 18:19:27 +08:00
quantity ( 'available' , '正品可售件数' , summary . availableQuantity , 'green' ),
volume ( 'inventory-volume' , '库存立方数(m³)' , summary . inventoryVolumeM3 , false , 'purple' ),
] },
2026-10-20 19:16:47 +08:00
{ key : 'period' , name : '所选时段分析' , cards : [
quantity ( 'no-outbound' , '所选时段无出库SKU' , summary . noOutboundSkuCount , 'amber' ),
2026-10-14 18:19:27 +08:00
volume ( 'outbound-volume' , ` ${ days } 日出库立方数(m³)` , summary . outboundVolumesM3 . find ( w => w . days === days ) ? . volume , true , 'green' ),
quantity ( 'unassessable' , '待评估SKU' , summary . unassessableSkuCount , 'purple' ),
] },
]
})
2026-10-20 19:16:47 +08:00
function volumeWarning ( value : TurnoverVolume | undefined , decimals = 6 ) {
if ( ! value ) return '数量或尺寸待确认'
const notes : string [] = []
if ( value . value == null ) notes . push ( value . knownSubtotal == null ? '暂无已知小计' : `已知小计 ${ volumeText ( value . knownSubtotal , decimals ) } ` )
if ( value . missingDimensionSkuCount ) notes . push ( `缺尺寸 ${ value . missingDimensionSkuCount } SKU` )
if ( value . unknownQuantitySkuCount ) notes . push ( `数量待确认 ${ value . unknownQuantitySkuCount } SKU` )
return notes . join ( ' · ' )
}
2026-10-14 09:23:29 +08:00
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 ))
async function refreshRuntime () {
const request = runtimeFence . begin ()
try {
const result = await analyticsApi . overview ( 'lingxing-primary' , undefined , request . signal )
if ( request . current ()) { runtime . value = result ; runtimeError . value = '' }
} catch { if ( request . current ()) runtimeError . value = '采集状态更新失败,请到数据采集查看;库存查询结果仍按其观测时间展示。' }
}
2026-10-14 00:07:05 +08:00
function castRow ( row : unknown ) { return row as TurnoverRow }
2026-10-15 10:29:43 +08:00
function historicalMetricHint ( row : TurnoverRow ) {
return row . qualifiedSampleDays < row . requiredSampleDays
? '历史库存记录不足,暂不能计算期间平均库存和周转次数'
: '暂不能计算;请在统计说明中查看历史库存记录及销量覆盖原因'
}
2026-10-14 00:07:05 +08:00
function restoreThreshold ( warehouse : string ) {
try {
const saved = sessionStorage . getItem ( `selfop-turnover-threshold: ${ warehouse } ` )
if ( saved && /^\d+$/ . test ( saved ) && Number . isSafeInteger ( Number ( saved )) && Number ( saved ) > 0 ) return saved
} catch { /* optional preference */ }
return ''
}
function quickFocus ( focus : TurnoverQuery [ 'focus' ], sortBy : TurnoverSort ) {
2026-10-14 11:02:56 +08:00
const needsThreshold = [ 'COVERAGE_GT' , 'COVERAGE_LE' ]. includes ( focus )
const threshold = filters . value . coverageDaysThreshold
filters . value = { ... filters . value , focus , sortBy , coverageDaysThreshold : needsThreshold && ( threshold == null || String ( threshold ). trim () === '' ) ? 30 : threshold }; void search ()
2026-10-14 00:07:05 +08:00
}
2026-10-14 12:39:16 +08:00
function query ( refresh = false ) : TurnoverQuery {
const custom = Array . isArray ( filters . value . customRange ) ? filters . value . customRange : []
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
2026-10-15 11:14:35 +08:00
return { sourceAccount : 'lingxing-primary' , warehouseCode : String ( filters . value . warehouseCode || '' ), endDate : range . endDate , baseWindowDays : days , groupBy : filters . value . groupBy as TurnoverQuery [ 'groupBy' ], customerCodes : normalizeTurnoverOwners ( filters . value . customerCodes ), sku : String ( filters . value . sku || '' ) || undefined , focus : filters . value . focus as TurnoverQuery [ 'focus' ], hideZeroInventory : filters . value . hideZeroInventory !== false , 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 }
2026-10-14 00:07:05 +08:00
}
function scopeKey ( q : TurnoverQuery ) { return [ q . warehouseCode , q . endDate , q . baseWindowDays ]. join ( '|' ) }
2026-10-15 11:14:35 +08:00
function overviewKey ( q : TurnoverQuery ) { return [ scopeKey ( q ), JSON . stringify ( q . customerCodes ?? []), q . groupBy ]. join ( '|' ) }
2026-10-14 00:07:05 +08:00
async function load ( refresh = false ) {
if ( ! permitted . value || ! active ) return
2026-10-15 10:29:43 +08:00
fence . cancel (); inputError . value = '' ; evidenceOpen . value = false ; evidence . value = null ; trendOpen . value = false
2026-10-14 12:39:16 +08:00
let q : TurnoverQuery
try { q = query ( refresh ) } catch ( cause ) { loading . value = false ; inputError . value = ( cause as Error ). message ; return }
2026-10-14 00:07:05 +08:00
if ( applied . value && applied . value . warehouseCode !== q . warehouseCode ) data . value = null
try {
validateTurnover ( q )
if ( ! warehouses . value . some ( w => w . value === q . warehouseCode )) throw new Error ( '指定仓库不可用,请重新选择有权限的仓库' )
} catch ( cause ) { loading . value = false ; inputError . value = ( cause as Error ). message ; return }
const sameScope = applied . value && scopeKey ( applied . value ) === scopeKey ( q )
2026-10-14 01:52:20 +08:00
q . snapshotContext = refresh ? undefined : linkedSnapshot ?? ( sameScope ? data . value ? . context . snapshotContext : undefined )
2026-10-14 00:07:05 +08:00
linkedSnapshot = undefined
2026-10-14 01:52:20 +08:00
q . includeOverview = refresh || q . snapshotContext !== data . value ? . context . snapshotContext || ! data . value ? . summary || ! applied . value || overviewKey ( applied . value ) !== overviewKey ( q )
2026-10-14 00:07:05 +08:00
if ( ! q . snapshotContext ) { q . current = 1 ; page . value = 1 ; q . includeOverview = true }
const previous = data . value
const request = fence . begin (); loading . value = true
2026-10-14 09:23:29 +08:00
if ( refresh ) void refreshRuntime ()
2026-10-14 00:07:05 +08:00
try {
const result = await analyticsApi . turnover ( q , request . signal )
if ( ! request . current ()) return
if ( result . context . warehouseCode !== q . warehouseCode || result . context . endDate !== q . endDate || result . context . baseWindowDays !== q . baseWindowDays ) throw new TurnoverContractError ( '返回的查询范围不一致,请整体刷新' )
if ( q . snapshotContext && result . context . snapshotContext !== q . snapshotContext ) throw new TurnoverContractError ( '查询版本已变化,请整体刷新' )
if ( q . includeOverview && ( ! result . summary || ! result . charts )) throw new TurnoverContractError ( '本次概览未完整返回,请整体刷新' )
data . value = { ... result , summary : q . includeOverview ? result . summary : previous ? . summary ?? null , charts : q . includeOverview ? result . charts : previous ? . charts ?? null }
applied . value = q ; error . value = null ; emit ( 'warehouseSelected' , q . warehouseCode )
2026-10-14 16:15:40 +08:00
void ownerDirectory . load ( result . context . analyticsContext , q . warehouseCode , request . signal )
2026-10-14 00:07:05 +08:00
try {
if ( q . coverageDaysThreshold != null ) sessionStorage . setItem ( `selfop-turnover-threshold: ${ q . warehouseCode } ` , String ( q . coverageDaysThreshold ))
} catch { /* optional preference */ }
} catch ( cause ) { if ( request . current ()) { error . value = getApiErrorDetails ( cause , cause instanceof TurnoverContractError ? cause . message : '库存周转读取失败;请重试' ); if ( data . value && applied . value ) page . value = applied . value . current } }
finally { if ( request . current ()) loading . value = false }
}
async function search () { page . value = 1 ; await load () }
2026-10-15 10:29:43 +08:00
function toggleZeroInventory ( checked : string | number | boolean ) {
filters . value = { ... filters . value , hideZeroInventory : checked === true }
void search ()
}
2026-10-14 00:07:05 +08:00
function reset () {
const warehouseCode = filters . value . warehouseCode || applied . value ? . warehouseCode || warehouses . value [ 0 ] ? . value || ''
2026-10-15 11:14:35 +08:00
filters . value = { warehouseCode , endDate : yesterdayBeijing (), baseWindowDays : 30 , rangePreset : 'LAST_30_DAYS' , customRange : [], groupBy : 'SKU' , hideZeroInventory : true , sku : '' , customerCodes : [], focus : 'ALL' , coverageDaysThreshold : '' , turnoverStatus : '' , sortBy : 'PHYSICAL_DESC' }
2026-10-14 00:07:05 +08:00
ownerReturn = null ; page . value = 1 ; void load ( true )
}
function changeFilters ( next : QueryModel ) {
notice . value = ''
if ( next . groupBy !== filters . value . groupBy ) {
next = { ... next , sku : '' , sortBy : 'PHYSICAL_DESC' }; notice . value = '已切换分组,SKU条件及专属排序已清除'
}
2026-10-15 11:14:35 +08:00
const changed = [ 'warehouseCode' , 'rangePreset' , 'groupBy' , 'customerCodes' ]. some ( k => next [ k ] !== filters . value [ k ])
2026-10-14 00:07:05 +08:00
if ( next . warehouseCode !== filters . value . warehouseCode ) next = { ... next , coverageDaysThreshold : restoreThreshold ( String ( next . warehouseCode || '' )) }
filters . value = next
if ( changed ) { page . value = 1 ; void load () }
}
function selectOwner ( code : string ) {
2026-10-15 11:14:35 +08:00
if ( applied . value ? . groupBy === 'OWNER' && data . value ) ownerReturn = { query : { ... applied . value }, result : data . value , filters : { ... filters . value , customerCodes : [... selectedOwners . value ], customRange : [...( filters . value . customRange as string [] ?? [])] } }
filters . value = { ... filters . value , groupBy : 'SKU' , customerCodes : [ code ], sku : '' , sortBy : 'PHYSICAL_DESC' }; page . value = 1 ; void load ()
2026-10-14 00:07:05 +08:00
}
function returnOwners () {
if ( ! ownerReturn ) return
if ( Date . parse ( ownerReturn . result . context . queryExpiresAt ) <= Date . now ()) { inputError . value = '原货主汇总版本已过期,请整体刷新' ; return }
2026-10-15 10:29:43 +08:00
fence . cancel (); trendOpen . value = false ; evidenceOpen . value = false ; evidence . value = null ; loading . value = false ; error . value = null
2026-10-15 11:14:35 +08:00
const saved = ownerReturn ; filters . value = { ... saved . filters , ... saved . query , sku : '' , customerCodes : [...( saved . query . customerCodes ?? [])], coverageDaysThreshold : saved . query . coverageDaysThreshold ?? '' , turnoverStatus : saved . query . turnoverStatus || '' }
2026-10-14 00:07:05 +08:00
applied . value = saved . query ; page . value = saved . query . current ; data . value = saved . result ; ownerReturn = null
}
async function showTrend ( row : TurnoverRow ) {
if ( ! row . sku || ! data . value || ! applied . value ) return
const metric = row . windows . find ( w => w . days === applied . value ! . baseWindowDays )
if ( ! metric ) return
trendOpen . value = true ; daily . value = null ; trendError . value = null ; trendLoading . value = true
const request = trendFence . begin (), context = data . value . context
try {
const end = new Date ( Date . parse ( ` ${ metric . endDateExclusive } T00:00:00Z` ) - 86400000 ). toISOString (). slice ( 0 , 10 )
const result = await analyticsApi . daily ({ sourceAccount : applied . value . sourceAccount , warehouseCode : applied . value . warehouseCode , customerCode : row . customerCode , sku : row . sku , rangePreset : 'CUSTOM' , startDate : metric . startDate , endDate : end , current : 1 , size : 100 , ... contextParams ( context . analyticsContext ) }, request . signal )
if ( ! request . current ()) return
if ( result . context . salesRunId !== context . analyticsContext . salesRunId || result . context . inventoryRunId !== context . analyticsContext . inventoryRunId || result . context . queryAsOf !== context . queryAsOf ) throw new Error ( '趋势版本与周转列表不一致,请整体刷新' )
daily . value = result
} catch ( cause ) { if ( request . current ()) trendError . value = getApiErrorDetails ( cause , '趋势读取失败' ) }
finally { if ( request . current ()) trendLoading . value = false }
}
watch ( trendOpen , open => { if ( ! open ) { trendFence . cancel (); daily . value = null ; trendLoading . value = false } })
function readRoute () {
try {
const initial = turnoverRoute ( route . query ); linkedSnapshot = initial . snapshotContext
2026-10-15 10:29:43 +08:00
filters . value = { ... filters . value , hideZeroInventory : true , ... initial , ...( initial . endDate ? { rangePreset : 'CUSTOM' , customRange : [ new Date ( Date . parse ( initial . endDate ) - (( initial . baseWindowDays ?? 30 ) - 1 ) * 86400000 ). toISOString (). slice ( 0 , 10 ), initial . endDate ] } : {}) }; page . value = initial . current ?? 1 ; size . value = initial . size ?? 20
2026-10-14 00:07:05 +08:00
return true
} catch ( cause ) { fence . cancel (); data . value = null ; inputError . value = ( cause as Error ). message ; return false }
}
async function initialize () {
if ( ! permitted . value || ! readRoute ()) return
const request = runtimeFence . begin ()
try {
const result = await analyticsApi . overview ( 'lingxing-primary' , undefined , request . signal )
if ( ! request . current ()) return
2026-10-14 09:23:29 +08:00
runtime . value = result ; runtimeError . value = ''
2026-10-14 00:07:05 +08:00
if ( ! filters . value . warehouseCode ) filters . value . warehouseCode = warehouses . value [ 0 ] ? . value ?? ''
if ( route . query . coverageDaysThreshold == null ) filters . value . coverageDaysThreshold = restoreThreshold ( String ( filters . value . warehouseCode || '' ))
if ( filters . value . warehouseCode ) await load ()
} catch ( cause ) { if ( request . current ()) error . value = getApiErrorDetails ( cause , '仓库列表读取失败' ) }
}
watch (() => route . query , () => { if ( active && readRoute ()) void load () })
watch ( chartsOpen , value => { try { sessionStorage . setItem ( 'selfop-turnover-charts' , String ( value )) } catch { /* optional preference */ } })
onMounted (() => { initialized = true ; try { chartsOpen . value = sessionStorage . getItem ( 'selfop-turnover-charts' ) !== 'false' } catch { /* optional preference */ } void initialize () })
2026-10-15 14:59:44 +08:00
onDeactivated (() => { active = false ; fence . cancel (); runtimeFence . cancel (); trendFence . cancel (); loading . value = false })
2026-10-14 00:07:05 +08:00
onActivated (() => { const resume = initialized && ! active ; active = true ; if ( resume ) void initialize () })
2026-10-15 14:59:44 +08:00
onUnmounted (() => { fence . cancel (); runtimeFence . cancel (); trendFence . cancel () })
2026-10-14 00:07:05 +08:00
</ script >
< template >
2026-10-20 19:16:47 +08:00
< AdminPage class = "admin-density admin-workbench turnover-page" >
2026-10-14 00:07:05 +08:00
< p v-if = "!permitted" role="alert" > 没有库存分析查看权限 。 </ p >
< template v-else >
< LoadErrorState v-if = "error" title="库存周转读取失败" :message="error.message" compact @retry="runtime ? load() : initialize()"><template #details><ApiErrorMeta :details="error" /></ template ></ LoadErrorState >
< p v-if = "inputError" role="alert">{{ inputError }}</p><p v-if="notice" role="status" >{{ notice }}</ p >
2026-10-20 19:16:47 +08:00
< div class = "workbench-table" >
2026-10-14 00:07:05 +08:00
< DataTable : data = "data?.rows ?? []" :columns = "columns" :loading = "loading" : total = "data?.total ?? 0" :page = "page" :page-size = "size" appearance = "integrated" : empty -text ="! warehouses.length ? ' 暂无可用仓库 ' : error ? ' 读取失败 , 请重试 ' : ' 暂无符合条件的数据 '" @ update : page = "page = $event; load()" @ update : page -size =" size = $ event ; search ()" >
2026-10-14 11:02:56 +08:00
< template # sortHeader = "{ column }" >
2026-10-15 14:31:24 +08:00
< span class = "turnover-sort-heading" >
2026-10-14 11:02:56 +08:00
< button type = "button" class = "turnover-sort" :data-column = "column.property" :disabled = "loading" : aria -label ="`$ { column.label } : ${ columnSortDirection ( column.property ) === ' ASC ' ? ' 当前升序 , ' : columnSortDirection ( column.property ) === ' DESC ' ? ' 当前降序 , ' : '' } 点击 $ { columnSortDirection ( column.property ) === ' DESC ' ? ' 升序 ' : ' 降序 ' } 排列 `" @click.stop ="sortColumn(column.property)" >
{{ column.label }}
< span class = "turnover-sort-arrows" aria -hidden =" true " >< span : class = "{ active: columnSortDirection(column.property) === 'ASC' }" > ▲ </ span >< span : class = "{ active: columnSortDirection(column.property) === 'DESC' }" > ▼ </ span ></ span >
</ button >
2026-10-15 14:31:24 +08:00
< el-popover v-if = "column.property === 'quantityTurnoverRate'" title="期间周转率计算说明" trigger="click" placement="bottom" :width="380" teleported >
< template # reference >< el-button link aria -label =" 期间周转率计算说明 " title = "查看计算公式" @click.stop >< Info :size = "14" /></ el-button ></ template >
< div class = "turnover-rules" >
< p > 期间周转率 = 所选 {{ applied ? . baseWindowDays ?? 30 }} 天完整出库量 ÷ 同期平均实物库存 × 100 % 。 </ p >
< p > 平均实物库存 = 同期每日末实物库存之和 ÷ 天数 。 历史库存由流水还原并核对 ; 包含冻结 、 次品实物 , 不含在途 。 </ p >
< p > 货主汇总使用汇总出库量 ÷ 汇总平均实物库存 , 不取各 SKU 百分比的平均值 。 </ p >
< p > 缺少完整销量 、 合格历史库存或平均库存为 0 时显示 “—”; 当前库存不代替历史平均库存 。 100 % 表示所选期间出库量相当于平均库存周转一次 。 </ p >
< p > 点击列名切换从高到低 / 从低到高 , 对全部匹配记录排序 ,“—” 排在最后 。 </ p >
</ div >
</ el-popover >
</ span >
2026-10-14 11:02:56 +08:00
</ template >
2026-10-20 19:16:47 +08:00
< template # toolbar -start >< slot name = "view-tabs" >< h2 class = "turnover-title" >< span class = "workbench-title" > 库存周转 </ span >< span class = "workbench-note" > 销量 · 库存 · 周转效率 </ span >< InlineHelp title = "库存体积说明" >< p > 体积优先采用完整仓库实测尺寸 , 缺失时按商品尺寸估算 ; 库存按当前全品质实物件数计算 , 历史出库按当前商品尺寸估算 , 不含在途及库位空隙 。 缺尺寸或数量未知时展示已知小计 。 </ p ></ InlineHelp ></ h2 ></ slot ></ template >
< template # toolbar -actions >< el-button v-if = "canExport" :disabled="loading || !data || exporting" title="后台导出全部匹配记录与当前显示列,在下载中心下载 Excel" @click="exportRows">{{ exporting ? '提交中…' : '导出' }}</el-button><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button v-if="data?.charts" :aria-pressed="chartsOpen" :title="chartsOpen ? '收起图表' : '展开图表'" @click="chartsOpen = !chartsOpen">图表</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起更多指标' : '显示更多指标' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules turnover-statistics"><p v-if="data" >{{ applied ? .customerCodes ? .length ? ' 所选货主概览 ' : ' 本仓概览 ' }} · 截止 {{ applied ? .endDate }} · 基准 {{ applied ? .baseWindowDays }} 天 。 当前库存采集 {{ beijingTime ( data.context.inventoryObservedFrom ) }} 至 {{ beijingTime ( data.context.inventoryObservedUntil ) }} ( 北京时间 )。 </ p >< p > 当前库存使用查询时固定的快照 , 不随统计时段变化 。 图表与指标为 {{ applied ? . customerCodes ? . length ? '所选货主' : '本仓' }} 整体 , 不随列表SKU或关注筛选改变 。 </ p >< p > 库存体积优先采用仓库实测尺寸 , 缺失时按商品尺寸估算 ; 历史出库体积按当前商品尺寸估算 。 缺尺寸或数量未知时仅展示已知小计 , 具体来源见数值提示 。 </ p >< p > 7 / 15 / 30 天均为截至所选日期的完整滚动窗口 。 日均仅使用完整窗口 ; 预计可售天数使用查询时固定的当前正品可售库存 , 不是库龄 。 </ p >< p > 期间件数周转次数 = 同期完整出库 ÷ 流水还原并经库存锚点核对的每日末实物库存均值 。 缺日 、 缺商品或单位异常不补零 ; 当前库存不代替历史平均库存 。 件数不是占用空间 , 快慢标签仅供人工复核 。 </ p >< p > 货主视图按 “ 含此类SKU的货主 ” 筛选 ; 未知数量保留已知小计 , 不平均SKU可售天数 。 库存过期不抹去有完整证据的历史销量与周转 。 </ p >
2026-10-15 10:29:43 +08:00
< p > 历史库存记录用于计算期间平均库存和周转次数 , 与已采集的销量天数不同 ; 记录不足不代表销量采集失败 。 </ p >
< template v-if = "data?.rows.length" >
< label > 查看本页历史库存记录
< select v-model = "evidence" aria-label="选择历史库存记录" :disabled="loading" >
< option :value = "null" > 请选择商品或货主 </ option >
< option v-for = "row in data.rows" :key="JSON.stringify([row.customerCode, row.sku])" :value="row" >{{ row.customerName || row.customerCode }} · {{ row.sku || ' 货主汇总 ' }}</ option >
</ select >
</ label >
< el-button link : disabled = "!evidence || loading" @click ="evidenceOpen = true" > 查看记录明细 </ el -button >
</ template >
</ div ></ el-popover ></ template >
2026-10-14 00:07:05 +08:00
< template # filters >
2026-10-14 16:15:40 +08:00
< p v-if = "ownerDirectory.error.value" role="alert" >{{ ownerDirectory.error.value }}</ p >
2026-10-14 00:07:05 +08:00
< QueryToolbar :model-value = "filters" :fields = "fields" ariaLabel = "库存周转查询" preference -key =" selfop -turnover-query " @ update :model-value = "changeFilters" @search ="search" @reset ="reset" />
2026-10-14 09:23:29 +08:00
</ template >
< template # before -results >
2026-10-20 19:16:47 +08:00
< div v-if = "selectedOwners.length" class="turnover-context" >
< el-tag v-for = "code in selectedOwners" :key="code" closable @close="changeOwners(selectedOwners.filter(item => item !== code))">{{ ownerLabel(code) }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners" > 返回货主汇总 </ el -button >
2026-10-14 00:07:05 +08:00
</ div >
2026-10-20 19:16:47 +08:00
< p v-if = "data && error" class="turnover-context" role="status" > 保留 {{ beijingTime ( data.context.queryAsOf ) }} 查询结果 </ p >
2026-10-14 09:23:29 +08:00
< p v-if = "data?.context.analyticsContext.inventoryStatus === 'STALE'" class="turnover-context" role="status" > 当前库存已过期 , 实物 、 可售 、 货主排名及预计可售天数暂不可用 ; 最近库存观测完成于 {{ beijingTime ( data.context.inventoryObservedUntil ) }} 。 采集成功后请整体刷新 。 </ p >
< p v-else-if = "data?.context.analyticsContext.inventoryStatus === 'UNKNOWN'" class="turnover-context" role="status" > 当前尚无可用库存版本 , 请查看数据采集 。 </ p >
< p v-if = "collectionNotice" class="turnover-context" role="status">{{ collectionNotice }};采集状态截至 {{ beijingTime(inventoryRuntime?.observedAt) }}。<el-button link @click="router.push('/self-operated/collection')" > 查看数据采集 </ el -button ></ p >
< p v-if = "runtimeError" class="turnover-context" role="status" >{{ runtimeError }}</ p >
2026-10-14 18:19:27 +08:00
< div v-if = "summaryGroups.length" class="turnover-summary" >
< section v-for = "group in summaryGroups" :key="group.key" class="turnover-summary-group" :data-group="group.key" :aria-label="group.name" >
2026-10-20 19:16:47 +08:00
< header class = "workbench-section-heading" >< h4 class = "workbench-section-title" >{{ group . name }}</ h4 ></ header >
2026-10-14 18:19:27 +08:00
< div class = "turnover-cards" >
2026-10-20 19:16:47 +08:00
< article v-for = "card in group.cards" :key="card.key" class="workbench-metric is-compact workbench-tone" :data-metric="card.key" :data-kind="card.kind" :data-tone="card.tone" >
< div class = "workbench-metric-heading" >
< span class = "workbench-icon is-compact" aria -hidden =" true " >< Ruler v-if = "card.kind === 'volume'" :size="18" /><Boxes v-else :size="18" /></ span >
< span class = "workbench-metric-label" >{{ card . name }}</ span >
</ div >
< div class = "workbench-metric-value" >< strong :title = "card.title" >{{ card . text }}</ strong ></ div >
< small v-if = "card.note" class="workbench-note" >{{ card.note }}</ small >
2026-10-14 18:19:27 +08:00
</ article >
</ div >
</ section >
</ div >
2026-10-20 15:48:27 +08:00
2026-10-14 00:07:05 +08:00
< template v-if = "data?.charts" >
< div v-if = "chartsOpen" class="turnover-charts" >
2026-10-20 19:16:47 +08:00
< section class = "workbench-panel" >
< header class = "turnover-chart-header workbench-chart-heading" >
< h3 class = "workbench-chart-title" > 货主实物件数 Top10 </ h3 >
< div class = "workbench-chart-actions" >
< 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 >
</ div >
</ 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 workbench-chart-empty is-compact" >
< span class = "workbench-chart-empty-icon" aria -hidden =" true " >< ChartNoAxesCombined :size = "24" /></ span >
< strong > 暂无可排名的库存数据 </ strong >< span > 库存数量尚未完整确认 , 确认后展示货主排名 </ span >
</ div >
< p v-if = "data.charts.unknownOwnerCount" class="turnover-chart-note workbench-note" >{{ data.charts.unknownOwnerCount }} 个货主未能完整计算 </ p >
</ section >
< section class = "workbench-panel" aria -label =" 库存体积与出库对比 " >
< header class = "turnover-chart-header workbench-chart-heading" >
< div class = "workbench-tabs" role = "group" aria -label =" 图表指标 " >
2026-10-14 18:19:27 +08:00
< button type = "button" : class = "{ 'is-active': rightChart === 'volume' }" : aria -pressed =" rightChart = == ' volume '" @click ="rightChart = 'volume'" > 货主库存立方数 Top10 </ button >
< button type = "button" : class = "{ 'is-active': rightChart === 'daily' }" : aria -pressed =" rightChart = == ' daily '" @click ="rightChart = 'daily'" > 日均出库对比 </ button >
</ div >
2026-10-20 19:16:47 +08:00
< div class = "workbench-chart-actions" >
2026-10-14 18:19:27 +08:00
< ChartDetailsDialog v-if = "rightChart === 'volume'" key="volume" inline button-label="查看明细" title="货主库存立方数明细" :reset-on="data.context.snapshotContext" >
< p > 当前库存 , 不随统计时段变化 ; 按完整体积排名 , 不含在途及库位空隙 。 </ p >
< p v-if = "data.charts.unknownVolumeOwnerCount" >{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知 , 未参与排名 。 </ p >
< table class = "density-table" >< thead >< tr >< th > 货主 </ th >< th > 库存立方数 </ th >< th > 计算说明 </ th ></ tr ></ thead >< tbody >< tr v-for = "owner in data.charts.ownerVolumeTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td :title="volumeText(owner.inventoryVolumeM3.value)" >{{ volumeText ( owner.inventoryVolumeM3.value , 2 ) }}</ td >< td >{{ volumeNote ( owner . inventoryVolumeM3 ) }}</ td ></ tr ></ tbody ></ table >
</ ChartDetailsDialog >
< ChartDetailsDialog v-else key = "daily" 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 >
2026-10-20 19:16:47 +08:00
</ div >
2026-10-14 18:19:27 +08:00
</ header >
< template v-if = "rightChart === 'volume'" >
< AnalyticsChart v-if = "data.charts.ownerVolumeTop.length" key="volume" class="turnover-chart" :option="turnoverChart(data.charts, 'volume')" @select="event => { const owner = data?.charts?.ownerVolumeTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" />
2026-10-20 19:16:47 +08:00
< div v-else class = "turnover-chart-empty workbench-chart-empty is-compact" >
< span class = "workbench-chart-empty-icon" aria -hidden =" true " >< ChartNoAxesCombined :size = "24" /></ span >
< strong > 暂无可排名的库存体积数据 </ strong >< span > 需有可用库存及有效尺寸 ; 未知体积不按零参与排名 </ span >
</ div >
< p v-if = "data.charts.unknownVolumeOwnerCount" class="turnover-chart-note workbench-note" >{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知 , 未参与排名 。 </ p >
2026-10-14 18:19:27 +08:00
</ template >
< template v-else >
2026-10-20 19:16:47 +08:00
< p v-if = "data.context.analyticsContext.rebuildRequired" class="turnover-chart-note workbench-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')" />
< div v-else class = "turnover-chart-empty workbench-chart-empty is-compact" >
< span class = "workbench-chart-empty-icon" aria -hidden =" true " >< ChartNoAxesCombined :size = "24" /></ span >
< p > 暂无完整窗口的日均出库数据 , 覆盖情况如下 。 </ p >
</ div >
< 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 >
2026-10-14 18:19:27 +08:00
</ template >
</ section >
2026-10-14 00:07:05 +08:00
</ div >
</ template >
2026-10-15 10:29:43 +08:00
< div class = "turnover-list-filters" >
2026-10-20 19:16:47 +08:00
< nav class = "turnover-focus workbench-tabs" aria -label =" 库存关注快捷筛选 " >
2026-10-14 11:02:56 +08:00
< button type = "button" : aria -pressed =" applied ? .focus === ' ALL '" @click ="quickFocus('ALL', 'PHYSICAL_DESC')" > 全部 </ button >
2026-10-14 12:39:16 +08:00
< button type = "button" : aria -pressed =" applied ? .focus === ' NO_OUTBOUND_IN_WINDOW '" @click ="quickFocus('NO_OUTBOUND_IN_WINDOW', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')" > 所选时段无出库 </ button >
2026-10-14 11:02:56 +08:00
< button type = "button" : aria -pressed =" applied ? .focus === ' COVERAGE_GT '" @click ="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')" > 消化较慢 </ button >
< button type = "button" : aria -pressed =" applied ? .focus === ' COVERAGE_LE '" @click ="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')" > 消化较快 </ button >
< button type = "button" : aria -pressed =" applied ? .focus === ' UNASSESSABLE '" @click ="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')" > 待评估 </ button >
</ nav >
2026-10-15 10:29:43 +08:00
< el-checkbox class = "turnover-zero-filter" border aria -label =" 隐藏 0 库存 " title = "按当前实物件数筛选;库存未知仍保留" : model -value =" filters.hideZeroInventory !== false " :disabled = "loading" @change ="toggleZeroInventory" > 隐藏 0 库存 </ el -checkbox >
</ div >
2026-10-14 11:02:56 +08:00
< div v-if = "['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold" >
2026-10-20 19:16:47 +08:00
< label > 可售天数阈值 < input v-model = "filters.coverageDaysThreshold" class="workbench-input" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天 </ label >
2026-10-14 11:02:56 +08:00
< el-button @click ="search()" > 应用 </ el -button >
2026-10-20 15:48:23 +08:00
< InlineHelp title = "可售天数阈值说明" >< p > 按当前可售库存预计还能销售的天数筛选 : 超过阈值为较慢 , 不超过阈值为较快 。 此数值不是上方的统计时间范围 ; 快捷筛选首次默认30天 , 可修改 。 零可售或无出库不归为较快 。 </ p ></ InlineHelp >
2026-10-14 11:02:56 +08:00
</ div >
2026-10-15 11:14:35 +08:00
< p v-if = "exportFeedback" class="turnover-context" role="status">{{ exportFeedback }}</p><p v-if="inputError" class="turnover-filter-feedback" role="alert" >{{ inputError }}</ p >
2026-10-14 11:02:56 +08:00
< p v-else-if = "loading" class="turnover-filter-feedback" role="status" > 正在更新筛选结果 … </ p >
2026-10-15 10:29:43 +08:00
< p v-else-if = "error" class="turnover-filter-feedback" role="alert" >{{ data ? ' 筛选未更新 , 当前保留上次结果 。' : ' 库存周转读取失败 。' }}{{ error.message }}</ p >
2026-10-14 11:02:56 +08:00
2026-10-14 00:07:05 +08:00
</ template >
< template # sku = "{ row }" >< el-button link type = "primary" @click ="showTrend(castRow(row))" >{{ castRow ( row ) .sku }} </ el -button ></ template >
< template # owner = "{ row }" >< el-button v-if = "ownerMode" link type="primary" @click="selectOwner(castRow(row).customerCode)" >{{ castRow ( row ) .customerName || castRow ( row ) .customerCode }} </ el -button >< span v-else >{{ castRow ( row ) .customerName || castRow ( row ) .customerCode }}</ span ></ template >
2026-10-14 14:09:24 +08:00
< template v-for = "key in ['physicalQuantity', 'availableQuantity', 'transitQuantity', 'frozenQuantity', 'defectiveQuantity'] as const" :key="key" #[key]="{ row }" >{{ totalText ( castRow ( row )[ key ]) }}</ template >
2026-10-14 00:07:05 +08:00
< template v-for = "n in [7, 15, 30] as TurnoverWindow[]" :key="n" #[`outbound${n}`]="{ row }" >{{ windowText ( castRow ( row ) .windows.find ( w = > w . days === n )) }}</ template >
2026-10-20 19:16:47 +08:00
< template # inventoryVolume = "{ row }" >< span class = "turnover-volume-value" :title = "volumeNote(castRow(row).inventoryVolumeM3)" >{{ volumeText ( castRow ( row ). inventoryVolumeM3 . value ) }}</ span >< small v-if = "volumeWarning(castRow(row).inventoryVolumeM3)" >{{ volumeWarning ( castRow ( row ) .inventoryVolumeM3 ) }}</ small ></ template >
< template # outboundVolume = "{ row }" >< span class = "turnover-volume-value" : title = "volumeNote(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume, true)" >{{ volumeText ( castRow ( row ). outboundVolumesM3 . find ( w => w . days === applied ? . baseWindowDays ) ? . volume . value ) }}</ span >< small v-if = "volumeWarning(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume)" >{{ volumeWarning ( castRow ( row ) .outboundVolumesM3.find ( w = > w . days === applied ? . baseWindowDays ) ? . volume ) }}</ small ></ template >
< template # unitVolume = "{ row }" >< span class = "turnover-volume-value" :title = "dimensionSourceText(castRow(row).dimensionSource)" >{{ volumeText ( castRow ( row ). unitVolumeM3 , 9 ) }}</ span >< small v-if = "castRow(row).unitVolumeM3 == null" > 缺少有效尺寸 </ small ></ template >
2026-10-14 00:07:05 +08:00
< template # daily = "{ row }" >{{ turnoverNumber ( castRow ( row ). averageDailyOutboundQuantity ) }}</ template >
2026-10-15 11:14:35 +08:00
< template # turnoverRate = "{ row }" >< span : title = "castRow(row).quantityTurnoverCount == null ? historicalMetricHint(castRow(row)) : `所选${applied?.baseWindowDays}天出库量 ÷ 期间平均实物库存 × 100%`" >{{ turnoverPercent ( castRow ( row ). quantityTurnoverCount ) }}</ span ></ template >
2026-10-14 00:07:05 +08:00
< template # coverage = "{ row }" >{{ turnoverNumber ( castRow ( row ). inventoryCoverageDays , 1 ) }}< small >{{ reasonsText ([ castRow ( row ). coverageStatus ]) }}</ small ></ template >
2026-10-14 12:39:16 +08:00
< template # noOutbound = "{ row }" >{{ turnoverNumber ( castRow ( row ). noOutboundInWindow . matchedSkuCount ) }} SKU / {{ turnoverNumber ( castRow ( row ). noOutboundInWindow . knownPhysicalQuantity ) }} 件 < small > 待判断 {{ castRow ( row ). noOutboundInWindow . unknownSkuCount }} SKU </ small ></ template >
2026-10-14 00:07:05 +08:00
< template # slow = "{ row }" >{{ applied ? . coverageDaysThreshold ? ` ${ turnoverNumber ( castRow ( row ). slowCoverage . matchedSkuCount ) } SKU / ${ castRow ( row ). slowCoverage . unknownSkuCount } SKU` : '未设查询阈值' }}</ template >
2026-10-15 10:29:43 +08:00
< template # averageStock = "{ row }" >< span : title = "castRow(row).averagePhysicalQuantity == null ? historicalMetricHint(castRow(row)) : undefined" >{{ turnoverNumber ( castRow ( row ). averagePhysicalQuantity ) }}</ span ></ template >
< template # turnover = "{ row }" >< span : title = "castRow(row).quantityTurnoverCount == null ? historicalMetricHint(castRow(row)) : undefined" >{{ castRow ( row ). quantityTurnoverCount == null ? '—' : ` ${ turnoverNumber ( castRow ( row ). quantityTurnoverCount ) } 次/ ${ applied ? . baseWindowDays } 日` }}</ span ></ template >
2026-10-14 00:07:05 +08:00
< template # lastDate = "{ row }" >{{ castRow ( row ). lastObservedOutboundDate || '已采集历史内未见出库' }}</ template >
</ DataTable >
2026-10-20 19:16:47 +08:00
</ div >
< AppDialog v-model = "trendOpen" class="admin-density-dialog admin-workbench is-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></ AppDialog >
< AppDialog v-model = "evidenceOpen" class="admin-density-dialog admin-workbench is-dialog" title="历史库存记录与销量覆盖" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><div v-if="evidence && data" class="workbench-evidence"><p>{{ evidence.customerName || evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};历史库存记录 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }}天;历史覆盖起点 {{ data.context.accumulationStartedOn || '尚无合格历史记录' }}</p><p>缺少库存记录的日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days" >{{ window.days }} 日 : {{ windowText ( window ) }} ; 完整 {{ window.completeDayCount }} 日 / 部分 {{ window.partialDayCount }} 日 / 未知 {{ window.unknownDayCount }} 日 </ p >< p > 日库存口径 : 北京时间每日结束时的全品质实物库存 , 由完整库存流水和独立库存锚点还原 ; 本次查询固定于 {{ beijingTime ( data . context . queryAsOf ) }} 的已发布版本 。 </ p >< p > 历史库存记录与销量覆盖分别校验 。 流水缺页 、 库存锚点不符或正在校正的日期保持未知 ; 不以当前库存代替历史库存 , 也不将缺失数据补零 。 </ p ></ div ></ AppDialog >
2026-10-14 00:07:05 +08:00
</ template >
</ AdminPage >
</ template >
< style scoped >
2026-10-15 14:31:24 +08:00
. turnover - sort - heading { display : inline - flex ; align - items : center ; gap : var ( -- admin - space - 1 ); }
2026-10-14 11:02:56 +08:00
. turnover - sort { display : inline - flex ; align - items : center ; gap : 6 px ; padding : 0 ; border : 0 ; background : transparent ; color : inherit ; font : inherit ; cursor : pointer ; }
. turnover - sort : disabled { cursor : wait ; }
. turnover - sort : focus - visible { outline : 2 px solid var ( -- el - color - primary ); outline - offset : 3 px ; }
. turnover - sort - arrows { display : inline - flex ; flex - direction : column ; color : var ( -- el - text - color - placeholder ); font - size : 9 px ; line - height : 10 px ; }
. turnover - sort - arrows . active { color : var ( -- el - color - primary ); }
. turnover - page : deep (. data - table -- integrated > . data - table__summary ) { padding : 12 px 20 px 0 ; }
. turnover - page : deep (. data - table -- integrated > . el - table ) { width : calc ( 100 % - 40 px ); margin - inline : 20 px ; }
2026-10-20 19:16:47 +08:00
. turnover - title { display : flex ; align - items : center ; flex - wrap : wrap ; gap : 16 px ; }
2026-10-14 11:02:56 +08:00
. turnover - context { display : flex ; gap : 16 px ; align - items : center ; flex - wrap : wrap ; padding : 10 px 0 ; color : var ( -- el - text - color - secondary ); font - size : var ( -- admin - font - caption ); }
2026-10-20 19:16:47 +08:00
. turnover - summary { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : var ( -- workbench - grid - gap ); padding : 4 px 0 12 px ; }
2026-10-14 18:19:27 +08:00
. turnover - summary - group { min - width : 0 ; display : flex ; flex - direction : column ; }
2026-10-20 19:16:47 +08:00
. turnover - summary - group > header { margin - bottom : var ( -- admin - space - 3 ); }
2026-10-14 18:19:27 +08:00
. turnover - cards { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); gap : 10 px ; flex : 1 ; }
2026-10-20 19:16:47 +08:00
. turnover - charts { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) minmax ( 0 , 1 fr ); gap : var ( -- workbench - grid - gap ); padding : 0 0 12 px ; align - items : stretch ; }
. turnover - charts > section { height : 360 px ; box - sizing : border - box ; display : flex ; flex - direction : column ; min - width : 0 ; }
2026-10-14 11:02:56 +08:00
. turnover - chart { flex : 1 1 0 ; height : 0 ; min - height : 120 px ; width : 100 % ; }
2026-10-20 19:16:47 +08:00
. turnover - chart - empty { flex : 1 1 0 ; min - height : 0 ; }
. turnover - chart - header { flex - shrink : 0 ; }
2026-10-14 11:02:56 +08:00
. turnover - chart - header : deep (. chart - disclosure ) { flex - shrink : 0 ; }
2026-10-14 14:00:35 +08:00
. 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 ); }
2026-10-15 10:29:43 +08:00
. turnover - list - filters { display : flex ; align - items : center ; justify - content : space - between ; flex - wrap : wrap ; gap : var ( -- admin - space - 3 ); border - bottom : 1 px solid var ( -- el - border - color - light ); }
. turnover - zero - filter { flex : 0 0 auto ; margin : var ( -- admin - space - 1 ) 0 ; }
2026-10-14 11:02:56 +08:00
. turnover - threshold { display : flex ; align - items : center ; flex - wrap : wrap ; gap : 12 px ; padding : 10 px 0 ; font - size : var ( -- admin - font - caption ); }
. turnover - threshold label { display : flex ; align - items : center ; gap : 8 px ; }
2026-10-20 19:16:47 +08:00
. turnover - threshold input { width : 76 px ; }
2026-10-14 11:02:56 +08:00
. turnover - threshold > span { color : var ( -- el - text - color - secondary ); }
. turnover - filter - feedback { margin : 8 px 0 ; color : var ( -- el - color - warning - dark - 2 ); font - size : var ( -- admin - font - caption ); }
2026-10-14 09:23:29 +08:00
small { display : block ; color : var ( -- el - text - color - secondary ); font - size : var ( -- admin - font - caption ); }
. turnover - rules { font - size : var ( -- admin - font - caption ); line - height : 1.7 ; }
2026-10-14 00:07:05 +08:00
</ style >