后端: - 事件流模型(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>
98 lines
3.2 KiB
TypeScript
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;
|
|
}
|