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 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) }