Files
admin-web/src/modules/self-operated/dashboardNavigation.ts
T
2026-10-14 11:02:56 +08:00

104 lines
9.1 KiB
TypeScript
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.
import type { LocationQuery } from 'vue-router'
import type { BusinessQuery, CoverageBucket, QueryContext, RangePreset } from './types'
import { validateCustomRange } from './state'
export const buckets: { code: CoverageBucket; label: string }[] = [
{ code: 'DAYS_0_30', label: '0~30天(含30)' }, { code: 'DAYS_30_60', label: '30~60天(含60)' },
{ code: 'DAYS_60_90', label: '60~90天(含90)' }, { code: 'DAYS_GT_90', label: '超过90天' },
{ code: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { code: 'NO_AVAILABLE', label: '无可售库存' }, { code: 'UNKNOWN', label: '库存或销量待核验' },
]
export const rangeOptions: { value: RangePreset; label: string }[] = [{ value: 'TODAY', label: '今日' }, { value: 'LAST_3_DAYS', label: '近3天' }, { value: 'THIS_WEEK', label: '本周' }, { value: 'LAST_15_DAYS', label: '近15天' }, { value: 'THIS_MONTH', label: '本月' }, { value: 'CUSTOM', label: '自定义' }]
export interface OverviewReturn { rangePreset: RangePreset; startDate?: string; endDate?: string; scroll: number }
export interface DashboardDrilldown {
context: QueryContext
customerCode?: string
analysis: 'summary' | 'hot'
rangePreset: RangePreset
startDate: string
endDate: string
inventory: Pick<BusinessQuery, 'attention' | 'sortBy' | 'coverageBucket' | 'bucketRuleVersion' | 'coverageSelection' | 'drilldownFrom'>
returnTo: OverviewReturn
summary: string
}
function scalar(query: LocationQuery, key: string, required = false): string {
const value = query[key]
if (value === undefined && !required) return ''
if (typeof value !== 'string' || !value || value.length > 12000 || /[\u0000-\u001f]/.test(value)) throw new Error(`经营总览下钻参数 ${key} 无效`)
return value
}
export function parseContext(raw: string): QueryContext {
let value: QueryContext
try { value = JSON.parse(raw) as QueryContext } catch { throw new Error('经营总览版本上下文无效') }
if (!value || typeof value !== 'object' || !/^\d{4}-\d{2}-\d{2}$/.test(value.anchorDate ?? '') || value.businessTimezone !== 'Asia/Shanghai') throw new Error('经营总览版本上下文无效')
for (const key of ['salesRunId', 'inventoryRunId'] as const) if (value[key] !== null && (typeof value[key] !== 'string' || !/^\d+$/.test(value[key]))) throw new Error('经营总览版本编号无效')
for (const key of ['salesScopeHash', 'inventoryScopeHash'] as const) if (value[key] !== null && (typeof value[key] !== 'string' || value[key]!.length > 512)) throw new Error('经营总览版本范围无效')
for (const key of ['queryAsOf', 'queryExpiresAt'] as const) if (typeof value[key] !== 'string' || !Number.isFinite(Date.parse(value[key]))) throw new Error('经营总览查询时间无效')
if (validateCustomRange(value.anchorDate, value.anchorDate) || Date.parse(value.queryExpiresAt) <= Date.parse(value.queryAsOf)) throw new Error('经营总览查询日期无效')
for (const key of ['inventoryFreshUntil', 'inventoryDrilldownExpiresAt'] as const) if (value[key] != null && (typeof value[key] !== 'string' || !Number.isFinite(Date.parse(value[key]!)))) throw new Error('经营总览库存时效无效')
return value
}
export function parseOverviewReturn(raw: string): OverviewReturn {
let value: OverviewReturn
try { value = JSON.parse(raw) as OverviewReturn } catch { throw new Error('经营总览返回条件无效') }
if (!value || !rangeOptions.some(option => option.value === value.rangePreset) || !Number.isFinite(value.scroll) || value.scroll < 0 || value.scroll > 1000000) throw new Error('经营总览返回条件无效')
if (value.rangePreset === 'CUSTOM' && validateCustomRange(value.startDate ?? '', value.endDate ?? '')) throw new Error('经营总览返回日期无效')
return value
}
export function parseDashboardDrilldown(query: LocationQuery, mode: 'sales' | 'inventory'): DashboardDrilldown | null {
if (query.from === undefined) {
if (['context', 'coverageBucket', 'coverageSelection', 'bucketRuleVersion', 'drilldownFrom', 'analysis', 'customerCode'].some(key => query[key] !== undefined)) throw new Error('经营总览来源参数缺失')
return null
}
if (scalar(query, 'from') !== 'overview') throw new Error('未知的经营分析来源')
const warehouse = scalar(query, 'warehouseCode', true)
if (warehouse.length > 128) throw new Error('仓库编码无效')
const context = parseContext(scalar(query, 'context', true))
const returnTo = parseOverviewReturn(scalar(query, 'overviewReturn', true))
const customerCode = scalar(query, 'customerCode') || undefined
const sku = scalar(query, 'sku')
if ((customerCode && customerCode.length > 128) || sku.length > 512 || (customerCode && !sku)) throw new Error('SKU/货主身份参数无效')
const result: DashboardDrilldown = { context, customerCode, analysis: 'summary', rangePreset: 'TODAY', startDate: '', endDate: '', inventory: {}, returnTo, summary: '来自经营总览' }
if (mode === 'sales') {
if (['coverageBucket', 'coverageSelection', 'bucketRuleVersion', 'attention', 'sortBy'].some(key => query[key] !== undefined)) throw new Error('销量下钻不能携带库存条件')
const analysis = scalar(query, 'analysis', true)
if (analysis === 'HOT') {
if (query.windowDays !== undefined || scalar(query, 'rangePreset', true) !== 'CUSTOM') throw new Error('热销下钻须使用榜单实际日期')
result.startDate = scalar(query, 'startDate', true); result.endDate = scalar(query, 'endDate', true)
if (validateCustomRange(result.startDate, result.endDate, context.anchorDate)) throw new Error('热销下钻日期无效')
result.analysis = 'hot'; result.rangePreset = 'CUSTOM'
result.summary += ` · 所选时段热销Top100 · ${result.startDate} 至 ${result.endDate}`
} else if (analysis === 'SKU') {
if (query.windowDays !== undefined) throw new Error('SKU日期下钻不能携带榜单窗口')
const range = scalar(query, 'rangePreset', true)
if (!rangeOptions.some(option => option.value === range)) throw new Error('销量日期枚举无效')
result.rangePreset = range as RangePreset
if (range === 'CUSTOM') {
result.startDate = scalar(query, 'startDate', true); result.endDate = scalar(query, 'endDate', true)
if (validateCustomRange(result.startDate, result.endDate, context.anchorDate)) throw new Error('销量下钻日期无效')
} else if (query.startDate !== undefined || query.endDate !== undefined) throw new Error('非自定义范围不能携带起止日期')
result.summary += ` · ${result.startDate || range} ${result.endDate}`
} else throw new Error('销量类别枚举无效')
} else {
if (query.drilldownFrom !== undefined && scalar(query, 'drilldownFrom') !== 'DASHBOARD') throw new Error('库存下钻来源枚举无效')
if (['analysis', 'rangePreset', 'startDate', 'endDate', 'windowDays'].some(key => query[key] !== undefined)) throw new Error('库存下钻不能携带销量日期条件')
result.startDate = context.rangeStartDate ?? ''
result.endDate = context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : ''
if (validateCustomRange(result.startDate, result.endDate, context.anchorDate)) throw new Error('库存下钻缺少有效的原图统计区间')
result.rangePreset = 'CUSTOM'
const bucket = scalar(query, 'coverageBucket')
const rule = scalar(query, 'bucketRuleVersion')
const selection = scalar(query, 'coverageSelection')
const attention = scalar(query, 'attention') || 'ALL'
const sort = scalar(query, 'sortBy') || 'AVAILABLE_DESC'
if (!['ALL', 'NO_OUTBOUND_IN_WINDOW'].includes(attention) || !['AVAILABLE_DESC', 'COVERAGE_DAYS_DESC', 'PHYSICAL_DESC', 'OUTBOUND_ASC'].includes(sort)) throw new Error('库存下钻枚举无效')
if ((bucket && (!buckets.some(item => item.code === bucket) || rule !== 'COVERAGE_DISTRIBUTION_V1' || selection || attention !== 'ALL')) || (!bucket && rule) || (selection && (selection !== 'FINITE_POSITIVE' || attention !== 'ALL'))) throw new Error('库存分组条件无效或互斥')
result.inventory = { attention: attention as 'ALL' | 'NO_OUTBOUND_IN_WINDOW', sortBy: sort as BusinessQuery['sortBy'], coverageBucket: bucket ? bucket as CoverageBucket : undefined, bucketRuleVersion: bucket ? 'COVERAGE_DISTRIBUTION_V1' : undefined, coverageSelection: selection ? 'FINITE_POSITIVE' : undefined, drilldownFrom: 'DASHBOARD' }
result.summary += ` · ${result.startDate} 至 ${result.endDate} · ${buckets.find(item => item.code === bucket)?.label ?? (selection ? '有限正可售天数,从高到低' : attention === 'NO_OUTBOUND_IN_WINDOW' ? '所选时段无出库,实物量从高到低' : '库存')}`
}
if (sku) result.summary += ` · ${customerCode ? `${customerCode} / ` : ''}${sku}`
return result
}
export function expired(context: QueryContext, now = Date.now()) { return now >= Date.parse(context.queryExpiresAt) }
export function inventoryExpired(context: QueryContext, now = Date.now()) { return expired(context, now) || !context.inventoryDrilldownExpiresAt || now >= Date.parse(context.inventoryDrilldownExpiresAt) }
export function previousDate(exclusive: string) { return new Date(Date.parse(`${exclusive}T00:00:00Z`) - 86400000).toISOString().slice(0, 10) }