475 lines
51 KiB
Vue
475 lines
51 KiB
Vue
<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>
|