import { and, eq, gte, lte, asc } from 'drizzle-orm'; import dayjs from 'dayjs'; import { db } from '../../db/index'; import { projects, projectCostEvents, projectRevenueEvents } from '../../db/schema'; export type Granularity = 'day' | 'week' | 'month' | 'year'; export interface TimeseriesBucket { bucket: string; // YYYY-MM-DD (day/week) | YYYY-MM (month) | YYYY (year) cost: number; revenue: number; net: number; cumulativeCost: number; cumulativeRevenue: number; cumulativeRoi: number | null; } function bucketKey(date: Date, granularity: Granularity): string { const d = dayjs(date); switch (granularity) { case 'day': return d.format('YYYY-MM-DD'); case 'week': return d.startOf('week').format('YYYY-MM-DD'); // 周一日期作为 key case 'month': return d.format('YYYY-MM'); case 'year': return d.format('YYYY'); } } /** * 按粒度生成时间桶并填充事件流。 * 同时累加返回累计 ROI 曲线。 */ export async function timeseries( projectId: string, from: Date, to: Date, granularity: Granularity ): Promise { // 截断到 launchedAt,跟 aggregate 保持一致 const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1); if (project?.launchedAt) { const launchedAt = project.launchedAt instanceof Date ? project.launchedAt : new Date(project.launchedAt); if (from < launchedAt) from = launchedAt; } const [costEvents, revenueEvents] = await Promise.all([ db.select().from(projectCostEvents).where(and( eq(projectCostEvents.projectId, projectId), gte(projectCostEvents.eventDate, from), lte(projectCostEvents.eventDate, to) )).orderBy(asc(projectCostEvents.eventDate)), db.select().from(projectRevenueEvents).where(and( eq(projectRevenueEvents.projectId, projectId), gte(projectRevenueEvents.eventDate, from), lte(projectRevenueEvents.eventDate, to) )).orderBy(asc(projectRevenueEvents.eventDate)), ]); // 收集所有桶 key,初始化为 0 const buckets = new Map(); for (const e of costEvents) { const k = bucketKey(toDate(e.eventDate), granularity); if (!buckets.has(k)) buckets.set(k, { cost: 0, revenue: 0 }); buckets.get(k)!.cost += e.amount; } for (const e of revenueEvents) { const k = bucketKey(toDate(e.eventDate), granularity); if (!buckets.has(k)) buckets.set(k, { cost: 0, revenue: 0 }); buckets.get(k)!.revenue += e.amount; } // 按 key 排序输出 const sortedKeys = Array.from(buckets.keys()).sort(); let cumCost = 0, cumRevenue = 0; return sortedKeys.map(k => { const { cost, revenue } = buckets.get(k)!; cumCost += cost; cumRevenue += revenue; return { bucket: k, cost: round2(cost), revenue: round2(revenue), net: round2(revenue - cost), cumulativeCost: round2(cumCost), cumulativeRevenue: round2(cumRevenue), cumulativeRoi: cumCost > 0 ? round2((cumRevenue - cumCost) / cumCost * 100) : null, }; }); } function toDate(v: any): Date { return v instanceof Date ? v : new Date(v); } function round2(n: number): number { return Math.round(n * 100) / 100; }