devperf/backend/src/services/roi/timeseries.ts
zyc 5af612e3fd feat(roi): ROI 动态规则引擎 v1 + 业务体系归属
后端:
- 事件流模型(project_cost_events / project_revenue_events)+ launchedAt 截断
- 3 大业务体系归属(airhubs/airflow/aircore) + 项目类型(hw/sw) + identifier 自动生成
- AI 三件套推荐(category + bizSystem + projectType)
- 营收 mock API + 外部对接规范 + 资产摊销 cron
- 5 个 migration(0003 ROI 引擎 / 0004 driver factors / 0005 biz system)
- 单测 11/11 过

前端:
- 项目级 ROI 看板:4 KPI 卡片 + 折线图(周/月/年)+ 成本/产出事件流并排
- 全公司决策罗盘:3 大 ROI 指标 + 业务线堆叠 + 分类筛选 chip
- 项目列表 + 侧边栏:按产品线分组(可折叠 + localStorage 持久化)
- Admin: ROI 策略配置 + 项目映射 + 未映射收容

数据:
- 23 项目全部 AI 自动分类 + 自动 identifier(airhubs-hw-001 这种)
- launchedAt 按各项目首次 commit 时间设置

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:20:22 +08:00

98 lines
3.2 KiB
TypeScript

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<TimeseriesBucket[]> {
// 截断到 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<string, { cost: number; revenue: number }>();
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;
}