Files
admin-web/src/modules/self-operated/components/InventoryTurnoverView.vue
T

475 lines
51 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Boxes, ChartNoAxesCombined, Info, Ruler } from '@lucide/vue'
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'
import { RequestFence, beijingTime, contextParams } from '../state'
import type { DailyResponse, Overview } from '../types'
import { overviewRange, overviewRangeOptions, beijingDate } from '../overviewRange'
import type { RangePreset } from '../types'
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow, TurnoverTotal, TurnoverVolume } from '../turnoverTypes'
import { normalizeTurnoverOwners, turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting'
import { totalText, turnoverTotalNote, volumeText, volumeNote, dimensionSourceText, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
import AnalyticsChart from './AnalyticsChart.vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import SkuTrend from './SkuTrend.vue'
import { useOwnerDirectory } from '../useOwnerDirectory'
import { turnoverPercent } from '../turnoverPresentation'
import { useExportTask } from '@/modules/downloads'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const ownerDirectory = useOwnerDirectory()
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)
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' })
const page = ref(1), size = ref(20), expanded = ref(false), chartsOpen = ref(true)
const rightChart = ref<'volume' | 'daily'>('volume')
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)
const exporting = ref(false), exportFeedback = ref('')
const submitExport = useExportTask()
const canExport = computed(() => access.can('platform:self-operated-analytics:export'))
async function exportRows() {
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 = ''
try {
await submitExport('SELFOP_INVENTORY_TURNOVER', `库存周转_${query.warehouseCode}_${query.endDate}`, parameters)
exportFeedback.value = '导出任务已提交,请到下载中心查看进度和下载 Excel'
} catch (cause) { exportFeedback.value = getApiErrorDetails(cause, '导出任务提交失败,请重试').message }
finally { exporting.value = false }
}
let linkedSnapshot: string | undefined
let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse; filters: QueryModel } | null = null
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 } },
{ 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() } }] : []),
{ key: 'groupBy', label: '分组', type: 'select', width: 120, options: [{ value: 'SKU', label: '按SKU' }, { value: 'OWNER', label: '按货主' }], props: { clearable: false } },
{ 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 } },
...(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: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, placeholder: '全部样本状态', 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.startsWith('UNIT_VOLUME_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
])
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()
}
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
const columns = computed<DataTableColumn[]>(() => [
...(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' } : {}) },
...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []),
{ prop: 'physicalQuantity', label: '实物件数', slotName: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slotName: 'availableQuantity', minWidth: 145 },
{ prop: 'inventoryVolumeM3', label: '库存立方数', slotName: 'inventoryVolume', minWidth: 145 },
{ prop: 'outboundVolumesM3', label: `${applied.value?.baseWindowDays ?? 30}日出库立方数(估算)`, slotName: 'outboundVolume', minWidth: 170 },
...(ownerMode.value ? [] : [{ prop: 'unitVolumeM3', label: '单件体积', slotName: 'unitVolume', minWidth: 135 }]),
{ prop: 'transitQuantity', label: '在途', slotName: 'transitQuantity', minWidth: 125 },
...([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 },
{ prop: 'quantityTurnoverRate', label: '期间周转率', slotName: 'turnoverRate', minWidth: 180 },
...(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 }]),
...(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 }] : []),
].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()
}
const incompleteDailyWindows = computed(() => data.value?.charts?.averageDaily.filter(w => w.dataStatus !== 'COMPLETE' || w.averageDailyOutboundQuantity == null) ?? [])
const summaryGroups = computed(() => {
const summary = data.value?.summary
if (!summary) return []
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(';') })
const days = applied.value?.baseWindowDays ?? 30
return [
{ key: 'stock', name: '当前库存', cards: [
quantity('physical', '实物件数', summary.physicalQuantity, 'blue'),
quantity('available', '正品可售件数', summary.availableQuantity, 'green'),
volume('inventory-volume', '库存立方数(m³)', summary.inventoryVolumeM3, false, 'purple'),
] },
{ key: 'period', name: '所选时段分析', cards: [
quantity('no-outbound', '所选时段无出库SKU', summary.noOutboundSkuCount, 'amber'),
volume('outbound-volume', `${days}日出库立方数(m³)`, summary.outboundVolumesM3.find(w => w.days === days)?.volume, true, 'green'),
quantity('unassessable', '待评估SKU', summary.unassessableSkuCount, 'purple'),
] },
]
})
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(' · ')
}
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 = '采集状态更新失败,请到数据采集查看;库存查询结果仍按其观测时间展示。' }
}
function castRow(row: unknown) { return row as TurnoverRow }
function historicalMetricHint(row: TurnoverRow) {
return row.qualifiedSampleDays < row.requiredSampleDays
? '历史库存记录不足,暂不能计算期间平均库存和周转次数'
: '暂不能计算;请在统计说明中查看历史库存记录及销量覆盖原因'
}
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) {
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()
}
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
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 }
}
function scopeKey(q: TurnoverQuery) { return [q.warehouseCode, q.endDate, q.baseWindowDays].join('|') }
function overviewKey(q: TurnoverQuery) { return [scopeKey(q), JSON.stringify(q.customerCodes ?? []), q.groupBy].join('|') }
async function load(refresh = false) {
if (!permitted.value || !active) return
fence.cancel(); inputError.value = ''; evidenceOpen.value = false; evidence.value = null; trendOpen.value = false
let q: TurnoverQuery
try { q = query(refresh) } catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return }
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)
q.snapshotContext = refresh ? undefined : linkedSnapshot ?? (sameScope ? data.value?.context.snapshotContext : undefined)
linkedSnapshot = undefined
q.includeOverview = refresh || q.snapshotContext !== data.value?.context.snapshotContext || !data.value?.summary || !applied.value || overviewKey(applied.value) !== overviewKey(q)
if (!q.snapshotContext) { q.current = 1; page.value = 1; q.includeOverview = true }
const previous = data.value
const request = fence.begin(); loading.value = true
if (refresh) void refreshRuntime()
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)
void ownerDirectory.load(result.context.analyticsContext, q.warehouseCode, request.signal)
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() }
function toggleZeroInventory(checked: string | number | boolean) {
filters.value = { ...filters.value, hideZeroInventory: checked === true }
void search()
}
function reset() {
const warehouseCode = filters.value.warehouseCode || applied.value?.warehouseCode || warehouses.value[0]?.value || ''
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' }
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条件及专属排序已清除'
}
const changed = ['warehouseCode', 'rangePreset', 'groupBy', 'customerCodes'].some(k => next[k] !== filters.value[k])
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) {
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()
}
function returnOwners() {
if (!ownerReturn) return
if (Date.parse(ownerReturn.result.context.queryExpiresAt) <= Date.now()) { inputError.value = '原货主汇总版本已过期,请整体刷新'; return }
fence.cancel(); trendOpen.value = false; evidenceOpen.value = false; evidence.value = null; loading.value = false; error.value = null
const saved = ownerReturn; filters.value = { ...saved.filters, ...saved.query, sku: '', customerCodes: [...(saved.query.customerCodes ?? [])], coverageDaysThreshold: saved.query.coverageDaysThreshold ?? '', turnoverStatus: saved.query.turnoverStatus || '' }
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
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
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
runtime.value = result; runtimeError.value = ''
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() })
onDeactivated(() => { active = false; fence.cancel(); runtimeFence.cancel(); trendFence.cancel(); loading.value = false })
onActivated(() => { const resume = initialized && !active; active = true; if (resume) void initialize() })
onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() })
</script>
<template>
<AdminPage class="admin-density admin-workbench turnover-page">
<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>
<div class="workbench-table">
<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()">
<template #sortHeader="{ column }">
<span class="turnover-sort-heading">
<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>
<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>
</template>
<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>
<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>
<template #filters>
<p v-if="ownerDirectory.error.value" role="alert">{{ ownerDirectory.error.value }}</p>
<QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" />
</template>
<template #before-results>
<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>
</div>
<p v-if="data && error" class="turnover-context" role="status">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</p>
<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>
<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">
<header class="workbench-section-heading"><h4 class="workbench-section-title">{{ group.name }}</h4></header>
<div class="turnover-cards">
<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>
</article>
</div>
</section>
</div>
<template v-if="data?.charts">
<div v-if="chartsOpen" class="turnover-charts">
<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="图表指标">
<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>
<div class="workbench-chart-actions">
<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>
</div>
</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) }" />
<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>
</template>
<template v-else>
<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>
</template>
</section>
</div>
</template>
<div class="turnover-list-filters">
<nav class="turnover-focus workbench-tabs" aria-label="库存关注快捷筛选">
<button type="button" :aria-pressed="applied?.focus === 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部</button>
<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>
<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>
<el-checkbox class="turnover-zero-filter" border aria-label="隐藏 0 库存" title="按当前实物件数筛选;库存未知仍保留" :model-value="filters.hideZeroInventory !== false" :disabled="loading" @change="toggleZeroInventory">隐藏 0 库存</el-checkbox>
</div>
<div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold">
<label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" class="workbench-input" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label>
<el-button @click="search()">应用</el-button>
<InlineHelp title="可售天数阈值说明"><p>按当前可售库存预计还能销售的天数筛选:超过阈值为较慢,不超过阈值为较快。此数值不是上方的统计时间范围;快捷筛选首次默认30天,可修改。零可售或无出库不归为较快。</p></InlineHelp>
</div>
<p v-if="exportFeedback" class="turnover-context" role="status">{{ exportFeedback }}</p><p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
<p v-else-if="loading" class="turnover-filter-feedback" role="status">正在更新筛选结果…</p>
<p v-else-if="error" class="turnover-filter-feedback" role="alert">{{ data ? '筛选未更新,当前保留上次结果。' : '库存周转读取失败。' }}{{ error.message }}</p>
</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>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'transitQuantity', 'frozenQuantity', 'defectiveQuantity'] as const" :key="key" #[key]="{ row }">{{ totalText(castRow(row)[key]) }}</template>
<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>
<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>
<template #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
<template #turnoverRate="{ row }"><span :title="castRow(row).quantityTurnoverCount == null ? historicalMetricHint(castRow(row)) : `所选${applied?.baseWindowDays}天出库量 ÷ 期间平均实物库存 × 100%`">{{ turnoverPercent(castRow(row).quantityTurnoverCount) }}</span></template>
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
<template #noOutbound="{ row }">{{ turnoverNumber(castRow(row).noOutboundInWindow.matchedSkuCount) }} SKU / {{ turnoverNumber(castRow(row).noOutboundInWindow.knownPhysicalQuantity) }} 件<small>待判断 {{ castRow(row).noOutboundInWindow.unknownSkuCount }} SKU</small></template>
<template #slow="{ row }">{{ applied?.coverageDaysThreshold ? `${turnoverNumber(castRow(row).slowCoverage.matchedSkuCount)} SKU / ${castRow(row).slowCoverage.unknownSkuCount} SKU` : '未设查询阈值' }}</template>
<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>
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
</DataTable>
</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>
</template>
</AdminPage>
</template>
<style scoped>
.turnover-sort-heading { display: inline-flex; align-items: center; gap: var(--admin-space-1); }
.turnover-sort { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }
.turnover-sort:disabled { cursor: wait; }
.turnover-sort:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 3px; }
.turnover-sort-arrows { display: inline-flex; flex-direction: column; color: var(--el-text-color-placeholder); font-size: 9px; line-height: 10px; }
.turnover-sort-arrows .active { color: var(--el-color-primary); }
.turnover-page :deep(.data-table--integrated > .data-table__summary) { padding: 12px 20px 0; }
.turnover-page :deep(.data-table--integrated > .el-table) { width: calc(100% - 40px); margin-inline: 20px; }
.turnover-title { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; }
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-summary { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: var(--workbench-grid-gap); padding: 4px 0 12px; }
.turnover-summary-group { min-width: 0; display: flex; flex-direction: column; }
.turnover-summary-group > header { margin-bottom: var(--admin-space-3); }
.turnover-cards { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; flex: 1; }
.turnover-charts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--workbench-grid-gap); padding: 0 0 12px; align-items: stretch; }
.turnover-charts > section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; }
.turnover-chart { flex: 1 1 0; height: 0; min-height: 120px; width: 100%; }
.turnover-chart-empty { flex: 1 1 0; min-height: 0; }
.turnover-chart-header { flex-shrink: 0; }
.turnover-chart-header :deep(.chart-disclosure) { flex-shrink: 0; }
.turnover-window-gaps { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 8px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-list-filters { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); border-bottom: 1px solid var(--el-border-color-light); }
.turnover-zero-filter { flex: 0 0 auto; margin: var(--admin-space-1) 0; }
.turnover-threshold { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 10px 0; font-size: var(--admin-font-caption); }
.turnover-threshold label { display: flex; align-items: center; gap: 8px; }
.turnover-threshold input { width: 76px; }
.turnover-threshold > span { color: var(--el-text-color-secondary); }
.turnover-filter-feedback { margin: 8px 0; color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption); }
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; }
</style>