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

237 lines
27 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 '@/styles/adminDensity.css'
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes'
import { turnoverRoute, validateTurnover, yesterdayBeijing, turnoverSorts } from '../turnoverNavigation'
import { totalText, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
import AnalyticsChart from './AnalyticsChart.vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import SkuTrend from './SkuTrend.vue'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
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, groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' })
const page = ref(1), size = ref(20), expanded = ref(false), chartsOpen = ref(true)
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)
let linkedSnapshot: string | undefined
let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse } | 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 sortLabels: Record<TurnoverSort, string> = { PHYSICAL_DESC: '实物件数最多', AVAILABLE_DESC: '可售件数最多', OUTBOUND_7_DESC: '完整7日出库最多', OUTBOUND_15_DESC: '完整15日出库最多', OUTBOUND_30_DESC: '完整30日出库最多', COVERAGE_ASC: '可售天数从少到多', COVERAGE_DESC: '可售天数从多到少', TURNOVER_DESC: '期间件数周转次数最多', NO_OUTBOUND_PHYSICAL_DESC: '已确认无出库实物最多' }
const fields = computed<QueryField[]>(() => [
{ key: 'warehouseCode', label: '仓库', type: 'select', width: 170, options: warehouses.value, props: { clearable: false } },
{ key: 'endDate', label: '截止日', type: 'date', width: 150, props: { valueFormat: 'YYYY-MM-DD', clearable: false, disabledDate: (date: Date) => date.toLocaleDateString('en-CA') > yesterdayBeijing() } },
{ key: 'baseWindowDays', label: '日均及可售基准', type: 'select', width: 160, options: [7, 15, 30].map(n => ({ value: n, label: `${n}天` })), props: { clearable: false } },
{ key: 'groupBy', label: '分组', type: 'select', width: 120, options: [{ value: 'SKU', label: '按SKU' }, { value: 'OWNER', label: '按货主' }], props: { clearable: false } },
...(filters.value.groupBy === 'SKU' ? [{ key: 'sku', label: 'SKU', type: 'input' as const, width: 175, placeholder: 'SKU精确查询' }] : []),
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_30D', label: '近30日无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
{ key: 'coverageDaysThreshold', label: '快慢阈值(天)', type: 'input', width: 145, placeholder: '正整数,可选' },
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSorts.filter(s => filters.value.groupBy === 'OWNER' ? !['COVERAGE_ASC', 'COVERAGE_DESC'].includes(s) : s !== 'NO_OUTBOUND_PHYSICAL_DESC').map(value => ({ value, label: sortLabels[value] })) },
])
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
const columns = computed<DataTableColumn[]>(() => [
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slot: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
{ prop: 'customerCode', label: '所属货主', slot: 'owner', minWidth: 160 },
...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []),
{ prop: 'physicalQuantity', label: '实物件数', slot: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slot: 'availableQuantity', minWidth: 145 },
...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slot: `outbound${n}`, minWidth: 160 })),
{ prop: 'averageDailyOutboundQuantity', label: `${applied.value?.baseWindowDays ?? 30}日日均`, slot: 'daily', minWidth: 110 },
...(ownerMode.value ? [{ prop: 'noOutbound30d', label: '无出库SKU / 实物', slot: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slot: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slot: 'coverage', minWidth: 150 }]),
{ prop: 'attentionReasons', label: '状态与样本', slot: 'status', minWidth: 180 },
...(expanded.value ? [{ prop: 'frozenQuantity', label: '冻结件数', slot: 'frozenQuantity', minWidth: 125 }, { prop: 'defectiveQuantity', label: '次品实物', slot: 'defectiveQuantity', minWidth: 125 }, { prop: 'averagePhysicalQuantity', label: '期间平均实物', slot: 'averageStock', minWidth: 145 }, { prop: 'quantityTurnoverCount', label: '期间件数周转', slot: 'turnover', minWidth: 145 }, { prop: 'lastObservedOutboundDate', label: '最近已观测出库日', slot: 'lastDate', minWidth: 180 }] : []),
])
const cards = computed(() => data.value?.summary ? [
{ name: '实物件数', value: data.value.summary.physicalQuantity }, { name: '正品可售件数', value: data.value.summary.availableQuantity },
{ name: '近30日无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount },
] : [])
function castRow(row: unknown) { return row as TurnoverRow }
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) {
filters.value = { ...filters.value, focus, sortBy }; void search()
}
function query(): TurnoverQuery {
return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: String(filters.value.endDate || ''), baseWindowDays: Number(filters.value.baseWindowDays) as TurnoverQuery['baseWindowDays'], groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCode: String(filters.value.customerCode || '') || undefined, sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], coverageDaysThreshold: filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true }
}
function scopeKey(q: TurnoverQuery) { return [q.warehouseCode, q.endDate, q.baseWindowDays].join('|') }
function overviewKey(q: TurnoverQuery) { return [scopeKey(q), q.customerCode, q.groupBy].join('|') }
async function load(refresh = false) {
if (!permitted.value || !active) return
fence.cancel(); inputError.value = ''; evidenceOpen.value = false; trendOpen.value = false
const q = query()
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
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)
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 reset() {
const warehouseCode = filters.value.warehouseCode || applied.value?.warehouseCode || warehouses.value[0]?.value || ''
filters.value = { warehouseCode, endDate: yesterdayBeijing(), baseWindowDays: 30, groupBy: 'SKU', sku: '', customerCode: '', 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', 'endDate', 'baseWindowDays', 'groupBy'].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.value = { ...filters.value, groupBy: 'SKU', customerCode: 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; loading.value = false; error.value = null
const saved = ownerReturn; filters.value = { ...saved.query, sku: '', customerCode: saved.query.customerCode || '', 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, ...initial }; 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
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 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>
<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 #toolbar-actions><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button></template>
<template #filters>
<QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" />
<div class="turnover-context">
<template v-if="filters.customerCode"><el-tag closable @close="filters.customerCode = ''; search()">货主:{{ filters.customerCode }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button></template>
<template v-if="data"><span>{{ applied?.customerCode ? '本货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天</span><span>当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</span><span v-if="error">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</span></template>
</div>
<div v-if="cards.length" class="turnover-cards"><article v-for="card in cards" :key="card.name"><span>{{ card.name }}</span><strong>{{ totalText(card.value) }}</strong></article></div>
<template v-if="data?.charts">
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
<div v-if="chartsOpen" class="turnover-charts">
<section><h3>货主实物件数 Top10</h3><AnalyticsChart class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><p>{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p><ChartDetailsDialog 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></section>
<section><h3>7/15/30 日日均出库</h3><AnalyticsChart class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : `覆盖不足:完整${window.completeDayCount}日,部分${window.partialDayCount}日,未知${window.unknownDayCount}日` }}</p><ChartDetailsDialog 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></section>
</div>
</template>
<nav class="turnover-context" aria-label="库存关注快捷筛选"><el-button link @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部/实物件数最多</el-button><el-button link @click="quickFocus('NO_OUTBOUND_30D', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">近30日无出库</el-button><el-button link @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</el-button><el-button link @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</el-button><el-button link @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</el-button></nav><details class="turnover-rules"><summary>统计说明</summary><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p></details>
<p class="turnover-context">筛选结果:{{ data?.total ?? 0 }} {{ ownerMode ? '个货主' : '条SKU' }}</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', '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 #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
<template #status="{ row }"><small>{{ reasonsText(castRow(row).attentionReasons) }}</small><span v-if="ownerMode">待评估 {{ castRow(row).unassessableSkuCount }} SKU</span><el-button link @click="evidence = castRow(row); evidenceOpen = true">日样本 {{ castRow(row).qualifiedSampleDays }}/{{ castRow(row).requiredSampleDays }}</el-button></template>
<template #noOutbound="{ row }">{{ turnoverNumber(castRow(row).noOutbound30d.matchedSkuCount) }} SKU / {{ turnoverNumber(castRow(row).noOutbound30d.knownPhysicalQuantity) }} 件<small>待判断 {{ castRow(row).noOutbound30d.unknownSkuCount }} SKU</small></template>
<template #slow="{ row }">{{ applied?.coverageDaysThreshold ? `${turnoverNumber(castRow(row).slowCoverage.matchedSkuCount)} SKU / ${castRow(row).slowCoverage.unknownSkuCount} SKU` : '未设查询阈值' }}</template>
<template #averageStock="{ row }">{{ turnoverNumber(castRow(row).averagePhysicalQuantity) }}</template>
<template #turnover="{ row }">{{ turnoverNumber(castRow(row).quantityTurnoverCount) }} 次/{{ applied?.baseWindowDays }}日</template>
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
</DataTable>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" 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" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" fullscreenable><template v-if="evidence && data"><p>{{ 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></template></AppDialog>
</template>
</AdminPage>
</template>
<style scoped>
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 16px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 12px 16px; }
.turnover-cards article { padding: 16px; background: var(--el-fill-color-light); border-top: 3px solid var(--el-color-primary); border-radius: 8px; }
.turnover-cards strong { display: block; font-size: 22px; margin-top: 10px; font-variant-numeric: tabular-nums; }
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 16px 16px; }
.turnover-charts section { border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 8px; }
.turnover-chart { height: 240px; }
h3 { margin: 0; font-size: var(--admin-font-body); }
small { display: block; color: var(--el-text-color-secondary); }
.turnover-rules { padding: 10px 16px; font-size: var(--admin-font-caption); }
</style>