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

335 lines
39 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 { Info } 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 } from '../turnoverTypes'
import { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting'
import { totalText, turnoverTotalNote, 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'
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', 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; 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: 'customerCode', label: '客户', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '名称或编码搜索', props: { filterable: true, clearable: true } },
...(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, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
])
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
const columns = computed<DataTableColumn[]>(() => [
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slotName: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
{ prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160 },
...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []),
{ prop: 'physicalQuantity', label: '实物件数', slotName: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slotName: 'availableQuantity', minWidth: 145 },
{ 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 },
...(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 }]),
{ prop: 'attentionReasons', label: '状态与样本', slotName: 'status', minWidth: 180 },
...(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 cards = computed(() => data.value?.summary ? [
{ name: '实物件数', value: data.value.summary.physicalQuantity }, { name: '正品可售件数', value: data.value.summary.availableQuantity },
{ name: '所选时段无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount },
] : [])
const incompleteDailyWindows = computed(() => data.value?.charts?.averageDaily.filter(w => w.dataStatus !== 'COMPLETE' || w.averageDailyOutboundQuantity == null) ?? [])
const runtimeError = ref('')
const inventoryRuntime = computed(() => runtime.value?.warehouses.find(w => w.warehouseCode === applied.value?.warehouseCode && w.dataFamily === 'INVENTORY'))
const collectionNotice = computed(() => !data.value || runtimeError.value ? '' : inventoryCollectionNotice(inventoryRuntime.value))
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 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'], customerCode: String(filters.value.customerCode || '') || undefined, sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], coverageDaysThreshold: !['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.value.focus)) || filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true }
}
function scopeKey(q: TurnoverQuery) { return [q.warehouseCode, q.endDate, q.baseWindowDays].join('|') }
function overviewKey(q: TurnoverQuery) { return [scopeKey(q), q.customerCode, q.groupBy].join('|') }
async function load(refresh = false) {
if (!permitted.value || !active) return
fence.cancel(); inputError.value = ''; evidenceOpen.value = false; 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 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', 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', 'rangePreset', 'groupBy', 'customerCode'].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, customRange: [...(filters.value.customRange as string[] ?? [])] } }
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.filters, ...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, ...(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 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 #sortHeader="{ column }">
<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>
</template>
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></template>
<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><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"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p></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 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>
<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="cards.length" class="turnover-cards"><article v-for="card in cards" :key="card.name"><span>{{ card.name }}</span><strong>{{ turnoverNumber(card.value.value) }}</strong><small>{{ turnoverTotalNote(card.value) }}</small></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><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
<section><header class="turnover-chart-header"><h3>日均出库对比(含所选时段)</h3><ChartDetailsDialog inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)</span></div></section>
</div>
</template>
<nav class="turnover-focus" aria-label="库存关注快捷筛选">
<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>
<div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold">
<label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label>
<el-button @click="search()">应用</el-button>
<span>按当前可售库存预计还能销售的天数筛选:超过阈值为较慢,不超过阈值为较快。此数值不是上方的统计时间范围;快捷筛选首次默认30天,可修改。零可售或无出库不归为较快。</span>
</div>
<p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
<p v-else-if="loading" class="turnover-filter-feedback" role="status">正在更新筛选结果…</p>
<p v-else-if="error" class="turnover-filter-feedback" role="alert">筛选未更新,当前保留上次结果。{{ 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 #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).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 }">{{ 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="关闭" 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" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body 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><p>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天采集成功的合格计划库存样本,最早次日查询时才能纳入窗口;当天样本数仍为0不代表采集未成功,历史缺日不能补零。</p></template></AppDialog>
</template>
</AdminPage>
</template>
<style scoped>
.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 { margin: 0; font-size: var(--admin-font-section); display: flex; align-items: center; gap: 16px; }
.turnover-title span { font-size: var(--admin-font-caption); font-weight: 400; color: var(--el-text-color-secondary); }
.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-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 4px 0 12px; }
.turnover-cards article { position: relative; overflow: hidden; padding: 16px 20px; border: 1px solid var(--el-color-primary-light-8); background: linear-gradient(120deg, #fff, var(--el-color-primary-light-9)); border-radius: 10px; color: var(--el-color-primary); }
.turnover-cards article:nth-child(2) { color: #248260; border-color: #dbeee5; background: linear-gradient(120deg, #fff, #edf8f1); }
.turnover-cards article:nth-child(3) { color: #b37a22; border-color: #f4e7d3; background: linear-gradient(120deg, #fff, #fff6e8); }
.turnover-cards article:nth-child(4) { color: #8a62bf; border-color: #e9e0f5; background: linear-gradient(120deg, #fff, #f6f0fc); }
.turnover-cards article::after { content: ''; position: absolute; width: 88px; height: 88px; border-radius: 50%; right: -22px; top: -30px; background: currentColor; opacity: .07; pointer-events: none; }
.turnover-cards article > span { color: var(--el-text-color-regular); }
.turnover-cards strong { display: block; font-size: 28px; margin: 8px 0 4px; font-variant-numeric: tabular-nums; }
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 0 12px; align-items: stretch; }
.turnover-charts section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
.turnover-chart { flex: 1 1 0; height: 0; min-height: 120px; width: 100%; }
.turnover-chart-empty { flex: 1 1 0; min-height: 0; padding: 16px; text-align: center; }
.turnover-chart-header { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.turnover-chart-header h3 { margin: 0; }
.turnover-chart-header :deep(.chart-disclosure) { flex-shrink: 0; }
.turnover-chart-empty { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-lighter); border-radius: 8px; margin-top: 12px; }
.turnover-chart-empty strong { color: var(--el-text-color-regular); }
.turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin: 8px 0 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-focus { display: flex; flex-wrap: wrap; gap: 24px; margin: 4px 0 0; border-bottom: 1px solid var(--el-border-color-light); }
.turnover-focus button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 12px 4px; font: inherit; color: var(--el-text-color-regular); cursor: pointer; }
.turnover-focus button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
.turnover-focus button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.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; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); font: inherit; color: var(--el-text-color-primary); }
.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); }
h3 { margin: 0 0 12px; font-size: var(--admin-font-body); border-left: 3px solid var(--el-color-primary); padding-left: 10px; }
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>