237 lines
27 KiB
Vue
237 lines
27 KiB
Vue
<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>
|