diff --git a/backend/drizzle/0003_add_roi_engine.sql b/backend/drizzle/0003_add_roi_engine.sql new file mode 100644 index 0000000..67449da --- /dev/null +++ b/backend/drizzle/0003_add_roi_engine.sql @@ -0,0 +1,122 @@ +-- ROI 动态规则引擎(v2.0) — 事件流模型 +-- 包含: projects 扩展字段、roi_strategies、project_cost_events、project_revenue_events、 +-- project_revenue_mapping、unmapped_revenue_events、sync_logs.source 枚举扩展 + +-- ── 1. 扩展 projects 表 ── +ALTER TABLE `projects` ADD COLUMN `category` enum('cash_cow','efficiency_tool','moat','composite') NULL; +--> statement-breakpoint +ALTER TABLE `projects` ADD COLUMN `composite_strategies` json NULL; +--> statement-breakpoint +ALTER TABLE `projects` ADD COLUMN `owner_id` varchar(50) NULL; +--> statement-breakpoint +ALTER TABLE `projects` ADD COLUMN `tags` json NULL; +--> statement-breakpoint +ALTER TABLE `projects` ADD COLUMN `launched_at` datetime NULL; +--> statement-breakpoint +ALTER TABLE `projects` ADD COLUMN `v_asset` double NULL; +--> statement-breakpoint +CREATE INDEX `idx_projects_category` ON `projects` (`category`); +--> statement-breakpoint + +-- ── 2. 扩展 sync_logs.source 枚举 ── +ALTER TABLE `sync_logs` MODIFY COLUMN `source` enum('plane','gitea','ai_okr','roi_cost_ingest','roi_revenue_ingest','roi_amortizer','roi_ai_driver') NOT NULL; +--> statement-breakpoint + +-- ── 3. roi_strategies ── +CREATE TABLE IF NOT EXISTS `roi_strategies` ( + `id` varchar(50) NOT NULL PRIMARY KEY, + `category` enum('cash_cow','efficiency_tool','moat','composite') NOT NULL, + `name` varchar(200) NOT NULL, + `formula_key` varchar(100) NOT NULL, + `params` json NOT NULL, + `updated_at` datetime NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uniq_roi_strategy_category` ON `roi_strategies` (`category`); +--> statement-breakpoint + +-- ── 4. project_cost_events ── +CREATE TABLE IF NOT EXISTS `project_cost_events` ( + `id` varchar(50) NOT NULL PRIMARY KEY, + `project_id` varchar(50) NOT NULL, + `event_date` datetime NOT NULL, + `cost_type` enum('dev_hours','hardware_bom','service_fee','amortization','other') NOT NULL, + `amount` double NOT NULL, + `hours` double NULL, + `hourly_rate_used` double NULL, + `data_source` enum('auto_commits','auto_tasks','plane_actual','manual','amortization_cron') NOT NULL, + `ref_type` varchar(50) NULL, + `ref_id` varchar(200) NULL, + `notes` text NULL, + `created_by` varchar(50) NULL, + `created_at` datetime NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_cost_events_project_date` ON `project_cost_events` (`project_id`, `event_date`); +--> statement-breakpoint +CREATE UNIQUE INDEX `uniq_cost_events_ref` ON `project_cost_events` (`project_id`, `ref_type`, `ref_id`); +--> statement-breakpoint + +-- ── 5. project_revenue_events ── +CREATE TABLE IF NOT EXISTS `project_revenue_events` ( + `id` varchar(50) NOT NULL PRIMARY KEY, + `project_id` varchar(50) NOT NULL, + `event_date` datetime NOT NULL, + `revenue_type` enum('direct_revenue','subscription','saved_cost','asset_value_add','refund','other') NOT NULL, + `amount` double NOT NULL, + `data_source` enum('api_pulled','manual','calculated','mock') NOT NULL, + `ref_type` varchar(50) NULL, + `ref_id` varchar(200) NULL, + `channel` varchar(50) NULL, + `notes` text NULL, + `created_by` varchar(50) NULL, + `created_at` datetime NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_revenue_events_project_date` ON `project_revenue_events` (`project_id`, `event_date`); +--> statement-breakpoint +CREATE UNIQUE INDEX `uniq_revenue_events_ref` ON `project_revenue_events` (`project_id`, `ref_type`, `ref_id`); +--> statement-breakpoint + +-- ── 6. project_revenue_mapping ── +CREATE TABLE IF NOT EXISTS `project_revenue_mapping` ( + `id` varchar(50) NOT NULL PRIMARY KEY, + `project_id` varchar(50) NOT NULL, + `business_project_key` varchar(100) NOT NULL, + `enabled` int NULL DEFAULT 1, + `notes` text NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uniq_revenue_mapping_business_key` ON `project_revenue_mapping` (`business_project_key`); +--> statement-breakpoint +CREATE INDEX `idx_revenue_mapping_project` ON `project_revenue_mapping` (`project_id`); +--> statement-breakpoint + +-- ── 7. unmapped_revenue_events ── +CREATE TABLE IF NOT EXISTS `unmapped_revenue_events` ( + `id` varchar(50) NOT NULL PRIMARY KEY, + `external_id` varchar(200) NOT NULL, + `business_project_key` varchar(100) NOT NULL, + `event_date` datetime NOT NULL, + `amount` double NOT NULL, + `revenue_type` varchar(50) NULL, + `channel` varchar(50) NULL, + `raw_payload` json NULL, + `status` enum('pending','resolved','ignored') NULL DEFAULT 'pending', + `resolved_event_id` varchar(50) NULL, + `created_at` datetime NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uniq_unmapped_external_id` ON `unmapped_revenue_events` (`external_id`); +--> statement-breakpoint +CREATE INDEX `idx_unmapped_status` ON `unmapped_revenue_events` (`status`); +--> statement-breakpoint + +-- ── 8. seed: 4 套默认策略参数 ── +INSERT INTO `roi_strategies` (`id`, `category`, `name`, `formula_key`, `params`, `updated_at`) VALUES + ('strat-cash-cow', 'cash_cow', '现金牛', 'cash_cow', '{"hourlyRate":400,"commitHourCoef":0.5,"taskHourCoef":6}', NOW()), + ('strat-efficiency-tool', 'efficiency_tool', '效能工具', 'efficiency_tool', '{"hourlyRate":400,"commitHourCoef":0.5,"taskHourCoef":6}', NOW()), + ('strat-moat', 'moat', '资本护城河', 'moat', '{"hourlyRate":400,"amortYears":3,"commitHourCoef":0.5,"taskHourCoef":6}', NOW()), + ('strat-composite', 'composite', '复合型', 'composite', '{"hourlyRate":400,"amortYears":3,"commitHourCoef":0.5,"taskHourCoef":6}', NOW()); diff --git a/backend/drizzle/0004_add_roi_driver_factors.sql b/backend/drizzle/0004_add_roi_driver_factors.sql new file mode 100644 index 0000000..43d32ee --- /dev/null +++ b/backend/drizzle/0004_add_roi_driver_factors.sql @@ -0,0 +1,12 @@ +-- ROI 引擎 AI 驱动因子文案表(月度快照) + +CREATE TABLE IF NOT EXISTS `roi_driver_factors` ( + `id` varchar(50) NOT NULL PRIMARY KEY, + `project_id` varchar(50) NOT NULL, + `period_key` varchar(20) NOT NULL, + `factors` json NOT NULL, + `context` json NULL, + `generated_at` datetime NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uniq_driver_factors_project_period` ON `roi_driver_factors` (`project_id`, `period_key`); diff --git a/backend/drizzle/0005_add_biz_system.sql b/backend/drizzle/0005_add_biz_system.sql new file mode 100644 index 0000000..961c5b0 --- /dev/null +++ b/backend/drizzle/0005_add_biz_system.sql @@ -0,0 +1,16 @@ +-- 业务体系归属字段:bizSystem + projectType + planeIdentifier 备份 + +ALTER TABLE `projects` ADD COLUMN `biz_system` enum('airhubs','airflow','aircore') NULL; +--> statement-breakpoint +ALTER TABLE `projects` ADD COLUMN `project_type` enum('hardware','software') NULL; +--> statement-breakpoint +ALTER TABLE `projects` ADD COLUMN `plane_identifier` varchar(200) NULL; +--> statement-breakpoint + +-- 把现有 identifier 一次性备份到 plane_identifier(以防 AI 覆盖后丢失) +UPDATE `projects` SET `plane_identifier` = `identifier` WHERE `plane_identifier` IS NULL; +--> statement-breakpoint + +CREATE INDEX `idx_projects_biz_system` ON `projects` (`biz_system`); +--> statement-breakpoint +CREATE INDEX `idx_projects_project_type` ON `projects` (`project_type`); diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 5678ec7..ca3707c 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -22,6 +22,27 @@ "when": 1777430400000, "tag": "0002_add_ai_okr_fields", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1779494400000, + "tag": "0003_add_roi_engine", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1779580800000, + "tag": "0004_add_roi_driver_factors", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1779667200000, + "tag": "0005_add_biz_system", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/scripts/ai-classify-all.ts b/backend/scripts/ai-classify-all.ts new file mode 100644 index 0000000..fc069ac --- /dev/null +++ b/backend/scripts/ai-classify-all.ts @@ -0,0 +1,67 @@ +/** + * 一次性脚本:对所有项目跑 AI 三件套分类(category + bizSystem + projectType), + * 同时自动生成新 identifier(airhubs-hw-001 这种)+ 同步更新 mapping。 + * + * 用法: + * bun run scripts/ai-classify-all.ts # 仅处理未完整分类的项目 + * bun run scripts/ai-classify-all.ts --force # 强制重跑所有项目(覆盖) + */ +import dayjs from 'dayjs'; +import { eq } from 'drizzle-orm'; +import { db } from '../src/db/index'; +import { projects } from '../src/db/schema'; +import { suggestProjectTag } from '../src/services/roi/ai-tag-suggester'; +import { applyAutoIdentifier } from '../src/services/roi/identifier-generator'; + +const force = process.argv.includes('--force'); + +const all = await db.select().from(projects); +console.log(`Total projects: ${all.length}, force=${force}`); + +let okCount = 0, skipCount = 0, failCount = 0; +const startedAt = Date.now(); + +for (const p of all) { + const label = `${p.planeIdentifier || p.identifier || '?'} (${p.name})`; + + const alreadyFull = p.category && p.bizSystem && p.projectType; + if (!force && alreadyFull) { + console.log(` ⊘ SKIP ${label} — fully classified (${p.bizSystem}/${p.projectType}/${p.category})`); + skipCount += 1; + continue; + } + + try { + console.log(` → AI ${label} ...`); + const sug = await suggestProjectTag(p.id); + const launchedAt = p.launchedAt ?? p.createdAt ?? dayjs().subtract(90, 'day').toDate(); + + const needsAsset = sug.suggestedCategory === 'moat'; + const vAsset = needsAsset ? (p.vAsset ?? 100_000) : (p.vAsset ?? null); + + // 1. 更新分类字段 + await db.update(projects).set({ + category: sug.suggestedCategory, + launchedAt: launchedAt as any, + vAsset: vAsset, + updatedAt: new Date(), + }).where(eq(projects.id, p.id)); + + // 2. 自动生成新 identifier + 同步 mapping + const newId = await applyAutoIdentifier(p.id, sug.suggestedBizSystem, sug.suggestedProjectType); + + console.log(` ✓ ${newId} | ${sug.suggestedBizSystem}/${sug.suggestedProjectType}/${sug.suggestedCategory} conf=${sug.confidence}`); + console.log(` ↳ ${sug.reasoning.slice(0, 80)}`); + okCount += 1; + } catch (e) { + console.error(` ✗ FAIL ${label}: ${(e as Error).message.slice(0, 200)}`); + failCount += 1; + } + + await new Promise(r => setTimeout(r, 1000)); +} + +const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); +console.log(''); +console.log(`Done. ok=${okCount} skipped=${skipCount} failed=${failCount} elapsed=${elapsed}s`); +process.exit(0); diff --git a/backend/scripts/ai-tag-all.ts b/backend/scripts/ai-tag-all.ts new file mode 100644 index 0000000..7cb3f9e --- /dev/null +++ b/backend/scripts/ai-tag-all.ts @@ -0,0 +1,60 @@ +/** + * 一次性脚本:用 AI 给所有未打标的项目自动建议 + 应用 category。 + * 已打标的项目跳过(避免覆盖人工决策)。 + * - 默认立项日:取项目 created_at(若有)否则用 today-90天 + * - 护城河项目需要 V_asset,AI 推荐 moat 时默认填 100,000(占位,管理员后续手填) + * 用法: bun run scripts/ai-tag-all.ts [--force] + */ +import dayjs from 'dayjs'; +import { eq } from 'drizzle-orm'; +import { db } from '../src/db/index'; +import { projects } from '../src/db/schema'; +import { suggestProjectTag } from '../src/services/roi/ai-tag-suggester'; + +const force = process.argv.includes('--force'); + +const all = await db.select().from(projects); +console.log(`Total projects: ${all.length}, force=${force}`); + +let okCount = 0, skipCount = 0, failCount = 0; +const startedAt = Date.now(); + +for (const p of all) { + const label = `${p.identifier || '?'} (${p.name})`; + if (!force && p.category) { + console.log(` ⊘ SKIP ${label} — already tagged as ${p.category}`); + skipCount += 1; + continue; + } + + try { + console.log(` → AI ${label} ...`); + const sug = await suggestProjectTag(p.id); + const launchedAt = p.launchedAt ?? p.createdAt ?? dayjs().subtract(90, 'day').toDate(); + + // 护城河默认 V_asset 占位 + const needsAsset = sug.suggestedCategory === 'moat'; + const vAsset = needsAsset ? (p.vAsset ?? 100_000) : (p.vAsset ?? null); + + await db.update(projects).set({ + category: sug.suggestedCategory, + launchedAt: launchedAt as any, + vAsset: vAsset, + updatedAt: new Date(), + }).where(eq(projects.id, p.id)); + + console.log(` ✓ ${sug.suggestedCategory} (conf=${sug.confidence}) — ${sug.reasoning.slice(0, 60)}`); + okCount += 1; + } catch (e) { + console.error(` ✗ FAIL ${label}: ${(e as Error).message.slice(0, 200)}`); + failCount += 1; + } + + // 1 秒间隔避免 LLM 限流 + await new Promise(r => setTimeout(r, 1000)); +} + +const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); +console.log(''); +console.log(`Done. ok=${okCount} skipped=${skipCount} failed=${failCount} elapsed=${elapsed}s`); +process.exit(0); diff --git a/backend/scripts/backfill-cost-events.ts b/backend/scripts/backfill-cost-events.ts new file mode 100644 index 0000000..dc8e58a --- /dev/null +++ b/backend/scripts/backfill-cost-events.ts @@ -0,0 +1,15 @@ +/** + * 一次性脚本:回填过去 N 天的 cost_events(基于已同步的 commits/tasks) + * 用法: bun run scripts/backfill-cost-events.ts [days=60] + */ +import dayjs from 'dayjs'; +import { runCostEventIngest } from '../src/services/roi/cost-ingest'; + +const days = Number(process.argv[2] || 60); +const from = dayjs().subtract(days, 'day').startOf('day').toDate(); +const to = dayjs().endOf('day').toDate(); + +console.log(`Backfilling cost events from ${from.toISOString()} to ${to.toISOString()}...`); +await runCostEventIngest({ from, to }); +console.log('Done.'); +process.exit(0); diff --git a/backend/scripts/backfill-launched-at.ts b/backend/scripts/backfill-launched-at.ts new file mode 100644 index 0000000..a6e6359 --- /dev/null +++ b/backend/scripts/backfill-launched-at.ts @@ -0,0 +1,66 @@ +/** + * 一次性脚本:把所有项目的 launchedAt 改成「该项目最早一次 commit 时间」。 + * 没有绑定 repo / repo 里无 commit 的项目,默认 2026-01-01。 + * + * 用法:bun run scripts/backfill-launched-at.ts + */ +import { asc, eq, inArray } from 'drizzle-orm'; +import { db } from '../src/db/index'; +import { projects, projectRepos, gitCommits } from '../src/db/schema'; + +const DEFAULT_DATE = new Date('2026-01-01T00:00:00+08:00'); + +/** 抹除 .git 后缀和 URL 前缀,只保留仓库名 */ +function normalizeRepoName(raw: string): string { + let cleaned = raw.trim().replace(/\.git$/, ''); + if (cleaned.includes('://')) { + try { + const parts = new URL(cleaned).pathname.split('/').filter(Boolean); + return parts[parts.length - 1] || cleaned; + } catch { /* fallthrough */ } + } + if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned; + return cleaned; +} + +const all = await db.select().from(projects); +console.log(`Total projects: ${all.length}`); + +let withCommitsCount = 0, fallbackCount = 0; + +for (const p of all) { + const repos = await db.select().from(projectRepos).where(eq(projectRepos.projectId, p.id)); + const repoNames = repos.map(r => normalizeRepoName(r.repoName)); + + let launchedAt = DEFAULT_DATE; + let source = 'default-2026-01-01'; + + if (repoNames.length > 0) { + const earliest = await db.select({ committedAt: gitCommits.committedAt, repoName: gitCommits.repoName, sha: gitCommits.sha }) + .from(gitCommits) + .where(inArray(gitCommits.repoName, repoNames)) + .orderBy(asc(gitCommits.committedAt)) + .limit(1); + if (earliest.length > 0 && earliest[0].committedAt) { + launchedAt = earliest[0].committedAt instanceof Date ? earliest[0].committedAt : new Date(earliest[0].committedAt); + source = `first commit ${earliest[0].repoName}/${earliest[0].sha?.slice(0, 7)}`; + withCommitsCount += 1; + } else { + fallbackCount += 1; + } + } else { + fallbackCount += 1; + } + + await db.update(projects).set({ + launchedAt, + updatedAt: new Date(), + }).where(eq(projects.id, p.id)); + + const label = `${p.identifier || p.id} (${p.name})`; + console.log(` ${label.padEnd(50)} → ${launchedAt.toISOString().slice(0, 10)} [${source}]`); +} + +console.log(''); +console.log(`Done. with-commits=${withCommitsCount} fallback=${fallbackCount}`); +process.exit(0); diff --git a/backend/scripts/backfill-revenue.ts b/backend/scripts/backfill-revenue.ts new file mode 100644 index 0000000..b998454 --- /dev/null +++ b/backend/scripts/backfill-revenue.ts @@ -0,0 +1,44 @@ +/** + * 一次性脚本:从 mock API 回填过去 N 天的营收事件。 + * 同时给所有项目自动 seed project_revenue_mapping(identifier 作 businessProjectKey)。 + * 用法: bun run scripts/backfill-revenue.ts [days=60] + */ +import { v4 as uuid } from 'uuid'; +import dayjs from 'dayjs'; +import { eq, inArray } from 'drizzle-orm'; +import { db } from '../src/db/index'; +import { projects, projectRevenueMapping } from '../src/db/schema'; +import { runRevenueIngest } from '../src/services/roi/revenue-ingest'; + +const days = Number(process.argv[2] || 60); + +// 1. seed mapping: identifier → projectId(没有就建) +const all = await db.select().from(projects); +const existing = await db.select().from(projectRevenueMapping); +const existingKeys = new Set(existing.map(m => m.businessProjectKey)); + +const toInsert = all + .filter(p => p.identifier && !existingKeys.has(p.identifier)) + .map(p => ({ + id: uuid(), + projectId: p.id, + businessProjectKey: p.identifier!, + enabled: 1, + notes: 'auto-seeded', + createdAt: new Date(), + updatedAt: new Date(), + })); + +if (toInsert.length > 0) { + await db.insert(projectRevenueMapping).values(toInsert); + console.log(`Seeded ${toInsert.length} project mappings`); +} + +// 2. 逐日拉 mock 数据 +for (let i = days; i >= 0; i--) { + const date = dayjs().subtract(i, 'day').format('YYYY-MM-DD'); + await runRevenueIngest(date); + if (i % 10 === 0) console.log(` → ${date}`); +} +console.log('Done.'); +process.exit(0); diff --git a/backend/src/config.ts b/backend/src/config.ts index 124a5e1..19c5918 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -29,6 +29,11 @@ const envSchema = z.object({ AI_API_KEY: z.string().default(''), AI_MODEL: z.string().default('doubao-seed-2-0-pro-260215'), AI_BASE_URL: z.string().default('https://ark.cn-beijing.volces.com/api/v3'), + + // ROI 外部营收 API + MOCK_REVENUE_API: z.coerce.boolean().default(false), + REVENUE_API_BASE_URL: z.string().default('http://localhost:3200/mock'), + REVENUE_API_KEY: z.string().default('mock-dev-key-12345'), }); function loadConfig() { diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index fc9c8ce..cb4b30b 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -35,10 +35,21 @@ export const projects = mysqlTable('projects', { name: varchar('name', { length: 200 }).notNull(), identifier: varchar('identifier', { length: 200 }), lastSyncedAt: datetime('last_synced_at'), + // ── ROI 引擎字段 ── + category: mysqlEnum('category', ['cash_cow', 'efficiency_tool', 'moat', 'composite']), + compositeStrategies: json('composite_strategies'), // 复合型时勾选的子策略列表 + bizSystem: mysqlEnum('biz_system', ['airhubs', 'airflow', 'aircore']), // 三大业务体系归属 + projectType: mysqlEnum('project_type', ['hardware', 'software']), // 硬件/软件 + planeIdentifier: varchar('plane_identifier', { length: 200 }), // 原始 Plane identifier 备份 + ownerId: varchar('owner_id', { length: 50 }).references(() => users.id), + tags: json('tags'), + launchedAt: datetime('launched_at'), // 立项日 — 累计 ROI 起算点 + vAsset: double('v_asset'), // 资本护城河的技术资产估值(元) createdAt: datetime('created_at').notNull(), updatedAt: datetime('updated_at').notNull(), }, (table) => ({ planeProjectIdx: uniqueIndex('uniq_projects_plane_id').on(table.planeProjectId), + categoryIdx: index('idx_projects_category').on(table.category), })); // ── Sprint Snapshots ── @@ -236,7 +247,7 @@ export const userProjectPermissions = mysqlTable('user_project_permissions', { // ── Sync Logs ── export const syncLogs = mysqlTable('sync_logs', { id: varchar('id', { length: 50 }).primaryKey(), - source: mysqlEnum('source', ['plane', 'gitea', 'ai_okr']).notNull(), + source: mysqlEnum('source', ['plane', 'gitea', 'ai_okr', 'roi_cost_ingest', 'roi_revenue_ingest', 'roi_amortizer', 'roi_ai_driver']).notNull(), status: mysqlEnum('status', ['success', 'error']).notNull(), message: text('message'), recordsProcessed: int('records_processed').default(0), @@ -252,3 +263,103 @@ export const aiAnalyzedCommits = mysqlTable('ai_analyzed_commits', { }, (table) => ({ shaIdx: uniqueIndex('uniq_analyzed_sha').on(table.commitSha), })); + +// ─────────────────────────────────────────────────────────── +// ROI 动态规则引擎(v2.0) — 事件流模型 +// ─────────────────────────────────────────────────────────── + +// ── ROI 策略配置库(全局参数,4 分类各一行) ── +export const roiStrategies = mysqlTable('roi_strategies', { + id: varchar('id', { length: 50 }).primaryKey(), + category: mysqlEnum('category', ['cash_cow', 'efficiency_tool', 'moat', 'composite']).notNull(), + name: varchar('name', { length: 200 }).notNull(), + formulaKey: varchar('formula_key', { length: 100 }).notNull(), + // params 例: { hourlyRate: 400, amortYears: 3, commitHourCoef: 0.5, taskHourCoef: 6 } + params: json('params').notNull(), + updatedAt: datetime('updated_at').notNull(), +}, (table) => ({ + categoryIdx: uniqueIndex('uniq_roi_strategy_category').on(table.category), +})); + +// ── 项目成本事件流 ── +export const projectCostEvents = mysqlTable('project_cost_events', { + id: varchar('id', { length: 50 }).primaryKey(), + projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(), + eventDate: datetime('event_date').notNull(), // 营收发生的自然日(精确到日) + costType: mysqlEnum('cost_type', ['dev_hours', 'hardware_bom', 'service_fee', 'amortization', 'other']).notNull(), + amount: double('amount').notNull(), // 已折算成 CNY 的金额 + hours: double('hours'), // 工时(仅 cost_type=dev_hours 时填,辅助溯源) + hourlyRateUsed: double('hourly_rate_used'), // 计算时使用的 R_h 快照 + dataSource: mysqlEnum('data_source', ['auto_commits', 'auto_tasks', 'plane_actual', 'manual', 'amortization_cron']).notNull(), + refType: varchar('ref_type', { length: 50 }), // 'git_commit' | 'plane_task' | 'manual' + refId: varchar('ref_id', { length: 200 }), // 关联唯一 id(防重) + notes: text('notes'), + createdBy: varchar('created_by', { length: 50 }).references(() => users.id), + createdAt: datetime('created_at').notNull(), +}, (table) => ({ + projectDateIdx: index('idx_cost_events_project_date').on(table.projectId, table.eventDate), + refUniqIdx: uniqueIndex('uniq_cost_events_ref').on(table.projectId, table.refType, table.refId), +})); + +// ── 项目产出事件流 ── +export const projectRevenueEvents = mysqlTable('project_revenue_events', { + id: varchar('id', { length: 50 }).primaryKey(), + projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(), + eventDate: datetime('event_date').notNull(), + revenueType: mysqlEnum('revenue_type', ['direct_revenue', 'subscription', 'saved_cost', 'asset_value_add', 'refund', 'other']).notNull(), + amount: double('amount').notNull(), // 可负数(退款/冲账) + dataSource: mysqlEnum('data_source', ['api_pulled', 'manual', 'calculated', 'mock']).notNull(), + refType: varchar('ref_type', { length: 50 }), + refId: varchar('ref_id', { length: 200 }), + channel: varchar('channel', { length: 50 }), // 渠道:alipay/wechat/stripe 等 + notes: text('notes'), + createdBy: varchar('created_by', { length: 50 }).references(() => users.id), + createdAt: datetime('created_at').notNull(), +}, (table) => ({ + projectDateIdx: index('idx_revenue_events_project_date').on(table.projectId, table.eventDate), + refUniqIdx: uniqueIndex('uniq_revenue_events_ref').on(table.projectId, table.refType, table.refId), +})); + +// ── 业务系统 → DevPerf 项目映射 ── +export const projectRevenueMapping = mysqlTable('project_revenue_mapping', { + id: varchar('id', { length: 50 }).primaryKey(), + projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(), + businessProjectKey: varchar('business_project_key', { length: 100 }).notNull(), + enabled: int('enabled').default(1), + notes: text('notes'), + createdAt: datetime('created_at').notNull(), + updatedAt: datetime('updated_at').notNull(), +}, (table) => ({ + businessKeyIdx: uniqueIndex('uniq_revenue_mapping_business_key').on(table.businessProjectKey), + projectIdx: index('idx_revenue_mapping_project').on(table.projectId), +})); + +// ── AI 生成的价值驱动因子文案(月度快照) ── +export const roiDriverFactors = mysqlTable('roi_driver_factors', { + id: varchar('id', { length: 50 }).primaryKey(), + projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(), + periodKey: varchar('period_key', { length: 20 }).notNull(), // YYYY-MM (上月) 或 'lifetime' + factors: json('factors').notNull(), // [{type, text}] + context: json('context'), // 当时的 ROI 数值快照 + generatedAt: datetime('generated_at').notNull(), +}, (table) => ({ + projectPeriodIdx: uniqueIndex('uniq_driver_factors_project_period').on(table.projectId, table.periodKey), +})); + +// ── 未映射的营收事件(收容表) ── +export const unmappedRevenueEvents = mysqlTable('unmapped_revenue_events', { + id: varchar('id', { length: 50 }).primaryKey(), + externalId: varchar('external_id', { length: 200 }).notNull(), + businessProjectKey: varchar('business_project_key', { length: 100 }).notNull(), + eventDate: datetime('event_date').notNull(), + amount: double('amount').notNull(), + revenueType: varchar('revenue_type', { length: 50 }), + channel: varchar('channel', { length: 50 }), + rawPayload: json('raw_payload'), + status: mysqlEnum('status', ['pending', 'resolved', 'ignored']).default('pending'), + resolvedEventId: varchar('resolved_event_id', { length: 50 }), // 解决后关联到 revenue_events.id + createdAt: datetime('created_at').notNull(), +}, (table) => ({ + externalIdx: uniqueIndex('uniq_unmapped_external_id').on(table.externalId), + statusIdx: index('idx_unmapped_status').on(table.status), +})); diff --git a/backend/src/index.ts b/backend/src/index.ts index 4d30fd3..d4adfd7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,8 @@ import { memberRoutes } from './routes/members'; import { okrRoutes } from './routes/okr'; import { gitRoutes } from './routes/git'; import { adminRoutes } from './routes/admin'; +import { mockRevenueRoutes } from './routes/mock-revenue'; +import { roiRoutes } from './routes/roi'; // Importing db triggers auto-migration on first load (B-07 fix) import { db } from './db/index'; import { seedAdminUser } from './db/seed-auto'; @@ -44,6 +46,12 @@ app.get('/api/health', (c) => { // Auth routes (public) app.route('/api/auth', authRoutes); +// Mock 营收 API (开发期,挂在 /mock 避开 /api/* 的 JWT auth) +if (config.MOCK_REVENUE_API) { + app.route('/mock', mockRevenueRoutes); + console.info('[Mock] Revenue API mock mounted at /mock/revenue/*'); +} + // Protected routes app.use('/api/*', authMiddleware); app.route('/api', overviewRoutes); @@ -52,6 +60,7 @@ app.route('/api', memberRoutes); app.route('/api', okrRoutes); app.route('/api', gitRoutes); app.route('/api', adminRoutes); +app.route('/api', roiRoutes); // Error handler app.onError(errorHandler); diff --git a/backend/src/middleware/ai-rate-limit.ts b/backend/src/middleware/ai-rate-limit.ts new file mode 100644 index 0000000..0d39256 --- /dev/null +++ b/backend/src/middleware/ai-rate-limit.ts @@ -0,0 +1,55 @@ +import type { MiddlewareHandler } from 'hono'; +import { AppError } from './error-handler'; + +/** + * 简易内存级 AI 调用限流。 + * - perUserPerMinute: 每用户每分钟最多 N 次 + * - perProjectPerDay: 每项目每天最多 M 次(从 query/param 取 projectId) + * + * 多实例部署时,各实例独立计数(不严格,但有限流意义)。 + */ +interface Counter { + count: number; + windowStart: number; +} + +const userMinuteCounters = new Map(); +const projectDayCounters = new Map(); + +function tick(map: Map, key: string, windowMs: number, limit: number): boolean { + const now = Date.now(); + const c = map.get(key); + if (!c || now - c.windowStart >= windowMs) { + map.set(key, { count: 1, windowStart: now }); + return true; + } + if (c.count >= limit) return false; + c.count += 1; + return true; +} + +export function aiRateLimit(opts: { + perUserPerMinute?: number; + perProjectPerDay?: number; + projectIdParam?: string; // 哪个 param 是 projectId,默认 'id' +} = {}): MiddlewareHandler { + const perUser = opts.perUserPerMinute ?? 5; + const perProject = opts.perProjectPerDay ?? 20; + const projectParam = opts.projectIdParam ?? 'id'; + + return async (c, next) => { + const user = c.get('user'); + if (!user) throw new AppError(40101, 'Authentication required', 401); + + if (!tick(userMinuteCounters, user.sub, 60_000, perUser)) { + throw new AppError(42901, `AI 调用过于频繁,每分钟最多 ${perUser} 次`, 429); + } + const projectId = c.req.param(projectParam); + if (projectId) { + if (!tick(projectDayCounters, projectId, 24 * 3600_000, perProject)) { + throw new AppError(42902, `该项目今日 AI 调用已达上限 ${perProject} 次`, 429); + } + } + await next(); + }; +} diff --git a/backend/src/middleware/project-access.ts b/backend/src/middleware/project-access.ts new file mode 100644 index 0000000..d547f54 --- /dev/null +++ b/backend/src/middleware/project-access.ts @@ -0,0 +1,35 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../db/index'; +import { projects, userProjectPermissions } from '../db/schema'; +import type { JWTPayload } from './auth'; + +/** + * 写权限: admin 全通过;否则必须是项目 owner。 + * (首期不开放细粒度协作者写权限,需要时扩展 userProjectPermissions 加 can_write 字段) + */ +export async function hasProjectWriteAccess(user: JWTPayload, projectId: string): Promise { + if (user.role === 'admin') return true; + const [project] = await db.select({ ownerId: projects.ownerId }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + return project?.ownerId === user.sub; +} + +/** + * 读权限: admin/manager 全通过;developer/viewer 必须是 owner 或在 userProjectPermissions 里。 + */ +export async function hasProjectReadAccess(user: JWTPayload, projectId: string): Promise { + if (user.role === 'admin' || user.role === 'manager') return true; + + const [project] = await db.select({ ownerId: projects.ownerId }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + if (project?.ownerId === user.sub) return true; + + const perm = await db.select().from(userProjectPermissions) + .where(eq(userProjectPermissions.userId, user.sub)) + .limit(50); + return perm.some(p => p.projectId === projectId); +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index d056890..95502e8 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -125,6 +125,10 @@ adminRoutes.get('/admin/projects', async (c) => { id: p.id, name: p.name, identifier: p.identifier, + planeIdentifier: p.planeIdentifier || null, + bizSystem: p.bizSystem || null, + projectType: p.projectType || null, + category: p.category || null, planeProjectId: p.planeProjectId, createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt, })), diff --git a/backend/src/routes/mock-revenue.ts b/backend/src/routes/mock-revenue.ts new file mode 100644 index 0000000..abd3fb5 --- /dev/null +++ b/backend/src/routes/mock-revenue.ts @@ -0,0 +1,51 @@ +import { Hono } from 'hono'; +import { config } from '../config'; +import { generateMockRevenueForDate, listMockBusinessProjects } from '../services/roi/revenue-ingest/mock-generator'; + +/** + * Mock 营收 API,严格按"附录 A:外部营收 API 接入规范"实现。 + * 仅在 MOCK_REVENUE_API=true 时挂载,挂在 /mock(不在 /api/* 下,避开 JWT auth)。 + * + * 路由: + * GET /mock/revenue/daily?date=YYYY-MM-DD + * GET /mock/revenue/projects + */ +export const mockRevenueRoutes = new Hono(); + +mockRevenueRoutes.use('*', async (c, next) => { + // 鉴权:严格按附录 A 的 Bearer Token + const auth = c.req.header('Authorization') || ''; + const match = auth.match(/^Bearer\s+(.+)$/i); + if (!match || match[1] !== config.REVENUE_API_KEY) { + return c.json({ error: 'UNAUTHORIZED' }, 401); + } + await next(); +}); + +mockRevenueRoutes.get('/revenue/daily', async (c) => { + const date = c.req.query('date'); + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return c.json({ error: 'INVALID_DATE', message: 'date must be YYYY-MM-DD' }, 400); + } + + try { + const events = await generateMockRevenueForDate(date); + return c.json({ + date, + events, + nextCursor: null, // mock 不分页(数据量小) + totalCount: events.length, + }); + } catch (e) { + return c.json({ error: 'INTERNAL', message: (e as Error).message }, 500); + } +}); + +mockRevenueRoutes.get('/revenue/projects', async (c) => { + try { + const projects = await listMockBusinessProjects(); + return c.json({ projects, totalCount: projects.length }); + } catch (e) { + return c.json({ error: 'INTERNAL', message: (e as Error).message }, 500); + } +}); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 63cfe25..79ac0e6 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -26,7 +26,11 @@ projectRoutes.get('/projects', async (c) => { id: p.id, name: p.name, identifier: p.identifier, + planeIdentifier: p.planeIdentifier || null, planeProjectId: p.planeProjectId, + category: p.category || null, + bizSystem: p.bizSystem || null, + projectType: p.projectType || null, createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt, lastSyncedAt: p.lastSyncedAt?.toISOString() || null, })), @@ -363,7 +367,15 @@ projectRoutes.get('/projects/:id', async (c) => { id: project.id, name: project.name, identifier: project.identifier, + planeIdentifier: project.planeIdentifier || null, lastSyncedAt: project.lastSyncedAt?.toISOString() || null, + category: project.category || null, + compositeStrategies: project.compositeStrategies || null, + bizSystem: project.bizSystem || null, + projectType: project.projectType || null, + launchedAt: project.launchedAt instanceof Date ? project.launchedAt.toISOString() : project.launchedAt, + vAsset: project.vAsset ?? null, + ownerId: project.ownerId || null, }, currentCycle, milestones: milestoneData, diff --git a/backend/src/routes/roi.ts b/backend/src/routes/roi.ts new file mode 100644 index 0000000..fe2ba1f --- /dev/null +++ b/backend/src/routes/roi.ts @@ -0,0 +1,472 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { v4 as uuid } from 'uuid'; +import { and, desc, eq, gte, lte } from 'drizzle-orm'; +import dayjs from 'dayjs'; +import { db } from '../db/index'; +import { + projects, projectCostEvents, projectRevenueEvents, + roiStrategies, projectRevenueMapping, unmappedRevenueEvents, + roiDriverFactors, +} from '../db/schema'; +import { requireRole } from '../middleware/role'; +import { AppError } from '../middleware/error-handler'; +import { hasProjectReadAccess, hasProjectWriteAccess } from '../middleware/project-access'; +import { aiRateLimit } from '../middleware/ai-rate-limit'; +import { getAllowedProjectIds } from '../services/permissions'; +import { aggregate, aggregateMany } from '../services/roi/aggregator'; +import { timeseries, type Granularity } from '../services/roi/timeseries'; +import { invalidateStrategyCache } from '../services/roi/strategy-params'; +import { suggestProjectTag } from '../services/roi/ai-tag-suggester'; +import { applyAutoIdentifier } from '../services/roi/identifier-generator'; + +export const roiRoutes = new Hono(); + +// ────────────────────────────────────────── +// 核心查询接口 +// ────────────────────────────────────────── + +const aggregateQuerySchema = z.object({ + projectId: z.string().min(1), + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); + +// GET /api/roi/aggregate?projectId=&from=&to= +roiRoutes.get('/roi/aggregate', zValidator('query', aggregateQuerySchema), async (c) => { + const user = c.get('user'); + const { projectId, from, to } = c.req.valid('query'); + if (!(await hasProjectReadAccess(user, projectId))) { + throw new AppError(40103, 'No access to project', 403); + } + const fromDate = new Date(from + 'T00:00:00+08:00'); + const toDate = new Date(to + 'T23:59:59+08:00'); + const result = await aggregate(projectId, fromDate, toDate); + return c.json({ code: 0, data: result, message: 'success' }); +}); + +const timeseriesQuerySchema = aggregateQuerySchema.extend({ + granularity: z.enum(['day', 'week', 'month', 'year']).default('month'), +}); + +// GET /api/roi/timeseries?projectId=&from=&to=&granularity= +roiRoutes.get('/roi/timeseries', zValidator('query', timeseriesQuerySchema), async (c) => { + const user = c.get('user'); + const { projectId, from, to, granularity } = c.req.valid('query'); + if (!(await hasProjectReadAccess(user, projectId))) { + throw new AppError(40103, 'No access to project', 403); + } + const fromDate = new Date(from + 'T00:00:00+08:00'); + const toDate = new Date(to + 'T23:59:59+08:00'); + const buckets = await timeseries(projectId, fromDate, toDate, granularity as Granularity); + return c.json({ code: 0, data: buckets, message: 'success' }); +}); + +const dashboardQuerySchema = z.object({ + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); + +// GET /api/roi/dashboard?from=&to= — 全公司汇总(自动按权限过滤项目) +roiRoutes.get('/roi/dashboard', + requireRole('admin', 'manager'), + zValidator('query', dashboardQuerySchema), + async (c) => { + const user = c.get('user'); + const { from, to } = c.req.valid('query'); + const allowedIds = await getAllowedProjectIds(user); // admin/manager => null + + let allProjects = await db.select().from(projects); + if (allowedIds !== null) { + allProjects = allProjects.filter(p => allowedIds.includes(p.id)); + } + const projectIds = allProjects.map(p => p.id); + const fromDate = new Date(from + 'T00:00:00+08:00'); + const toDate = new Date(to + 'T23:59:59+08:00'); + + const results = await aggregateMany(projectIds, fromDate, toDate); + + // 按 category 分组汇总(给堆叠图用) + const byCategory: Record = {}; + let totalCost = 0, totalRevenue = 0; + + const projectMap = new Map(allProjects.map(p => [p.id, p])); + const projectCards = results.map(r => { + const p = projectMap.get(r.projectId); + const cat = p?.category || 'uncategorized'; + if (!byCategory[cat]) byCategory[cat] = { totalCost: 0, totalRevenue: 0, netProfit: 0, projectCount: 0 }; + byCategory[cat].totalCost += r.totalCost; + byCategory[cat].totalRevenue += r.totalRevenue; + byCategory[cat].netProfit += r.netProfit; + byCategory[cat].projectCount += 1; + totalCost += r.totalCost; + totalRevenue += r.totalRevenue; + return { + projectId: r.projectId, + name: p?.name || '', + identifier: p?.identifier || '', + category: p?.category || null, + totalCost: r.totalCost, + totalRevenue: r.totalRevenue, + roiValue: r.roiValue, + confidence: r.confidence, + }; + }); + + const companyRoi = totalCost > 0 ? Math.round((totalRevenue - totalCost) / totalCost * 10000) / 100 : null; + + return c.json({ + code: 0, + data: { + from, to, + summary: { + totalCost: Math.round(totalCost * 100) / 100, + totalRevenue: Math.round(totalRevenue * 100) / 100, + netProfit: Math.round((totalRevenue - totalCost) * 100) / 100, + roiValue: companyRoi, + projectCount: projectIds.length, + }, + byCategory, + projects: projectCards, + }, + message: 'success', + }); + }); + +// ────────────────────────────────────────── +// 事件流写入/查询 +// ────────────────────────────────────────── + +const createCostEventSchema = z.object({ + eventDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + costType: z.enum(['dev_hours', 'hardware_bom', 'service_fee', 'amortization', 'other']), + amount: z.number().min(0).max(1e8), + hours: z.number().min(0).optional().nullable(), + notes: z.string().max(500).optional().nullable(), +}); + +// POST /api/projects/:id/cost-events +roiRoutes.post('/projects/:id/cost-events', + zValidator('json', createCostEventSchema), + async (c) => { + const user = c.get('user'); + const projectId = c.req.param('id'); + if (!(await hasProjectWriteAccess(user, projectId))) { + throw new AppError(40103, 'No write access to project', 403); + } + const body = c.req.valid('json'); + + const id = uuid(); + await db.insert(projectCostEvents).values({ + id, + projectId, + eventDate: new Date(body.eventDate + 'T00:00:00+08:00'), + costType: body.costType, + amount: body.amount, + hours: body.hours ?? null, + hourlyRateUsed: null, + dataSource: 'manual', + refType: 'manual', + refId: id, // 手动事件用自己的 id 当 refId,保证唯一 + notes: body.notes ?? null, + createdBy: user.sub, + createdAt: new Date(), + }); + return c.json({ code: 0, data: { id }, message: 'success' }); + }); + +const createRevenueEventSchema = z.object({ + eventDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + revenueType: z.enum(['direct_revenue', 'subscription', 'saved_cost', 'asset_value_add', 'refund', 'other']), + amount: z.number().min(-1e8).max(1e8), + channel: z.string().max(50).optional().nullable(), + notes: z.string().max(500).optional().nullable(), +}); + +// POST /api/projects/:id/revenue-events +roiRoutes.post('/projects/:id/revenue-events', + zValidator('json', createRevenueEventSchema), + async (c) => { + const user = c.get('user'); + const projectId = c.req.param('id'); + if (!(await hasProjectWriteAccess(user, projectId))) { + throw new AppError(40103, 'No write access to project', 403); + } + const body = c.req.valid('json'); + + const id = uuid(); + await db.insert(projectRevenueEvents).values({ + id, + projectId, + eventDate: new Date(body.eventDate + 'T00:00:00+08:00'), + revenueType: body.revenueType, + amount: body.amount, + dataSource: 'manual', + refType: 'manual', + refId: id, + channel: body.channel ?? null, + notes: body.notes ?? null, + createdBy: user.sub, + createdAt: new Date(), + }); + return c.json({ code: 0, data: { id }, message: 'success' }); + }); + +const listEventsQuerySchema = z.object({ + type: z.enum(['cost', 'revenue']), + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + limit: z.coerce.number().min(1).max(500).default(100), +}); + +// GET /api/projects/:id/events?type=cost|revenue&from=&to= +roiRoutes.get('/projects/:id/events', zValidator('query', listEventsQuerySchema), async (c) => { + const user = c.get('user'); + const projectId = c.req.param('id'); + if (!(await hasProjectReadAccess(user, projectId))) { + throw new AppError(40103, 'No access to project', 403); + } + const { type, from, to, limit } = c.req.valid('query'); + let fromDate = from ? new Date(from + 'T00:00:00+08:00') : dayjs().subtract(90, 'day').toDate(); + const toDate = to ? new Date(to + 'T23:59:59+08:00') : new Date(); + + // 截断到 launchedAt(若有),跟 aggregate/timeseries 对齐 —— 事件流只显示算入 ROI 的事件 + 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 (fromDate < launchedAt) fromDate = launchedAt; + } + + if (type === 'cost') { + const rows = await db.select().from(projectCostEvents).where(and( + eq(projectCostEvents.projectId, projectId), + gte(projectCostEvents.eventDate, fromDate), + lte(projectCostEvents.eventDate, toDate) + )).orderBy(desc(projectCostEvents.eventDate)).limit(limit); + return c.json({ code: 0, data: rows, message: 'success' }); + } else { + const rows = await db.select().from(projectRevenueEvents).where(and( + eq(projectRevenueEvents.projectId, projectId), + gte(projectRevenueEvents.eventDate, fromDate), + lte(projectRevenueEvents.eventDate, toDate) + )).orderBy(desc(projectRevenueEvents.eventDate)).limit(limit); + return c.json({ code: 0, data: rows, message: 'success' }); + } +}); + +// DELETE /api/projects/:id/events/:eventId +roiRoutes.delete('/projects/:id/events/:eventId', async (c) => { + const user = c.get('user'); + const projectId = c.req.param('id'); + const eventId = c.req.param('eventId'); + const type = c.req.query('type'); + if (type !== 'cost' && type !== 'revenue') { + throw new AppError(40001, 'type query must be cost or revenue', 400); + } + if (!(await hasProjectWriteAccess(user, projectId))) { + throw new AppError(40103, 'No write access to project', 403); + } + if (type === 'cost') { + await db.delete(projectCostEvents).where(and( + eq(projectCostEvents.id, eventId), + eq(projectCostEvents.projectId, projectId) + )); + } else { + await db.delete(projectRevenueEvents).where(and( + eq(projectRevenueEvents.id, eventId), + eq(projectRevenueEvents.projectId, projectId) + )); + } + return c.json({ code: 0, data: null, message: 'success' }); +}); + +// ────────────────────────────────────────── +// 策略配置 +// ────────────────────────────────────────── + +// GET /api/roi/strategies +roiRoutes.get('/roi/strategies', async (c) => { + const rows = await db.select().from(roiStrategies); + return c.json({ code: 0, data: rows, message: 'success' }); +}); + +const patchStrategySchema = z.object({ + params: z.object({ + hourlyRate: z.number().min(0).max(10000).optional(), + amortYears: z.number().min(1).max(20).optional(), + commitHourCoef: z.number().min(0).max(40).optional(), + taskHourCoef: z.number().min(0).max(80).optional(), + }), +}); + +// PATCH /api/roi/strategies/:id +roiRoutes.patch('/roi/strategies/:id', + requireRole('admin'), + zValidator('json', patchStrategySchema), + async (c) => { + const id = c.req.param('id'); + const body = c.req.valid('json'); + const [existing] = await db.select().from(roiStrategies).where(eq(roiStrategies.id, id)).limit(1); + if (!existing) throw new AppError(40401, 'Strategy not found', 404); + + const merged = { ...(existing.params as object), ...body.params }; + await db.update(roiStrategies).set({ + params: merged, + updatedAt: new Date(), + }).where(eq(roiStrategies.id, id)); + invalidateStrategyCache(); + return c.json({ code: 0, data: null, message: 'success' }); + }); + +// ────────────────────────────────────────── +// 项目打标 +// ────────────────────────────────────────── + +const tagProjectSchema = z.object({ + category: z.enum(['cash_cow', 'efficiency_tool', 'moat', 'composite']), + compositeStrategies: z.array(z.enum(['cash_cow', 'efficiency_tool', 'moat'])).optional().nullable(), + bizSystem: z.enum(['airhubs', 'airflow', 'aircore']).optional().nullable(), + projectType: z.enum(['hardware', 'software']).optional().nullable(), + ownerId: z.string().optional().nullable(), + launchedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + vAsset: z.number().min(0).max(1e10).optional().nullable(), + tags: z.array(z.string()).optional().nullable(), +}); + +// POST /api/projects/:id/tag +roiRoutes.post('/projects/:id/tag', + zValidator('json', tagProjectSchema), + async (c) => { + const user = c.get('user'); + const projectId = c.req.param('id'); + if (!(await hasProjectWriteAccess(user, projectId))) { + throw new AppError(40103, 'No write access to project', 403); + } + const body = c.req.valid('json'); + + // 复合型 + 包含 moat,或者 category=moat,vAsset 必填 + const needsAsset = body.category === 'moat' || + (body.category === 'composite' && (body.compositeStrategies || []).includes('moat')); + if (needsAsset && (!body.vAsset || body.vAsset <= 0)) { + throw new AppError(40002, '资本护城河项目必须填写 vAsset (技术资产估值)', 422); + } + + await db.update(projects).set({ + category: body.category, + compositeStrategies: body.compositeStrategies ?? null, + ownerId: body.ownerId ?? null, + launchedAt: body.launchedAt ? new Date(body.launchedAt + 'T00:00:00+08:00') : null, + vAsset: body.vAsset ?? null, + tags: body.tags ?? null, + updatedAt: new Date(), + }).where(eq(projects.id, projectId)); + + // 若同时给出 bizSystem + projectType,自动生成新 identifier + let newIdentifier: string | null = null; + if (body.bizSystem && body.projectType) { + newIdentifier = await applyAutoIdentifier(projectId, body.bizSystem, body.projectType); + } + + return c.json({ code: 0, data: { identifier: newIdentifier }, message: 'success' }); + }); + +// ────────────────────────────────────────── +// 项目映射(业务方 key → DevPerf project) +// ────────────────────────────────────────── + +// GET /api/roi/mapping +roiRoutes.get('/roi/mapping', requireRole('admin', 'manager'), async (c) => { + const rows = await db.select().from(projectRevenueMapping); + return c.json({ code: 0, data: rows, message: 'success' }); +}); + +const createMappingSchema = z.object({ + projectId: z.string().min(1), + businessProjectKey: z.string().min(1).max(100), + enabled: z.boolean().default(true), + notes: z.string().max(500).optional().nullable(), +}); + +// POST /api/roi/mapping +roiRoutes.post('/roi/mapping', + requireRole('admin'), + zValidator('json', createMappingSchema), + async (c) => { + const body = c.req.valid('json'); + const now = new Date(); + await db.insert(projectRevenueMapping).values({ + id: uuid(), + projectId: body.projectId, + businessProjectKey: body.businessProjectKey, + enabled: body.enabled ? 1 : 0, + notes: body.notes ?? null, + createdAt: now, + updatedAt: now, + }); + return c.json({ code: 0, data: null, message: 'success' }); + }); + +// DELETE /api/roi/mapping/:id +roiRoutes.delete('/roi/mapping/:id', requireRole('admin'), async (c) => { + const id = c.req.param('id'); + await db.delete(projectRevenueMapping).where(eq(projectRevenueMapping.id, id)); + return c.json({ code: 0, data: null, message: 'success' }); +}); + +// GET /api/roi/unmapped — 列出未映射的营收(管理员处理) +roiRoutes.get('/roi/unmapped', requireRole('admin', 'manager'), async (c) => { + const rows = await db.select().from(unmappedRevenueEvents) + .where(eq(unmappedRevenueEvents.status, 'pending')) + .orderBy(desc(unmappedRevenueEvents.createdAt)) + .limit(200); + return c.json({ code: 0, data: rows, message: 'success' }); +}); + +// ────────────────────────────────────────── +// AI +// ────────────────────────────────────────── + +// POST /api/projects/:id/suggest-tag — AI 推荐项目分类 +roiRoutes.post('/projects/:id/suggest-tag', + requireRole('admin', 'manager'), + aiRateLimit({ perUserPerMinute: 5, perProjectPerDay: 20 }), + async (c) => { + const user = c.get('user'); + const projectId = c.req.param('id'); + if (!(await hasProjectWriteAccess(user, projectId))) { + throw new AppError(40103, 'No write access to project', 403); + } + try { + const result = await suggestProjectTag(projectId); + return c.json({ code: 0, data: result, message: 'success' }); + } catch (e) { + const msg = (e as Error).message; + throw new AppError(50003, `AI 推荐失败: ${msg.slice(0, 200)}`, 502); + } + }); + +// GET /api/projects/:id/driver-factors?periodKey=YYYY-MM — 查询某月驱动因子文案 +roiRoutes.get('/projects/:id/driver-factors', async (c) => { + const user = c.get('user'); + const projectId = c.req.param('id'); + if (!(await hasProjectReadAccess(user, projectId))) { + throw new AppError(40103, 'No access to project', 403); + } + const periodKey = c.req.query('periodKey'); + + let rows; + if (periodKey) { + rows = await db.select().from(roiDriverFactors).where(and( + eq(roiDriverFactors.projectId, projectId), + eq(roiDriverFactors.periodKey, periodKey) + )); + } else { + // 不传则返回最近 3 个月 + rows = await db.select().from(roiDriverFactors) + .where(eq(roiDriverFactors.projectId, projectId)) + .orderBy(desc(roiDriverFactors.generatedAt)) + .limit(3); + } + return c.json({ code: 0, data: rows, message: 'success' }); +}); diff --git a/backend/src/services/roi/aggregator.ts b/backend/src/services/roi/aggregator.ts new file mode 100644 index 0000000..ddc30ea --- /dev/null +++ b/backend/src/services/roi/aggregator.ts @@ -0,0 +1,150 @@ +import { and, eq, gte, lte } from 'drizzle-orm'; +import { db } from '../../db/index'; +import { projects, projectCostEvents, projectRevenueEvents } from '../../db/schema'; +import { calculateBep } from './bep-calculator'; +import { evaluateConfidence } from './confidence-evaluator'; +import type { AggregateResult, CostBreakdown, RevenueBreakdown } from './types'; + +function toDate(input: string | Date): Date { + return input instanceof Date ? input : new Date(input); +} + +function toIsoDay(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +const ZERO_COST: CostBreakdown = { devHours: 0, hardwareBom: 0, serviceFee: 0, amortization: 0, other: 0 }; +const ZERO_REVENUE: RevenueBreakdown = { directRevenue: 0, subscription: 0, savedCost: 0, assetValueAdd: 0, refund: 0, other: 0 }; + +/** + * 对单个项目在 [from, to] 时间窗内聚合事件流,返回 ROI 结果。 + * - from/to 起点早于 projects.launchedAt 时自动截断到 launchedAt + * - TotalCost = 0 时 roiValue 返回 null + */ +export async function aggregate( + projectId: string, + from: string | Date, + to: string | Date +): Promise { + let fromDate = toDate(from); + const toDateObj = toDate(to); + + // 截断到 launchedAt + 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 (fromDate < launchedAt) fromDate = launchedAt; + } + + // 拉取窗口内的事件 + const costEvents = await db.select().from(projectCostEvents).where( + and( + eq(projectCostEvents.projectId, projectId), + gte(projectCostEvents.eventDate, fromDate), + lte(projectCostEvents.eventDate, toDateObj) + ) + ); + + const revenueEvents = await db.select().from(projectRevenueEvents).where( + and( + eq(projectRevenueEvents.projectId, projectId), + gte(projectRevenueEvents.eventDate, fromDate), + lte(projectRevenueEvents.eventDate, toDateObj) + ) + ); + + // 聚合 + const costBreakdown: CostBreakdown = { ...ZERO_COST }; + let totalCost = 0; + for (const e of costEvents) { + totalCost += e.amount; + switch (e.costType) { + case 'dev_hours': costBreakdown.devHours += e.amount; break; + case 'hardware_bom': costBreakdown.hardwareBom += e.amount; break; + case 'service_fee': costBreakdown.serviceFee += e.amount; break; + case 'amortization': costBreakdown.amortization += e.amount; break; + default: costBreakdown.other += e.amount; + } + } + + const revenueBreakdown: RevenueBreakdown = { ...ZERO_REVENUE }; + let totalRevenue = 0; + for (const e of revenueEvents) { + totalRevenue += e.amount; + switch (e.revenueType) { + case 'direct_revenue': revenueBreakdown.directRevenue += e.amount; break; + case 'subscription': revenueBreakdown.subscription += e.amount; break; + case 'saved_cost': revenueBreakdown.savedCost += e.amount; break; + case 'asset_value_add': revenueBreakdown.assetValueAdd += e.amount; break; + case 'refund': revenueBreakdown.refund += e.amount; break; + default: revenueBreakdown.other += e.amount; + } + } + + const netProfit = totalRevenue - totalCost; + const roiValue = totalCost > 0 ? (netProfit / totalCost) * 100 : null; + + const confidence = evaluateConfidence(costEvents, revenueEvents); + + // BEP 只在累计窗口(from = launchedAt)有意义,且 roi < 100% 时计算 + const isLifetimeWindow = project?.launchedAt && Math.abs(fromDate.getTime() - new Date(project.launchedAt).getTime()) < 24 * 3600 * 1000; + let bepDays: number | null = null; + if (isLifetimeWindow && roiValue !== null) { + bepDays = await calculateBep(projectId, totalCost, totalRevenue, toDateObj); + } + + return { + projectId, + from: toIsoDay(fromDate), + to: toIsoDay(toDateObj), + totalCost: round2(totalCost), + totalRevenue: round2(totalRevenue), + netProfit: round2(netProfit), + roiValue: roiValue === null ? null : round2(roiValue), + confidence, + bepDays, + costBreakdown: { + devHours: round2(costBreakdown.devHours), + hardwareBom: round2(costBreakdown.hardwareBom), + serviceFee: round2(costBreakdown.serviceFee), + amortization: round2(costBreakdown.amortization), + other: round2(costBreakdown.other), + }, + revenueBreakdown: { + directRevenue: round2(revenueBreakdown.directRevenue), + subscription: round2(revenueBreakdown.subscription), + savedCost: round2(revenueBreakdown.savedCost), + assetValueAdd: round2(revenueBreakdown.assetValueAdd), + refund: round2(revenueBreakdown.refund), + other: round2(revenueBreakdown.other), + }, + costEventCount: costEvents.length, + revenueEventCount: revenueEvents.length, + }; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * 批量聚合多个项目(罗盘汇总用)。 + * 内部并发跑,但限制并发数避免压垮 DB。 + */ +export async function aggregateMany( + projectIds: string[], + from: string | Date, + to: string | Date, + concurrency = 8 +): Promise { + const results: AggregateResult[] = []; + for (let i = 0; i < projectIds.length; i += concurrency) { + const batch = projectIds.slice(i, i + concurrency); + const batchResults = await Promise.all(batch.map(id => aggregate(id, from, to))); + results.push(...batchResults); + } + return results; +} diff --git a/backend/src/services/roi/ai-driver-writer.ts b/backend/src/services/roi/ai-driver-writer.ts new file mode 100644 index 0000000..f1298a5 --- /dev/null +++ b/backend/src/services/roi/ai-driver-writer.ts @@ -0,0 +1,167 @@ +import { v4 as uuid } from 'uuid'; +import { and, desc, eq, gte } from 'drizzle-orm'; +import dayjs from 'dayjs'; +import { db } from '../../db/index'; +import { + projects, projectRepos, gitCommits, objectives, + roiDriverFactors, syncLogs, +} from '../../db/schema'; +import { callLLM, parseLLMJson } from '../llm-client'; +import { aggregate } from './aggregator'; + +export interface DriverFactor { + type: '现金流驱动' | '降本增效驱动' | '技术资产驱动'; + text: string; +} + +const SYSTEM_PROMPT = `你是软件项目价值分析师。给定项目本月 ROI 数据和近期工作内容,生成 1-3 条"价值驱动因子"文案,告诉管理者这个项目的价值来源是什么。 + +三种驱动因子类型: +- 现金流驱动: 项目直接产生营收,推动现金流入 +- 降本增效驱动: 项目通过工具化/自动化节省了内部成本 +- 技术资产驱动: 项目沉淀了技术能力/数据/算法,形成长期资产 + +请基于真实数据判断该项目最显著的 1-3 个驱动因子。回复严格 JSON 格式: +{ + "factors": [ + { "type": "现金流驱动" | "降本增效驱动" | "技术资产驱动", "text": "60字内的简短说明" } + ] +} + +不要添加其他字段或文本。每条 text 必须 ≤60 字,客观陈述,不要使用营销语言。`; + +const ALLOWED_TYPES = new Set(['现金流驱动', '降本增效驱动', '技术资产驱动']); + +/** + * 为单个项目生成"上月"的驱动因子,写入 roi_driver_factors 表。 + * periodKey = 上月 YYYY-MM + */ +export async function generateDriverFactorsForProject(projectId: string, asOf: Date = new Date()): Promise { + const lastMonth = dayjs(asOf).subtract(1, 'month'); + const periodKey = lastMonth.format('YYYY-MM'); + + // 上月 ROI + const monthStart = lastMonth.startOf('month').toDate(); + const monthEnd = lastMonth.endOf('month').toDate(); + const monthAgg = await aggregate(projectId, monthStart, monthEnd); + + // 累计 ROI(从 launchedAt 起) + const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1); + if (!project) throw new Error(`Project not found: ${projectId}`); + const launchedAt = project.launchedAt ? new Date(project.launchedAt) : monthStart; + const lifetimeAgg = await aggregate(projectId, launchedAt, monthEnd); + + // 近期 commits 摘要 + const repos = await db.select().from(projectRepos).where(eq(projectRepos.projectId, projectId)); + const repoNames = new Set(repos.map(r => r.repoName)); + let commitSummary = '(无近期提交)'; + if (repoNames.size > 0) { + const recent = await db.select().from(gitCommits) + .where(gte(gitCommits.committedAt, monthStart)) + .orderBy(desc(gitCommits.committedAt)) + .limit(30); + const projCommits = recent.filter(c => repoNames.has(c.repoName)); + if (projCommits.length > 0) { + commitSummary = projCommits + .map(c => `- ${(c.message || '').split('\n')[0].slice(0, 80)}`) + .slice(0, 15) + .join('\n'); + } + } + + // OKR 进展 + const objs = await db.select().from(objectives).where(eq(objectives.projectId, projectId)).limit(5); + const okrSummary = objs.length > 0 + ? objs.map(o => `- ${o.title} (进度 ${Math.round((o.progress || 0) * 100)}%)`).join('\n') + : '(无 OKR)'; + + const userPrompt = `项目: ${project.name} (定位: ${project.category || '未打标'}) + +上月 ROI 数据 (${periodKey}): +- 总成本: ¥${monthAgg.totalCost.toLocaleString()} +- 总产出: ¥${monthAgg.totalRevenue.toLocaleString()} +- 月 ROI: ${monthAgg.roiValue === null ? 'N/A' : monthAgg.roiValue + '%'} +- 直接营收占比: ¥${monthAgg.revenueBreakdown.directRevenue} +- 节约成本占比: ¥${monthAgg.revenueBreakdown.savedCost} +- 资产增值占比: ¥${monthAgg.revenueBreakdown.assetValueAdd} + +累计 ROI (立项至今): ${lifetimeAgg.roiValue === null ? 'N/A' : lifetimeAgg.roiValue + '%'} + +上月关键 commits: +${commitSummary} + +OKR 进展: +${okrSummary} + +请输出 JSON 驱动因子。`; + + const raw = await callLLM(SYSTEM_PROMPT, userPrompt); + const parsed = parseLLMJson<{ factors: DriverFactor[] }>(raw); + + // 校验 + if (!Array.isArray(parsed.factors)) throw new Error('LLM response missing factors array'); + const validFactors = parsed.factors + .filter(f => f && ALLOWED_TYPES.has(f.type) && typeof f.text === 'string') + .map(f => ({ type: f.type, text: f.text.slice(0, 80) })) + .slice(0, 3); + + if (validFactors.length === 0) throw new Error('LLM returned no valid factors'); + + // upsert: 先 delete 旧的(同 project + period),再 insert 新的 + await db.delete(roiDriverFactors).where(and( + eq(roiDriverFactors.projectId, projectId), + eq(roiDriverFactors.periodKey, periodKey), + )); + await db.insert(roiDriverFactors).values({ + id: uuid(), + projectId, + periodKey, + factors: validFactors, + context: { + monthRoi: monthAgg.roiValue, + lifetimeRoi: lifetimeAgg.roiValue, + monthCost: monthAgg.totalCost, + monthRevenue: monthAgg.totalRevenue, + }, + generatedAt: new Date(), + }); + + return validFactors; +} + +/** + * 月度 cron:为所有打标项目生成驱动因子。失败的项目记录但不中断。 + */ +export async function runMonthlyDriverFactorsGeneration(): Promise { + const startedAt = Date.now(); + let okCount = 0, failCount = 0; + const errors: string[] = []; + + // 仅为已打标的项目生成 + const allProjects = await db.select().from(projects); + const candidates = allProjects.filter(p => p.category !== null); + + for (const p of candidates) { + try { + await generateDriverFactorsForProject(p.id); + okCount += 1; + } catch (e) { + failCount += 1; + const msg = `${p.identifier || p.id}: ${(e as Error).message}`; + errors.push(msg); + console.warn('[ROI-AI-DRIVER]', msg); + } + // 简单速率控制:每项目间隔 1 秒,避免 LLM 限流 + await new Promise(r => setTimeout(r, 1000)); + } + + const elapsed = Date.now() - startedAt; + await db.insert(syncLogs).values({ + id: uuid(), + source: 'roi_ai_driver', + status: failCount === 0 ? 'success' : 'error', + message: `driver factors: ok=${okCount} fail=${failCount} elapsed=${elapsed}ms${errors.length > 0 ? ' errors=' + errors.slice(0, 3).join('|') : ''}`, + recordsProcessed: okCount, + syncedAt: new Date(), + }).catch(() => {}); +} diff --git a/backend/src/services/roi/ai-tag-suggester.ts b/backend/src/services/roi/ai-tag-suggester.ts new file mode 100644 index 0000000..33328ff --- /dev/null +++ b/backend/src/services/roi/ai-tag-suggester.ts @@ -0,0 +1,116 @@ +import { and, desc, eq, gte } from 'drizzle-orm'; +import dayjs from 'dayjs'; +import { db } from '../../db/index'; +import { + projects, gitCommits, projectRepos, objectives, +} from '../../db/schema'; +import { callLLM, parseLLMJson } from '../llm-client'; +import type { RoiCategory } from './types'; +import type { BizSystem, ProjectType } from './identifier-generator'; + +export interface TagSuggestion { + suggestedCategory: RoiCategory; + suggestedBizSystem: BizSystem; + suggestedProjectType: ProjectType; + confidence: number; // 0~1 + reasoning: string; +} + +const SYSTEM_PROMPT = `你是软件项目分析师。基于项目名称、近期 commits、OKR,从 3 个独立维度判断项目归属。 + +【维度 1:ROI 商业定位 category】 +- cash_cow(现金牛): 直接产生营收的对外业务/产品,如硬件销售、SaaS 订阅、API 计费 +- efficiency_tool(效能工具): 内部工具/平台,通过提升团队效率间接产生价值,如 CI/CD、监控、数据中台 +- moat(资本护城河): 沉淀技术资产、专利、数据壁垒,如算法引擎、底层基座、大模型、技术框架 +- composite(复合型): 同时兼具上述 2 种或 3 种属性,难以单一归类 + +【维度 2:业务体系 bizSystem(三大核心)】 +- airhubs: 硬件与潮玩业务线(ToB/ToC 营收主力)—— 电子吧唧、AI 玩具、智能硬件、潮玩周边等可对外售卖的产品归这里 +- airflow: 内容生成与效能线(降本增效与数字化)—— OKR 系统、内容管理平台、视频生成工具、DevOps、IAM、监控等内部效能工具归这里 +- aircore: 底层技术基座(护城河与技术中台)—— RTC 基座、AR/Live2D 引擎、AI 算法、操作系统适配层等技术中台归这里 + +【维度 3:技术属性 projectType】 +- hardware: 项目核心产物含硬件(嵌入式固件、PCB、模组、终端设备等) +- software: 纯软件,包括 Web/App/后端/SDK/工具脚本 + +请严格按以下 JSON 结构回复,不添加任何其他字段: +{ + "suggestedCategory": "cash_cow" | "efficiency_tool" | "moat" | "composite", + "suggestedBizSystem": "airhubs" | "airflow" | "aircore", + "suggestedProjectType": "hardware" | "software", + "confidence": 0.0~1.0 的数字,表示整体判断的置信度, + "reasoning": "简短中文说明(100 字内),先说 bizSystem 归属,再说 category 和 projectType 依据" +}`; + +/** + * AI 给项目推荐商业定位标签。 + * 上下文: 项目名 + identifier + 近 30 天 commit messages 摘要 + 关联 OKR title。 + */ +export async function suggestProjectTag(projectId: string): Promise { + const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1); + if (!project) throw new Error(`Project not found: ${projectId}`); + + // 近 30 天 commits + const since = dayjs().subtract(30, 'day').toDate(); + const repos = await db.select().from(projectRepos).where(eq(projectRepos.projectId, projectId)); + const repoNames = new Set(repos.map(r => r.repoName)); + + let commitSummary = ''; + if (repoNames.size > 0) { + const recent = await db.select().from(gitCommits) + .where(gte(gitCommits.committedAt, since)) + .orderBy(desc(gitCommits.committedAt)) + .limit(50); + const projCommits = recent.filter(c => repoNames.has(c.repoName)); + commitSummary = projCommits + .map(c => `- ${(c.message || '').split('\n')[0].slice(0, 80)}`) + .slice(0, 25) + .join('\n') || '(无近期 commits)'; + } else { + commitSummary = '(项目未绑定 repo)'; + } + + // 关联 OKR + const objs = await db.select({ title: objectives.title }) + .from(objectives) + .where(eq(objectives.projectId, projectId)) + .limit(10); + const okrSummary = objs.length > 0 + ? objs.map(o => `- ${o.title}`).join('\n') + : '(无 OKR)'; + + const userPrompt = `项目信息: +名称: ${project.name} +代号: ${project.identifier || '(无)'} + +近 30 天关键 commit messages: +${commitSummary} + +关联 OKR 目标: +${okrSummary} + +请输出 JSON 判断。`; + + const raw = await callLLM(SYSTEM_PROMPT, userPrompt); + const parsed = parseLLMJson(raw); + + // 校验 + const validCategories: RoiCategory[] = ['cash_cow', 'efficiency_tool', 'moat', 'composite']; + const validBizSystems: BizSystem[] = ['airhubs', 'airflow', 'aircore']; + const validTypes: ProjectType[] = ['hardware', 'software']; + + if (!validCategories.includes(parsed.suggestedCategory)) { + throw new Error(`Invalid category from LLM: ${parsed.suggestedCategory}`); + } + if (!validBizSystems.includes(parsed.suggestedBizSystem)) { + throw new Error(`Invalid bizSystem from LLM: ${parsed.suggestedBizSystem}`); + } + if (!validTypes.includes(parsed.suggestedProjectType)) { + throw new Error(`Invalid projectType from LLM: ${parsed.suggestedProjectType}`); + } + if (typeof parsed.confidence !== 'number' || parsed.confidence < 0 || parsed.confidence > 1) { + parsed.confidence = 0.5; + } + parsed.reasoning = (parsed.reasoning || '').slice(0, 250); + return parsed; +} diff --git a/backend/src/services/roi/bep-calculator.ts b/backend/src/services/roi/bep-calculator.ts new file mode 100644 index 0000000..f049004 --- /dev/null +++ b/backend/src/services/roi/bep-calculator.ts @@ -0,0 +1,60 @@ +import { and, eq, gte, lte } from 'drizzle-orm'; +import { db } from '../../db/index'; +import { projectCostEvents, projectRevenueEvents } from '../../db/schema'; + +/** + * BEP 纯数学部分(单元测试入口)。 + * + * 返回: + * - 0: 已经回本(累计 ROI >= 100%) + * - null: 近 30 天日均净产出 <= 0,按当前趋势永远无法回本 + * - 正整数: 预计还需多少天回本(向上取整) + */ +export function bepFromTotals( + totalCost: number, + totalRevenue: number, + recentCost: number, + recentRevenue: number, + windowDays = 30 +): number | null { + const deficit = totalCost - totalRevenue; + if (deficit <= 0) return 0; + + const dailyNetIncome = (recentRevenue - recentCost) / windowDays; + if (dailyNetIncome <= 0) return null; + + return Math.ceil(deficit / dailyNetIncome); +} + +/** + * 计算预计回本周期(BEP),包含 DB 查询。 + */ +export async function calculateBep( + projectId: string, + totalCost: number, + totalRevenue: number, + asOf: Date +): Promise { + if (totalCost - totalRevenue <= 0) return 0; // 已回本,免去 DB 查询 + + const since = new Date(asOf); + since.setDate(since.getDate() - 30); + + const [costAgg, revenueAgg] = await Promise.all([ + db.select().from(projectCostEvents).where(and( + eq(projectCostEvents.projectId, projectId), + gte(projectCostEvents.eventDate, since), + lte(projectCostEvents.eventDate, asOf) + )), + db.select().from(projectRevenueEvents).where(and( + eq(projectRevenueEvents.projectId, projectId), + gte(projectRevenueEvents.eventDate, since), + lte(projectRevenueEvents.eventDate, asOf) + )), + ]); + + const recentCost = costAgg.reduce((sum, e) => sum + e.amount, 0); + const recentRevenue = revenueAgg.reduce((sum, e) => sum + e.amount, 0); + + return bepFromTotals(totalCost, totalRevenue, recentCost, recentRevenue, 30); +} diff --git a/backend/src/services/roi/confidence-evaluator.ts b/backend/src/services/roi/confidence-evaluator.ts new file mode 100644 index 0000000..9bc30b3 --- /dev/null +++ b/backend/src/services/roi/confidence-evaluator.ts @@ -0,0 +1,34 @@ +import type { Confidence } from './types'; + +type CostEvent = { dataSource: string }; +type RevenueEvent = { dataSource: string }; + +const HIGH_QUALITY_COST_SOURCES = new Set(['plane_actual', 'manual']); + +/** + * 对一个时间窗的事件计算置信度: + * - High: 成本事件 80%+ 来自 plane_actual / manual,且收益事件存在 + * - Low: 只有成本无收益,或所有成本都来自 auto_commits/auto_tasks 估算 + * - Medium: 其他情况 + */ +export function evaluateConfidence( + costEvents: CostEvent[], + revenueEvents: RevenueEvent[] +): Confidence { + const totalCost = costEvents.length; + const totalRevenue = revenueEvents.length; + + if (totalCost === 0 && totalRevenue === 0) return 'low'; + + // 无收益 = 直接 low(无法判断盈亏) + if (totalRevenue === 0) return 'low'; + + // 全自动估算 = low + const highQualityCount = costEvents.filter(e => HIGH_QUALITY_COST_SOURCES.has(e.dataSource)).length; + if (totalCost > 0 && highQualityCount === 0) return 'low'; + + // 80%+ 高质量 = high + if (totalCost > 0 && highQualityCount / totalCost >= 0.8) return 'high'; + + return 'medium'; +} diff --git a/backend/src/services/roi/cost-ingest/from-commits.ts b/backend/src/services/roi/cost-ingest/from-commits.ts new file mode 100644 index 0000000..ff9508f --- /dev/null +++ b/backend/src/services/roi/cost-ingest/from-commits.ts @@ -0,0 +1,129 @@ +import { v4 as uuid } from 'uuid'; +import { and, eq, gte, inArray, lte } from 'drizzle-orm'; +import dayjs from 'dayjs'; +import { db } from '../../../db/index'; +import { gitCommits, projectRepos, projects, projectCostEvents } from '../../../db/schema'; +import { getStrategyParams } from '../strategy-params'; +import type { RoiCategory } from '../types'; + +interface IngestResult { + insertedCount: number; + skippedCount: number; + projectStats: Record; +} + +/** + * 把 [from, to] 时间窗内的 git commits 转成 cost_events。 + * 通过 project_repos 表把 repoName 关联到 projectId。 + * 用 (project_id, ref_type='git_commit', ref_id=sha) 唯一索引去重。 + */ +export async function ingestCommitsAsCostEvents(from: Date, to: Date): Promise { + const result: IngestResult = { insertedCount: 0, skippedCount: 0, projectStats: {} }; + + // 1. 建 repoName → projectId 映射 + const bindings = await db.select().from(projectRepos); + if (bindings.length === 0) { + return result; // 没有任何 repo 绑定项目,跳过 + } + const repoToProject = new Map(); + for (const b of bindings) { + repoToProject.set(normalizeRepoName(b.repoName), b.projectId); + } + + // 2. 拉项目的 category 字典(为不同 category 取不同 R_h) + const allProjects = await db.select().from(projects); + const projectCategory = new Map( + allProjects.map(p => [p.id, p.category as RoiCategory | null]) + ); + + // 3. 拉时间窗内的 commits + const commits = await db.select().from(gitCommits).where(and( + gte(gitCommits.committedAt, from), + lte(gitCommits.committedAt, to) + )); + if (commits.length === 0) return result; + + // 4. 反查已 insert 过的 sha(在该项目 ref_id 上),批量过滤 + const candidates = commits + .map(c => ({ commit: c, projectId: repoToProject.get(normalizeRepoName(c.repoName)) })) + .filter(x => x.projectId !== undefined) as { commit: typeof commits[0]; projectId: string }[]; + + if (candidates.length === 0) return result; + + // 一次性查所有可能冲突的 (project_id, sha) 组合 + const shasByProject = new Map>(); + for (const { commit, projectId } of candidates) { + if (!shasByProject.has(projectId)) shasByProject.set(projectId, new Set()); + shasByProject.get(projectId)!.add(commit.sha); + } + + const existingShas = new Set(); // key = projectId + '::' + sha + for (const [projectId, shas] of shasByProject) { + if (shas.size === 0) continue; + const existing = await db.select({ + refId: projectCostEvents.refId, + }).from(projectCostEvents).where(and( + eq(projectCostEvents.projectId, projectId), + eq(projectCostEvents.refType, 'git_commit'), + inArray(projectCostEvents.refId, Array.from(shas)) + )); + for (const e of existing) { + if (e.refId) existingShas.add(`${projectId}::${e.refId}`); + } + } + + // 5. 按项目分组批量插入 + const now = new Date(); + const toInsert: typeof projectCostEvents.$inferInsert[] = []; + + for (const { commit, projectId } of candidates) { + const key = `${projectId}::${commit.sha}`; + if (existingShas.has(key)) { + result.skippedCount += 1; + continue; + } + const params = await getStrategyParams(projectCategory.get(projectId) ?? null); + const hours = params.commitHourCoef; + const amount = hours * params.hourlyRate; + + toInsert.push({ + id: uuid(), + projectId, + eventDate: commit.committedAt instanceof Date ? commit.committedAt : new Date(commit.committedAt), + costType: 'dev_hours', + amount, + hours, + hourlyRateUsed: params.hourlyRate, + dataSource: 'auto_commits', + refType: 'git_commit', + refId: commit.sha, + notes: (commit.message || '').split('\n')[0].slice(0, 200), + createdBy: null, + createdAt: now, + }); + result.projectStats[projectId] = (result.projectStats[projectId] || 0) + 1; + } + + if (toInsert.length > 0) { + // 分批插入避免单批过大 + const BATCH_SIZE = 200; + for (let i = 0; i < toInsert.length; i += BATCH_SIZE) { + await db.insert(projectCostEvents).values(toInsert.slice(i, i + BATCH_SIZE)); + } + } + result.insertedCount = toInsert.length; + return result; +} + +/** 抹除 .git 后缀和 URL 前缀,只保留仓库名 */ +function normalizeRepoName(raw: string): string { + let cleaned = raw.trim().replace(/\.git$/, ''); + if (cleaned.includes('://')) { + try { + const parts = new URL(cleaned).pathname.split('/').filter(Boolean); + return parts[parts.length - 1] || cleaned; + } catch { /* fallthrough */ } + } + if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned; + return cleaned; +} diff --git a/backend/src/services/roi/cost-ingest/from-plane-tasks.ts b/backend/src/services/roi/cost-ingest/from-plane-tasks.ts new file mode 100644 index 0000000..4ea2832 --- /dev/null +++ b/backend/src/services/roi/cost-ingest/from-plane-tasks.ts @@ -0,0 +1,104 @@ +import { v4 as uuid } from 'uuid'; +import { and, eq, gte, inArray, lte, isNotNull } from 'drizzle-orm'; +import { db } from '../../../db/index'; +import { taskSnapshots, projects, projectCostEvents } from '../../../db/schema'; +import { getStrategyParams } from '../strategy-params'; +import type { RoiCategory } from '../types'; + +interface IngestResult { + insertedCount: number; + skippedCount: number; + projectStats: Record; +} + +/** + * 把 [from, to] 时间窗内完成的 Plane tasks 转成 cost_events。 + * task 通过 projectId 字段直接关联,不需要 repo 映射。 + * 用 (project_id, ref_type='plane_task', ref_id=planeIssueId) 唯一索引去重。 + * + * 由于当前 taskSnapshots 表无 actual_hours 字段,统一用 taskHourCoef 估算, + * dataSource='auto_tasks',置信度只能落到 Medium。 + */ +export async function ingestPlaneTasksAsCostEvents(from: Date, to: Date): Promise { + const result: IngestResult = { insertedCount: 0, skippedCount: 0, projectStats: {} }; + + // 1. 拉时间窗内完成的 tasks(只算 completedAt 在窗口内的) + const completedTasks = await db.select().from(taskSnapshots).where(and( + isNotNull(taskSnapshots.completedAt), + isNotNull(taskSnapshots.projectId), + gte(taskSnapshots.completedAt, from), + lte(taskSnapshots.completedAt, to) + )); + if (completedTasks.length === 0) return result; + + // 2. 项目 category 字典 + const allProjects = await db.select().from(projects); + const projectCategory = new Map( + allProjects.map(p => [p.id, p.category as RoiCategory | null]) + ); + + // 3. 检查去重(按 projectId 分组查已存在的 plane_issue_id) + const issuesByProject = new Map>(); + for (const t of completedTasks) { + if (!t.projectId) continue; + if (!issuesByProject.has(t.projectId)) issuesByProject.set(t.projectId, new Set()); + issuesByProject.get(t.projectId)!.add(t.planeIssueId); + } + + const existingKeys = new Set(); + for (const [projectId, issueIds] of issuesByProject) { + if (issueIds.size === 0) continue; + const existing = await db.select({ + refId: projectCostEvents.refId, + }).from(projectCostEvents).where(and( + eq(projectCostEvents.projectId, projectId), + eq(projectCostEvents.refType, 'plane_task'), + inArray(projectCostEvents.refId, Array.from(issueIds)) + )); + for (const e of existing) { + if (e.refId) existingKeys.add(`${projectId}::${e.refId}`); + } + } + + // 4. 准备 insert + const now = new Date(); + const toInsert: typeof projectCostEvents.$inferInsert[] = []; + + for (const task of completedTasks) { + if (!task.projectId || !task.completedAt) continue; + const key = `${task.projectId}::${task.planeIssueId}`; + if (existingKeys.has(key)) { + result.skippedCount += 1; + continue; + } + const params = await getStrategyParams(projectCategory.get(task.projectId) ?? null); + const hours = params.taskHourCoef; + const amount = hours * params.hourlyRate; + + toInsert.push({ + id: uuid(), + projectId: task.projectId, + eventDate: task.completedAt instanceof Date ? task.completedAt : new Date(task.completedAt), + costType: 'dev_hours', + amount, + hours, + hourlyRateUsed: params.hourlyRate, + dataSource: 'auto_tasks', + refType: 'plane_task', + refId: task.planeIssueId, + notes: task.title?.slice(0, 200) || null, + createdBy: null, + createdAt: now, + }); + result.projectStats[task.projectId] = (result.projectStats[task.projectId] || 0) + 1; + } + + if (toInsert.length > 0) { + const BATCH_SIZE = 200; + for (let i = 0; i < toInsert.length; i += BATCH_SIZE) { + await db.insert(projectCostEvents).values(toInsert.slice(i, i + BATCH_SIZE)); + } + } + result.insertedCount = toInsert.length; + return result; +} diff --git a/backend/src/services/roi/cost-ingest/index.ts b/backend/src/services/roi/cost-ingest/index.ts new file mode 100644 index 0000000..5da3019 --- /dev/null +++ b/backend/src/services/roi/cost-ingest/index.ts @@ -0,0 +1,65 @@ +import { v4 as uuid } from 'uuid'; +import dayjs from 'dayjs'; +import { db } from '../../../db/index'; +import { syncLogs } from '../../../db/schema'; +import { ingestCommitsAsCostEvents } from './from-commits'; +import { ingestPlaneTasksAsCostEvents } from './from-plane-tasks'; + +export interface RunOptions { + /** 要 ingest 的时间窗起点(含)。默认为昨天 00:00 */ + from?: Date; + /** 要 ingest 的时间窗终点(含)。默认为昨天 23:59:59.999 */ + to?: Date; +} + +/** + * 主入口:把 [from, to] 内的成本事件 ingest 到 cost_events。 + * 同时跑 commits 和 plane tasks 两个来源。 + * 任一来源失败不影响另一个,结果汇总写一条 syncLogs。 + */ +export async function runCostEventIngest(opts: RunOptions = {}): Promise { + const from = opts.from ?? dayjs().subtract(1, 'day').startOf('day').toDate(); + const to = opts.to ?? dayjs().subtract(1, 'day').endOf('day').toDate(); + + const startedAt = Date.now(); + let totalInserted = 0; + let totalSkipped = 0; + const errors: string[] = []; + + try { + const r1 = await ingestCommitsAsCostEvents(from, to); + totalInserted += r1.insertedCount; + totalSkipped += r1.skippedCount; + console.info(`[ROI-COST-INGEST] commits → cost: inserted=${r1.insertedCount} skipped=${r1.skippedCount}`); + } catch (e) { + const msg = `commits ingest failed: ${(e as Error).message}`; + console.error('[ROI-COST-INGEST]', msg); + errors.push(msg); + } + + try { + const r2 = await ingestPlaneTasksAsCostEvents(from, to); + totalInserted += r2.insertedCount; + totalSkipped += r2.skippedCount; + console.info(`[ROI-COST-INGEST] tasks → cost: inserted=${r2.insertedCount} skipped=${r2.skippedCount}`); + } catch (e) { + const msg = `tasks ingest failed: ${(e as Error).message}`; + console.error('[ROI-COST-INGEST]', msg); + errors.push(msg); + } + + const elapsed = Date.now() - startedAt; + const status = errors.length === 0 ? 'success' : 'error'; + const message = errors.length === 0 + ? `cost ingest ok: inserted=${totalInserted} skipped=${totalSkipped} from=${dayjs(from).format('YYYY-MM-DD')} to=${dayjs(to).format('YYYY-MM-DD')} elapsed=${elapsed}ms` + : `cost ingest partial: inserted=${totalInserted} skipped=${totalSkipped} errors=${errors.join(' | ')}`; + + await db.insert(syncLogs).values({ + id: uuid(), + source: 'roi_cost_ingest', + status, + message, + recordsProcessed: totalInserted, + syncedAt: new Date(), + }).catch(e => console.error('[ROI-COST-INGEST] syncLog write failed:', e)); +} diff --git a/backend/src/services/roi/identifier-generator.ts b/backend/src/services/roi/identifier-generator.ts new file mode 100644 index 0000000..151efd7 --- /dev/null +++ b/backend/src/services/roi/identifier-generator.ts @@ -0,0 +1,86 @@ +import { and, eq, like } from 'drizzle-orm'; +import { db } from '../../db/index'; +import { projects, projectRevenueMapping } from '../../db/schema'; + +export type BizSystem = 'airhubs' | 'airflow' | 'aircore'; +export type ProjectType = 'hardware' | 'software'; + +const TYPE_SHORT: Record = { + hardware: 'hw', + software: 'sw', +}; + +/** + * 根据 bizSystem + projectType 生成下一个唯一 identifier。 + * 格式:`{bizSystem}-{hw|sw}-{3位序号}`,例如 `airhubs-hw-001`、`airflow-sw-002`。 + * 同(bizSystem, projectType)组合独立编号。 + */ +export async function generateIdentifier( + bizSystem: BizSystem, + projectType: ProjectType +): Promise { + const prefix = `${bizSystem}-${TYPE_SHORT[projectType]}-`; + + const existing = await db.select({ identifier: projects.identifier }) + .from(projects) + .where(and( + eq(projects.bizSystem, bizSystem), + eq(projects.projectType, projectType), + like(projects.identifier, `${prefix}%`) + )); + + let maxSeq = 0; + for (const row of existing) { + if (!row.identifier) continue; + const m = row.identifier.match(/-(\d{3})$/); + if (m) { + const n = parseInt(m[1], 10); + if (n > maxSeq) maxSeq = n; + } + } + const nextSeq = String(maxSeq + 1).padStart(3, '0'); + return `${prefix}${nextSeq}`; +} + +/** + * 应用新 identifier 到项目,同时把 project_revenue_mapping 里的 businessProjectKey + * 一并更新(否则 mock 营收会进 unmapped)。 + * + * 返回 newIdentifier。 + */ +export async function applyAutoIdentifier( + projectId: string, + bizSystem: BizSystem, + projectType: ProjectType +): Promise { + // 已经有合规 identifier 时跳过 + const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1); + if (!project) throw new Error(`Project not found: ${projectId}`); + + const expectedPrefix = `${bizSystem}-${TYPE_SHORT[projectType]}-`; + if (project.identifier?.startsWith(expectedPrefix) && + project.bizSystem === bizSystem && + project.projectType === projectType) { + return project.identifier; // 已是正确格式 + } + + const newId = await generateIdentifier(bizSystem, projectType); + const oldId = project.identifier; + + await db.update(projects).set({ + identifier: newId, + bizSystem, + projectType, + updatedAt: new Date(), + }).where(eq(projects.id, projectId)); + + // 同步更新 mapping(若存在) + if (oldId) { + await db.update(projectRevenueMapping).set({ + businessProjectKey: newId, + updatedAt: new Date(), + }).where(eq(projectRevenueMapping.businessProjectKey, oldId)); + } + + return newId; +} diff --git a/backend/src/services/roi/revenue-ingest/asset-amortizer.ts b/backend/src/services/roi/revenue-ingest/asset-amortizer.ts new file mode 100644 index 0000000..7b0dd23 --- /dev/null +++ b/backend/src/services/roi/revenue-ingest/asset-amortizer.ts @@ -0,0 +1,90 @@ +import { v4 as uuid } from 'uuid'; +import { and, eq, isNotNull } from 'drizzle-orm'; +import dayjs from 'dayjs'; +import { db } from '../../../db/index'; +import { projects, projectRevenueEvents } from '../../../db/schema'; +import { getStrategyParams } from '../strategy-params'; + +export interface AmortizeResult { + insertedCount: number; + skippedCount: number; + projectStats: Record; +} + +/** + * 每月 1 号 cron 调用:为所有 [资本护城河] 或 [复合型含 moat] 项目 insert 一条资产摊销事件。 + * + * 规则: + * amount = V_asset / (N × 12) + * eventDate = 上月最后一天 + * refType = 'asset_amortization', refId = `${projectId}-${yyyy-MM}` 保证幂等 + * + * 触发时机假设:在每月 1 号 cron 执行,asOf 默认 = 今天。 + */ +export async function runAssetAmortization(asOf: Date = new Date()): Promise { + const result: AmortizeResult = { insertedCount: 0, skippedCount: 0, projectStats: {} }; + + // 摊销期间 = 上月 + const lastMonth = dayjs(asOf).subtract(1, 'month'); + const periodKey = lastMonth.format('YYYY-MM'); + const eventDate = lastMonth.endOf('month').toDate(); + + // 1. 找出所有资本护城河项目(category=moat 且 v_asset>0) + const moatProjects = await db.select().from(projects).where(and( + eq(projects.category, 'moat'), + isNotNull(projects.vAsset) + )); + + // 复合型包含 moat 也算入 + const compositeProjects = await db.select().from(projects).where(and( + eq(projects.category, 'composite'), + isNotNull(projects.vAsset) + )); + const compositeWithMoat = compositeProjects.filter(p => { + const strategies = (p.compositeStrategies as string[]) || []; + return Array.isArray(strategies) && strategies.includes('moat'); + }); + + const targets = [...moatProjects, ...compositeWithMoat]; + + // 2. 对每个项目 insert(refId 唯一索引保证幂等,跑两次只 insert 第一次) + const now = new Date(); + for (const project of targets) { + if (!project.vAsset || project.vAsset <= 0) continue; + + const params = await getStrategyParams(project.category as any); + const amortYears = params.amortYears ?? 3; + const monthlyAmount = project.vAsset / (amortYears * 12); + + const refId = `${project.id}-${periodKey}`; + // 先查是否已存在 + const [existing] = await db.select().from(projectRevenueEvents).where(and( + eq(projectRevenueEvents.projectId, project.id), + eq(projectRevenueEvents.refType, 'asset_amortization'), + eq(projectRevenueEvents.refId, refId) + )).limit(1); + if (existing) { + result.skippedCount += 1; + continue; + } + + await db.insert(projectRevenueEvents).values({ + id: uuid(), + projectId: project.id, + eventDate, + revenueType: 'asset_value_add', + amount: monthlyAmount, + dataSource: 'calculated', + refType: 'asset_amortization', + refId, + channel: null, + notes: `资产摊销 ${periodKey}: V_asset=${project.vAsset} / (${amortYears}×12) = ${monthlyAmount.toFixed(2)}`, + createdBy: null, + createdAt: now, + }); + result.insertedCount += 1; + result.projectStats[project.id] = (result.projectStats[project.id] || 0) + 1; + } + + return result; +} diff --git a/backend/src/services/roi/revenue-ingest/from-revenue-api.ts b/backend/src/services/roi/revenue-ingest/from-revenue-api.ts new file mode 100644 index 0000000..d3d1706 --- /dev/null +++ b/backend/src/services/roi/revenue-ingest/from-revenue-api.ts @@ -0,0 +1,215 @@ +import { v4 as uuid } from 'uuid'; +import { eq, inArray } from 'drizzle-orm'; +import { db } from '../../../db/index'; +import { + projectRevenueEvents, + projectRevenueMapping, + unmappedRevenueEvents, +} from '../../../db/schema'; +import { config } from '../../../config'; + +// 对齐附录 A 的响应类型 +interface RemoteEvent { + externalId: string; + businessProjectKey: string; + eventDate: string; // YYYY-MM-DD + amount: number; + currency: string; + revenueType: string; // 字符串,我们映射成 enum + channel?: string; + occurredAt?: string; + metadata?: Record; +} + +interface RemoteResponse { + date: string; + events: RemoteEvent[]; + nextCursor: string | null; + totalCount: number; +} + +export interface RevenueIngestResult { + insertedCount: number; + unmappedCount: number; + skippedCount: number; + fetchedCount: number; + errors: string[]; +} + +const REVENUE_TYPE_WHITELIST = new Set([ + 'direct_revenue', 'subscription', 'saved_cost', 'asset_value_add', 'refund', 'other', +]); + +function normalizeRevenueType(t: string): typeof projectRevenueEvents.$inferInsert['revenueType'] { + if (REVENUE_TYPE_WHITELIST.has(t)) return t as any; + return 'other'; +} + +/** + * 从外部营收 API 拉指定日期的事件,写入 revenue_events 表(已映射)或 unmapped_revenue_events(未映射)。 + * + * 支持: + * - cursor 分页(纯实现,mock 不会分页但留接口) + * - 5xx/429 指数退避 3 次 + * - 401/4xx 立即报错(不重试) + * - refType='revenue_api', refId=externalId 唯一索引去重 + */ +export async function ingestRevenueForDate(dateStr: string): Promise { + const result: RevenueIngestResult = { + insertedCount: 0, + unmappedCount: 0, + skippedCount: 0, + fetchedCount: 0, + errors: [], + }; + + // 1. 拉映射表 + const mappings = await db.select().from(projectRevenueMapping); + const businessKeyToProject = new Map(); + for (const m of mappings) { + if (m.enabled) businessKeyToProject.set(m.businessProjectKey, m.projectId); + } + + // 2. 分页拉数据 + let cursor: string | null = null; + const allEvents: RemoteEvent[] = []; + while (true) { + let resp: RemoteResponse; + try { + resp = await fetchPageWithRetry(dateStr, cursor); + } catch (e) { + result.errors.push((e as Error).message); + return result; + } + allEvents.push(...resp.events); + if (!resp.nextCursor) break; + cursor = resp.nextCursor; + } + result.fetchedCount = allEvents.length; + if (allEvents.length === 0) return result; + + // 3. 检查 externalId 去重 + const externalIds = allEvents.map(e => e.externalId); + const existingRefIds = new Set(); + + // 拉 revenue_events 中已有的 ref_id + const BATCH = 500; + for (let i = 0; i < externalIds.length; i += BATCH) { + const slice = externalIds.slice(i, i + BATCH); + const existing = await db.select({ refId: projectRevenueEvents.refId }) + .from(projectRevenueEvents) + .where(inArray(projectRevenueEvents.refId, slice)); + for (const e of existing) { + if (e.refId) existingRefIds.add(e.refId); + } + } + + // 拉 unmapped_revenue_events 已有的 external_id + const existingUnmapped = new Set(); + for (let i = 0; i < externalIds.length; i += BATCH) { + const slice = externalIds.slice(i, i + BATCH); + const existing = await db.select({ externalId: unmappedRevenueEvents.externalId }) + .from(unmappedRevenueEvents) + .where(inArray(unmappedRevenueEvents.externalId, slice)); + for (const e of existing) { + existingUnmapped.add(e.externalId); + } + } + + // 4. 分流插入 + const now = new Date(); + const toInsertMapped: typeof projectRevenueEvents.$inferInsert[] = []; + const toInsertUnmapped: typeof unmappedRevenueEvents.$inferInsert[] = []; + + for (const ev of allEvents) { + if (existingRefIds.has(ev.externalId) || existingUnmapped.has(ev.externalId)) { + result.skippedCount += 1; + continue; + } + const projectId = businessKeyToProject.get(ev.businessProjectKey); + const eventDate = new Date(ev.eventDate + 'T00:00:00+08:00'); + + if (projectId) { + toInsertMapped.push({ + id: uuid(), + projectId, + eventDate, + revenueType: normalizeRevenueType(ev.revenueType), + amount: ev.amount, + dataSource: config.MOCK_REVENUE_API ? 'mock' : 'api_pulled', + refType: 'revenue_api', + refId: ev.externalId, + channel: ev.channel || null, + notes: ev.metadata ? JSON.stringify(ev.metadata).slice(0, 500) : null, + createdBy: null, + createdAt: now, + }); + } else { + toInsertUnmapped.push({ + id: uuid(), + externalId: ev.externalId, + businessProjectKey: ev.businessProjectKey, + eventDate, + amount: ev.amount, + revenueType: ev.revenueType, + channel: ev.channel || null, + rawPayload: ev as any, + status: 'pending', + resolvedEventId: null, + createdAt: now, + }); + } + } + + if (toInsertMapped.length > 0) { + for (let i = 0; i < toInsertMapped.length; i += 200) { + await db.insert(projectRevenueEvents).values(toInsertMapped.slice(i, i + 200)); + } + result.insertedCount = toInsertMapped.length; + } + if (toInsertUnmapped.length > 0) { + for (let i = 0; i < toInsertUnmapped.length; i += 200) { + await db.insert(unmappedRevenueEvents).values(toInsertUnmapped.slice(i, i + 200)); + } + result.unmappedCount = toInsertUnmapped.length; + } + + return result; +} + +async function fetchPageWithRetry(date: string, cursor: string | null): Promise { + const url = new URL(`${config.REVENUE_API_BASE_URL}/revenue/daily`); + url.searchParams.set('date', date); + if (cursor) url.searchParams.set('cursor', cursor); + + const backoff = [10_000, 30_000, 90_000]; + let lastErr: Error | null = null; + + for (let attempt = 0; attempt <= backoff.length; attempt++) { + try { + const res = await fetch(url.toString(), { + headers: { + 'Authorization': `Bearer ${config.REVENUE_API_KEY}`, + 'Accept': 'application/json', + }, + }); + + if (res.status === 200) { + return await res.json() as RemoteResponse; + } + if (res.status === 401 || (res.status >= 400 && res.status < 500 && res.status !== 429)) { + const body = await res.text().catch(() => ''); + throw new Error(`Revenue API ${res.status}: ${body.slice(0, 200)}`); + } + // 5xx 或 429 → 重试 + lastErr = new Error(`Revenue API ${res.status}, retrying...`); + } catch (e) { + lastErr = e as Error; + } + + if (attempt < backoff.length) { + await new Promise(r => setTimeout(r, backoff[attempt])); + } + } + throw lastErr ?? new Error('Revenue API fetch failed after retries'); +} diff --git a/backend/src/services/roi/revenue-ingest/index.ts b/backend/src/services/roi/revenue-ingest/index.ts new file mode 100644 index 0000000..acc9e8d --- /dev/null +++ b/backend/src/services/roi/revenue-ingest/index.ts @@ -0,0 +1,68 @@ +import { v4 as uuid } from 'uuid'; +import dayjs from 'dayjs'; +import { db } from '../../../db/index'; +import { syncLogs } from '../../../db/schema'; +import { ingestRevenueForDate } from './from-revenue-api'; +import { runAssetAmortization } from './asset-amortizer'; + +/** + * 每日营收 ingest 入口:拉昨日数据,写一条 syncLog。 + */ +export async function runRevenueIngest(dateStr?: string): Promise { + const date = dateStr ?? dayjs().subtract(1, 'day').format('YYYY-MM-DD'); + const startedAt = Date.now(); + + try { + const r = await ingestRevenueForDate(date); + const elapsed = Date.now() - startedAt; + await db.insert(syncLogs).values({ + id: uuid(), + source: 'roi_revenue_ingest', + status: r.errors.length === 0 ? 'success' : 'error', + message: `revenue ingest date=${date}: fetched=${r.fetchedCount} inserted=${r.insertedCount} unmapped=${r.unmappedCount} skipped=${r.skippedCount} elapsed=${elapsed}ms${r.errors.length > 0 ? ' errors=' + r.errors.join('|') : ''}`, + recordsProcessed: r.insertedCount + r.unmappedCount, + syncedAt: new Date(), + }); + } catch (e) { + const msg = (e as Error).message; + console.error('[ROI-REVENUE-INGEST]', msg); + await db.insert(syncLogs).values({ + id: uuid(), + source: 'roi_revenue_ingest', + status: 'error', + message: `revenue ingest date=${date} failed: ${msg}`, + recordsProcessed: 0, + syncedAt: new Date(), + }).catch(() => {}); + } +} + +/** + * 每月 1 号资产摊销 cron 入口。 + */ +export async function runMonthlyAmortization(): Promise { + const startedAt = Date.now(); + try { + const r = await runAssetAmortization(); + const elapsed = Date.now() - startedAt; + await db.insert(syncLogs).values({ + id: uuid(), + source: 'roi_amortizer', + status: 'success', + message: `amortizer ok: inserted=${r.insertedCount} skipped=${r.skippedCount} elapsed=${elapsed}ms`, + recordsProcessed: r.insertedCount, + syncedAt: new Date(), + }); + } catch (e) { + const msg = (e as Error).message; + console.error('[ROI-AMORTIZER]', msg); + await db.insert(syncLogs).values({ + id: uuid(), + source: 'roi_amortizer', + status: 'error', + message: `amortizer failed: ${msg}`, + recordsProcessed: 0, + syncedAt: new Date(), + }).catch(() => {}); + } +} diff --git a/backend/src/services/roi/revenue-ingest/mock-generator.ts b/backend/src/services/roi/revenue-ingest/mock-generator.ts new file mode 100644 index 0000000..e614513 --- /dev/null +++ b/backend/src/services/roi/revenue-ingest/mock-generator.ts @@ -0,0 +1,145 @@ +import { db } from '../../../db/index'; +import { projects } from '../../../db/schema'; +import type { RoiCategory } from '../types'; + +export interface MockRevenueEvent { + externalId: string; + businessProjectKey: string; + eventDate: string; + amount: number; + currency: string; + revenueType: 'direct_revenue' | 'subscription' | 'refund' | 'other'; + channel: string; + occurredAt: string; + metadata?: Record; +} + +/** 字符串 hash 转 32-bit 整数(用作种子) */ +function hashSeed(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +/** 简单 LCG 随机数生成器,可复现 */ +function makeRng(seed: number) { + let s = seed || 1; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +const CHANNELS = ['alipay', 'wechat', 'stripe', 'bank', 'offline'] as const; + +interface CategoryProfile { + baseMin: number; + baseMax: number; + amountMin: number; + amountMax: number; +} + +const PROFILE: Record = { + cash_cow: { baseMin: 2, baseMax: 5, amountMin: 1000, amountMax: 10000 }, + efficiency_tool: { baseMin: 1, baseMax: 2, amountMin: 500, amountMax: 3000 }, + moat: { baseMin: 0, baseMax: 1, amountMin: 800, amountMax: 4000 }, + composite: { baseMin: 1, baseMax: 3, amountMin: 800, amountMax: 5000 }, + default: { baseMin: 1, baseMax: 2, amountMin: 500, amountMax: 3000 }, +}; + +function getProfile(cat: RoiCategory | null | undefined): CategoryProfile { + if (!cat) return PROFILE.default; + return PROFILE[cat] ?? PROFILE.default; +} + +function isWeekend(date: Date): boolean { + const d = date.getDay(); + return d === 0 || d === 6; +} + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +function dateOnly(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +function isoAt(d: Date, hour: number, min: number, sec: number): string { + return `${dateOnly(d)}T${pad2(hour)}:${pad2(min)}:${pad2(sec)}+08:00`; +} + +/** + * 为指定日期生成可复现的 mock 营收事件。 + * 同一 date 调用任意次返回完全相同的结果。 + */ +export async function generateMockRevenueForDate(dateStr: string): Promise { + const date = new Date(dateStr + 'T00:00:00+08:00'); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date: ${dateStr}`); + } + + const allProjects = await db.select().from(projects); + const events: MockRevenueEvent[] = []; + + const weekend = isWeekend(date); + const rng = makeRng(hashSeed(dateStr + 'devperf-mock')); + + for (const project of allProjects) { + if (!project.identifier) continue; // 没有 identifier 跳过 + + const profile = getProfile(project.category as RoiCategory | null); + let count = profile.baseMin + Math.floor(rng() * (profile.baseMax - profile.baseMin + 1)); + if (weekend) count = Math.ceil(count / 2); + + for (let i = 0; i < count; i++) { + const isRefund = rng() < 0.05; + const baseAmount = profile.amountMin + Math.floor(rng() * (profile.amountMax - profile.amountMin)); + const amount = isRefund ? -Math.floor(baseAmount * 0.3) : baseAmount; + const revenueType: MockRevenueEvent['revenueType'] = isRefund + ? 'refund' + : (rng() < 0.3 ? 'subscription' : 'direct_revenue'); + const channel = CHANNELS[Math.floor(rng() * CHANNELS.length)]; + const hour = Math.floor(rng() * 24); + const minute = Math.floor(rng() * 60); + const second = Math.floor(rng() * 60); + + events.push({ + externalId: `MOCK-${dateStr.replace(/-/g, '')}-${project.identifier}-${String(i).padStart(3, '0')}`, + businessProjectKey: project.identifier, + eventDate: dateStr, + amount, + currency: 'CNY', + revenueType, + channel, + occurredAt: isoAt(date, hour, minute, second), + metadata: isRefund ? { kind: 'mock_refund' } : { kind: 'mock_revenue' }, + }); + } + } + + // 按 occurredAt 排序,符合真实接口习惯 + events.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt)); + return events; +} + +export interface MockBusinessProject { + businessProjectKey: string; + name: string; + active: boolean; +} + +/** 返回 mock 业务系统中的"项目清单",用于映射维护页面 */ +export async function listMockBusinessProjects(): Promise { + const allProjects = await db.select().from(projects); + return allProjects + .filter(p => p.identifier) + .map(p => ({ + businessProjectKey: p.identifier!, + name: p.name, + active: true, + })); +} diff --git a/backend/src/services/roi/strategy-params.ts b/backend/src/services/roi/strategy-params.ts new file mode 100644 index 0000000..a6621a7 --- /dev/null +++ b/backend/src/services/roi/strategy-params.ts @@ -0,0 +1,49 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db/index'; +import { roiStrategies } from '../../db/schema'; +import type { RoiCategory, StrategyParams } from './types'; + +const DEFAULTS: StrategyParams = { + hourlyRate: 400, + amortYears: 3, + commitHourCoef: 0.5, + taskHourCoef: 6, +}; + +let cache: Map | null = null; +let cacheLoadedAt = 0; +const CACHE_TTL_MS = 60_000; // 1 分钟缓存,改完策略最多 1 分钟生效 + +async function loadCache(): Promise> { + const now = Date.now(); + if (cache && now - cacheLoadedAt < CACHE_TTL_MS) return cache; + + const rows = await db.select().from(roiStrategies); + const map = new Map(); + for (const row of rows) { + const p = (row.params as Partial) || {}; + map.set(row.category, { + hourlyRate: p.hourlyRate ?? DEFAULTS.hourlyRate, + amortYears: p.amortYears ?? DEFAULTS.amortYears, + commitHourCoef: p.commitHourCoef ?? DEFAULTS.commitHourCoef, + taskHourCoef: p.taskHourCoef ?? DEFAULTS.taskHourCoef, + }); + } + cache = map; + cacheLoadedAt = now; + return map; +} + +/** + * 取某个分类的策略参数。未打标(null)时返回 cash_cow 的参数作为默认。 + */ +export async function getStrategyParams(category: RoiCategory | null): Promise { + const map = await loadCache(); + const cat: RoiCategory = category ?? 'cash_cow'; + return map.get(cat) ?? DEFAULTS; +} + +export function invalidateStrategyCache(): void { + cache = null; + cacheLoadedAt = 0; +} diff --git a/backend/src/services/roi/timeseries.ts b/backend/src/services/roi/timeseries.ts new file mode 100644 index 0000000..4d08317 --- /dev/null +++ b/backend/src/services/roi/timeseries.ts @@ -0,0 +1,97 @@ +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; +} diff --git a/backend/src/services/roi/types.ts b/backend/src/services/roi/types.ts new file mode 100644 index 0000000..85d8e26 --- /dev/null +++ b/backend/src/services/roi/types.ts @@ -0,0 +1,49 @@ +// ROI 引擎共享类型定义 + +export type RoiCategory = 'cash_cow' | 'efficiency_tool' | 'moat' | 'composite'; + +export type CostType = 'dev_hours' | 'hardware_bom' | 'service_fee' | 'amortization' | 'other'; + +export type RevenueType = 'direct_revenue' | 'subscription' | 'saved_cost' | 'asset_value_add' | 'refund' | 'other'; + +export type Confidence = 'high' | 'medium' | 'low'; + +export interface CostBreakdown { + devHours: number; + hardwareBom: number; + serviceFee: number; + amortization: number; + other: number; +} + +export interface RevenueBreakdown { + directRevenue: number; + subscription: number; + savedCost: number; + assetValueAdd: number; + refund: number; + other: number; +} + +export interface AggregateResult { + projectId: string; + from: string; // YYYY-MM-DD + to: string; // YYYY-MM-DD + totalCost: number; + totalRevenue: number; + netProfit: number; + roiValue: number | null; // 百分比,80 = 80%。成本为 0 返回 null + confidence: Confidence; + bepDays: number | null; // 已回本 = 0;不可回本 = null;正数 = 预计还需天数 + costBreakdown: CostBreakdown; + revenueBreakdown: RevenueBreakdown; + costEventCount: number; + revenueEventCount: number; +} + +export interface StrategyParams { + hourlyRate: number; + amortYears?: number; + commitHourCoef: number; + taskHourCoef: number; +} diff --git a/backend/src/sync/scheduler.ts b/backend/src/sync/scheduler.ts index 8e4657e..41f0aca 100644 --- a/backend/src/sync/scheduler.ts +++ b/backend/src/sync/scheduler.ts @@ -2,11 +2,17 @@ import { v4 as uuid } from 'uuid'; import { Cron } from 'croner'; import { syncGitea } from './sync-gitea'; import { analyzeCommitsForOKR } from '../services/okr-ai-sync'; +import { runCostEventIngest } from '../services/roi/cost-ingest'; +import { runRevenueIngest, runMonthlyAmortization } from '../services/roi/revenue-ingest'; +import { runMonthlyDriverFactorsGeneration } from '../services/roi/ai-driver-writer'; import { db } from '../db/index'; import { syncLogs } from '../db/schema'; import { config } from '../config'; let giteaJob: Cron | null = null; +let revenueJob: Cron | null = null; +let amortizerJob: Cron | null = null; +let driverFactorsJob: Cron | null = null; async function runSyncAndAnalyze() { await syncGitea(); @@ -27,6 +33,12 @@ async function runSyncAndAnalyze() { syncedAt: new Date(), }); } + + // ROI 成本事件 ingest:Git/Plane 同步完后跑,挂同一周期 + console.info('[SCHEDULER] ROI 成本事件 ingest 开始...'); + await runCostEventIngest().catch(e => + console.error('[SCHEDULER] ROI 成本事件 ingest 失败:', e) + ); } export function startScheduler(): void { @@ -36,7 +48,30 @@ export function startScheduler(): void { await runSyncAndAnalyze(); }); + // ROI 营收 ingest:每天 03:00 拉昨日营收 + revenueJob = new Cron('0 3 * * *', async () => { + console.info('[SCHEDULER] ROI 营收 ingest 开始...'); + await runRevenueIngest().catch(e => console.error('[SCHEDULER] ROI 营收 ingest 失败:', e)); + }); + + // ROI 资产摊销:每月 1 号 01:00 + amortizerJob = new Cron('0 1 1 * *', async () => { + console.info('[SCHEDULER] ROI 资产摊销开始...'); + await runMonthlyAmortization().catch(e => console.error('[SCHEDULER] ROI 资产摊销失败:', e)); + }); + + // ROI 驱动因子 AI:每月 1 号 03:00(摊销完后跑,数据齐备) + driverFactorsJob = new Cron('0 3 1 * *', async () => { + if (!config.AI_ENABLED || !config.AI_API_KEY) { + console.warn('[SCHEDULER] 跳过 ROI 驱动因子: AI 未启用'); + return; + } + console.info('[SCHEDULER] ROI 驱动因子生成开始...'); + await runMonthlyDriverFactorsGeneration().catch(e => console.error('[SCHEDULER] ROI 驱动因子失败:', e)); + }); + console.info(`[SCHEDULER] Gitea 自动同步已启动(每天 02:00 + 19:00 UTC), AI_ENABLED=${config.AI_ENABLED}, AI_API_KEY length=${config.AI_API_KEY?.length || 0}`); + console.info(`[SCHEDULER] ROI cron 已启动:营收 ingest(每天 03:00) + 资产摊销(每月 1 号 01:00) + AI 驱动因子(每月 1 号 03:00)`); // 启动时立刻写一条诊断日志(不阻塞) db.insert(syncLogs).values({ @@ -57,5 +92,8 @@ export function startScheduler(): void { export function stopScheduler(): void { giteaJob?.stop(); + revenueJob?.stop(); + amortizerJob?.stop(); + driverFactorsJob?.stop(); console.info('[SCHEDULER] 已停止同步任务'); } diff --git a/backend/tests/unit/roi-engine.test.ts b/backend/tests/unit/roi-engine.test.ts new file mode 100644 index 0000000..c285853 --- /dev/null +++ b/backend/tests/unit/roi-engine.test.ts @@ -0,0 +1,86 @@ +/** + * ROI 引擎纯函数单元测试。 + * 仅测不依赖 DB 的逻辑;聚合/查询走集成测试。 + */ +import { describe, it, expect } from 'bun:test'; +import { evaluateConfidence } from '../../src/services/roi/confidence-evaluator'; +import { bepFromTotals } from '../../src/services/roi/bep-calculator'; + +describe('confidence-evaluator', () => { + it('returns low when no events at all', () => { + expect(evaluateConfidence([], [])).toBe('low'); + }); + + it('returns low when only cost, no revenue', () => { + expect(evaluateConfidence([{ dataSource: 'manual' }], [])).toBe('low'); + }); + + it('returns low when all cost is auto-estimated', () => { + const costs = [ + { dataSource: 'auto_commits' }, + { dataSource: 'auto_tasks' }, + ]; + const revenues = [{ dataSource: 'manual' }]; + expect(evaluateConfidence(costs, revenues)).toBe('low'); + }); + + it('returns high when 80%+ cost is plane_actual/manual + revenue exists', () => { + const costs = [ + { dataSource: 'plane_actual' }, + { dataSource: 'plane_actual' }, + { dataSource: 'manual' }, + { dataSource: 'manual' }, + { dataSource: 'auto_commits' }, // 1/5 = 20% 自动,刚好满足 80%+ 高质量 + ]; + const revenues = [{ dataSource: 'api_pulled' }]; + expect(evaluateConfidence(costs, revenues)).toBe('high'); + }); + + it('returns medium when high-quality cost ratio is between 1% and 80%', () => { + const costs = [ + { dataSource: 'plane_actual' }, + { dataSource: 'auto_commits' }, + { dataSource: 'auto_commits' }, + ]; + const revenues = [{ dataSource: 'manual' }]; + // 1/3 = 33% 高质量,落在 medium 区间 + expect(evaluateConfidence(costs, revenues)).toBe('medium'); + }); + + it('returns high when all cost manual', () => { + const costs = [{ dataSource: 'manual' }, { dataSource: 'manual' }]; + const revenues = [{ dataSource: 'manual' }]; + expect(evaluateConfidence(costs, revenues)).toBe('high'); + }); +}); + +describe('bepFromTotals', () => { + it('returns 0 when already broken even (revenue >= cost)', () => { + expect(bepFromTotals(100000, 100000, 0, 0)).toBe(0); + expect(bepFromTotals(100000, 150000, 0, 0)).toBe(0); + }); + + it('returns null when recent net income is non-positive', () => { + // 累计亏 5w,近 30 天净产出为 0 + expect(bepFromTotals(100000, 50000, 30000, 30000)).toBe(null); + // 近 30 天还在亏 + expect(bepFromTotals(100000, 50000, 30000, 20000)).toBe(null); + }); + + it('returns positive days when on track to break even', () => { + // 总投入 100w,总产出 80w => 缺口 20w + // 近 30 天:成本 3w,收入 6w => 日均净 1000 元 + // 预计天数 = 200000 / 1000 = 200 天 + expect(bepFromTotals(1_000_000, 800_000, 30_000, 60_000)).toBe(200); + }); + + it('rounds up partial days', () => { + // 缺口 1000,日均净 300 => 3.33 天向上取整 = 4 + expect(bepFromTotals(2000, 1000, 0, 9000)).toBe(4); // daily = 9000/30 = 300 + }); + + it('respects custom windowDays', () => { + // 缺口 1000,近 10 天净 1000 => 日均 100 => 10 天回本 + expect(bepFromTotals(2000, 1000, 0, 1000, 10)).toBe(10); + }); +}); diff --git a/frontend/src/api/roi.ts b/frontend/src/api/roi.ts new file mode 100644 index 0000000..b8eb649 --- /dev/null +++ b/frontend/src/api/roi.ts @@ -0,0 +1,139 @@ +import request from './request'; + +export type RoiCategory = 'cash_cow' | 'efficiency_tool' | 'moat' | 'composite'; +export type BizSystem = 'airhubs' | 'airflow' | 'aircore'; +export type ProjectType = 'hardware' | 'software'; +export type Confidence = 'high' | 'medium' | 'low'; + +export interface AggregateResult { + projectId: string; + from: string; + to: string; + totalCost: number; + totalRevenue: number; + netProfit: number; + roiValue: number | null; + confidence: Confidence; + bepDays: number | null; + costBreakdown: Record; + revenueBreakdown: Record; + costEventCount: number; + revenueEventCount: number; +} + +export interface TimeseriesBucket { + bucket: string; + cost: number; + revenue: number; + net: number; + cumulativeCost: number; + cumulativeRevenue: number; + cumulativeRoi: number | null; +} + +export interface DashboardResult { + from: string; + to: string; + summary: { + totalCost: number; + totalRevenue: number; + netProfit: number; + roiValue: number | null; + projectCount: number; + }; + byCategory: Record; + projects: Array<{ + projectId: string; + name: string; + identifier: string; + category: RoiCategory | null; + totalCost: number; + totalRevenue: number; + roiValue: number | null; + confidence: Confidence; + }>; +} + +export interface TagSuggestion { + suggestedCategory: RoiCategory; + suggestedBizSystem: BizSystem; + suggestedProjectType: ProjectType; + confidence: number; + reasoning: string; +} + +export interface Strategy { + id: string; + category: RoiCategory; + name: string; + formulaKey: string; + params: { + hourlyRate: number; + amortYears?: number; + commitHourCoef: number; + taskHourCoef: number; + }; + updatedAt: string; +} + +export interface DriverFactor { + type: '现金流驱动' | '降本增效驱动' | '技术资产驱动'; + text: string; +} + +// ── 核心聚合 ── +export const aggregateRoi = (projectId: string, from: string, to: string) => + request.get<{ code: number; data: AggregateResult }>(`/api/roi/aggregate`, { params: { projectId, from, to } }); + +export const timeseriesRoi = (projectId: string, from: string, to: string, granularity: 'day' | 'week' | 'month' | 'year' = 'month') => + request.get<{ code: number; data: TimeseriesBucket[] }>(`/api/roi/timeseries`, { params: { projectId, from, to, granularity } }); + +export const fetchDashboard = (from: string, to: string) => + request.get<{ code: number; data: DashboardResult }>(`/api/roi/dashboard`, { params: { from, to } }); + +// ── 事件流 ── +export const createCostEvent = (projectId: string, payload: any) => + request.post(`/api/projects/${projectId}/cost-events`, payload); + +export const createRevenueEvent = (projectId: string, payload: any) => + request.post(`/api/projects/${projectId}/revenue-events`, payload); + +export const listEvents = (projectId: string, type: 'cost' | 'revenue', from?: string, to?: string, limit = 100) => + request.get(`/api/projects/${projectId}/events`, { params: { type, from, to, limit } }); + +export const deleteEvent = (projectId: string, eventId: string, type: 'cost' | 'revenue') => + request.delete(`/api/projects/${projectId}/events/${eventId}`, { params: { type } }); + +// ── 策略 ── +export const fetchStrategies = () => + request.get<{ code: number; data: Strategy[] }>(`/api/roi/strategies`); + +export const updateStrategy = (id: string, params: any) => + request.patch(`/api/roi/strategies/${id}`, { params }); + +// ── 打标 ── +export interface TagPayload { + category: RoiCategory; + compositeStrategies?: ('cash_cow' | 'efficiency_tool' | 'moat')[] | null; + bizSystem?: BizSystem | null; + projectType?: ProjectType | null; + ownerId?: string | null; + launchedAt?: string | null; + vAsset?: number | null; + tags?: string[] | null; +} +export const tagProject = (projectId: string, payload: TagPayload) => + request.post(`/api/projects/${projectId}/tag`, payload); + +export const suggestTag = (projectId: string) => + request.post<{ code: number; data: TagSuggestion }>(`/api/projects/${projectId}/suggest-tag`, undefined, { timeout: 60000 }); + +// ── 项目映射 ── +export const listMapping = () => request.get(`/api/roi/mapping`); +export const createMapping = (payload: any) => request.post(`/api/roi/mapping`, payload); +export const deleteMapping = (id: string) => request.delete(`/api/roi/mapping/${id}`); +export const listUnmapped = () => request.get(`/api/roi/unmapped`); + +// ── 驱动因子 ── +export const fetchDriverFactors = (projectId: string, periodKey?: string) => + request.get(`/api/projects/${projectId}/driver-factors`, { params: { periodKey } }); diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 06c47a6..5bdeb6c 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -18,18 +18,62 @@ const dashStore = useDashboardStore(); // B-17: Track whether the Projects sub-menu is expanded const projectsExpanded = ref(false); -const projectList = ref>([]); +const projectList = ref>([]); async function loadProjectList() { try { const res = await getAdminProjectsApi(); const list = res.data.data || []; - projectList.value = list.map((p: any) => ({ projectId: p.id, name: p.name, identifier: p.identifier || '' })); + projectList.value = list.map((p: any) => ({ + projectId: p.id, + name: p.name, + identifier: p.identifier || '', + bizSystem: p.bizSystem || null, + })); } catch { // Silently fail } } +// 按产品线分组 +const BIZ_GROUPS: Array<{ key: 'airhubs' | 'airflow' | 'aircore' | 'uncategorized'; label: string; color: string }> = [ + { key: 'airhubs', label: 'airhubs · 硬件与潮玩业务线', color: '#0D9668' }, + { key: 'airflow', label: 'airflow · 内容生成与效能线', color: '#3B5998' }, + { key: 'aircore', label: 'aircore · 底层技术基座', color: '#D4920A' }, + { key: 'uncategorized', label: '未分类', color: '#6B7280' }, +]; + +// 各分组的展开状态(默认全部展开,localStorage 持久化) +const groupOpenState = ref>( + (() => { + try { return JSON.parse(localStorage.getItem('sidebar-group-open') || '{}'); } + catch { return {}; } + })() +); + +function isGroupOpen(key: string): boolean { + return groupOpenState.value[key] !== false; // 默认 true(undefined → 展开) +} + +function toggleGroup(key: string) { + groupOpenState.value = { ...groupOpenState.value, [key]: !isGroupOpen(key) }; + try { localStorage.setItem('sidebar-group-open', JSON.stringify(groupOpenState.value)); } catch {} +} + +const projectGroups = computed(() => { + const map: Record = { + airhubs: [], airflow: [], aircore: [], uncategorized: [], + }; + for (const p of projectList.value) { + const k = p.bizSystem || 'uncategorized'; + if (!map[k]) map[k] = []; + map[k].push(p); + } + return BIZ_GROUPS + .map(g => ({ ...g, projects: map[g.key] })) + .filter(g => g.projects.length > 0); +}); + onMounted(loadProjectList); // 路由变化时刷新项目列表(如从项目列表页返回时) @@ -58,6 +102,11 @@ const menuOptions = computed(() => { items.push({ label: 'Git 活动', key: '/git', icon: 'git-branch' }); } + // ROI 罗盘:仅 admin/manager + if (role === 'admin' || role === 'manager') { + items.push({ label: 'ROI 罗盘', key: '/roi', icon: 'trending-up' }); + } + // B-17: Members nav item (admin/manager only) if (role === 'admin' || role === 'manager') { items.push({ label: '成员', key: '/members', icon: 'users' }); @@ -78,6 +127,7 @@ const activeKey = computed(() => { if (route.path.startsWith('/members/')) return '/members'; if (route.path.startsWith('/okr')) return '/okr'; if (route.path.startsWith('/git')) return '/git'; + if (route.path.startsWith('/roi')) return '/roi'; if (route.path.startsWith('/admin')) return '/admin'; return '/'; }); @@ -165,20 +215,35 @@ const roleTagType = computed(() => { - +