feat(selfop): multi-owner turnover filters, CSV export and percentage
This commit is contained in:
@@ -17,13 +17,15 @@ 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 { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
|
||||
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 { collectTurnoverExport, turnoverCsv, turnoverPercent } from '../turnoverExport'
|
||||
import { saveCsv } from '@/shared/downloadCsv'
|
||||
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const ownerDirectory = useOwnerDirectory()
|
||||
@@ -32,13 +34,30 @@ 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: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' })
|
||||
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), exportProgress = ref(0), exportFeedback = ref('')
|
||||
let exportController: AbortController | undefined
|
||||
async function exportRows() {
|
||||
if (exporting.value || loading.value || !applied.value || !data.value) return
|
||||
const controller = new AbortController(); exportController = controller
|
||||
const q = { ...applied.value, customerCodes: [...(applied.value.customerCodes ?? [])], snapshotContext: data.value.context.snapshotContext }
|
||||
const exportColumns = columns.value.map(c => ({ prop: String(c.prop), label: String(c.label) }))
|
||||
const total = Number(data.value.total)
|
||||
exporting.value = true; exportProgress.value = 0; exportFeedback.value = ''
|
||||
try {
|
||||
const rows = await collectTurnoverExport(q, total, analyticsApi.turnover, controller.signal, count => { exportProgress.value = count })
|
||||
controller.signal.throwIfAborted()
|
||||
saveCsv(new Blob([turnoverCsv(rows, exportColumns, q)], { type: 'text/csv;charset=utf-8' }), `库存周转_${q.warehouseCode}_${q.groupBy}_${q.endDate}.csv`)
|
||||
exportFeedback.value = `已导出 ${rows.length} 条记录`
|
||||
} catch (cause) { exportFeedback.value = controller.signal.aborted ? '导出已取消' : getApiErrorDetails(cause, '导出失败,请重试').message }
|
||||
finally { exporting.value = false; exportController = undefined }
|
||||
}
|
||||
let linkedSnapshot: string | undefined
|
||||
let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse; filters: QueryModel } | null = null
|
||||
let initialized = false, active = true
|
||||
@@ -48,12 +67,18 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ 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 } },
|
||||
{ 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, 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 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
|
||||
@@ -66,6 +91,7 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ 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: 145 },
|
||||
...(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 })))
|
||||
@@ -134,12 +160,13 @@ function query(refresh = false): TurnoverQuery {
|
||||
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'], 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 }
|
||||
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), q.customerCode, q.groupBy].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
|
||||
exportController?.abort()
|
||||
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 }
|
||||
@@ -178,7 +205,7 @@ function toggleZeroInventory(checked: string | number | boolean) {
|
||||
}
|
||||
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: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' }
|
||||
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) {
|
||||
@@ -186,20 +213,20 @@ function changeFilters(next: QueryModel) {
|
||||
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])
|
||||
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, customRange: [...(filters.value.customRange as string[] ?? [])] } }
|
||||
filters.value = { ...filters.value, groupBy: 'SKU', customerCode: code, sku: '', sortBy: 'PHYSICAL_DESC' }; page.value = 1; void load()
|
||||
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: '', customerCode: saved.query.customerCode || '', coverageDaysThreshold: saved.query.coverageDaysThreshold ?? '', turnoverStatus: saved.query.turnoverStatus || '' }
|
||||
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) {
|
||||
@@ -240,9 +267,9 @@ async function initialize() {
|
||||
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 })
|
||||
onDeactivated(() => { active = false; exportController?.abort(); 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() })
|
||||
onUnmounted(() => { exportController?.abort(); fence.cancel(); runtimeFence.cancel(); trendFence.cancel() })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density turnover-page">
|
||||
@@ -258,7 +285,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
</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>
|
||||
<template #toolbar-actions><el-button :disabled="loading || !data || exporting" title="导出当前查询的全部记录与当前显示列(CSV)" @click="exportRows">{{ exporting ? `导出中 ${exportProgress} 条` : '导出' }}</el-button><el-button v-if="exporting" @click="exportController?.abort()">取消导出</el-button><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>
|
||||
<p>历史库存记录用于计算期间平均库存和周转次数,与已采集的销量天数不同;记录不足不代表销量采集失败。</p>
|
||||
<template v-if="data?.rows.length">
|
||||
<label>查看本页历史库存记录
|
||||
@@ -275,9 +302,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<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="data || filters.customerCode" 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 v-if="data || selectedOwners.length" class="turnover-context">
|
||||
<template v-if="selectedOwners.length"><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></template>
|
||||
<template v-if="data"><span>{{ applied?.customerCodes?.length ? '所选货主概览' : '本仓概览' }} · 截止 {{ 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>
|
||||
@@ -295,7 +322,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
</div>
|
||||
<p v-if="data" class="turnover-context">体积优先采用完整仓库实测尺寸,缺失时按商品尺寸估算;库存按当前全品质实物件数计算,历史出库按当前商品尺寸估算,不含在途及库位空隙。缺尺寸或数量未知时展示已知小计。</p>
|
||||
<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 class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCodes?.length ? '所选货主' : '本仓' }}整体,不随列表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 aria-label="库存体积与出库对比">
|
||||
@@ -337,7 +364,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<el-button @click="search()">应用</el-button>
|
||||
<span>按当前可售库存预计还能销售的天数筛选:超过阈值为较慢,不超过阈值为较快。此数值不是上方的统计时间范围;快捷筛选首次默认30天,可修改。零可售或无出库不归为较快。</span>
|
||||
</div>
|
||||
<p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
|
||||
<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>
|
||||
|
||||
@@ -350,6 +377,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<template #outboundVolume="{ row }">{{ volumeText(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume.value) }}<small>{{ volumeNote(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume, true) }}</small></template>
|
||||
<template #unitVolume="{ row }">{{ volumeText(castRow(row).unitVolumeM3, 9) }}<small>{{ dimensionSourceText(castRow(row).dimensionSource) }}</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>
|
||||
|
||||
Reference in New Issue
Block a user