104 lines
9.1 KiB
TypeScript
104 lines
9.1 KiB
TypeScript
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) }
|