feat(roi): ROI 动态规则引擎 v1 + 业务体系归属

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zyc 2026-05-22 13:20:22 +08:00
parent ad66228edc
commit 5af612e3fd
54 changed files with 4732 additions and 20 deletions

View File

@ -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());

View File

@ -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`);

View File

@ -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`);

View File

@ -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
}
]
}

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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() {

View File

@ -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),
}));

View File

@ -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);

View File

@ -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<string, Counter>();
const projectDayCounters = new Map<string, Counter>();
function tick(map: Map<string, Counter>, 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();
};
}

View File

@ -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<boolean> {
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<boolean> {
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);
}

View File

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

View File

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

View File

@ -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,

472
backend/src/routes/roi.ts Normal file
View File

@ -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<string, { totalCost: number; totalRevenue: number; netProfit: number; projectCount: number }> = {};
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' });
});

View File

@ -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<AggregateResult> {
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<AggregateResult[]> {
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;
}

View File

@ -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<DriverFactor[]> {
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<void> {
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(() => {});
}

View File

@ -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 DevOpsIAM
- 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<TagSuggestion> {
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<TagSuggestion>(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;
}

View File

@ -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<number | null> {
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);
}

View File

@ -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';
}

View File

@ -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<string, number>;
}
/**
* [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<IngestResult> {
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<string, string>();
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<string, RoiCategory | null>(
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<string, Set<string>>();
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<string>(); // 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;
}

View File

@ -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<string, number>;
}
/**
* [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<IngestResult> {
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<string, RoiCategory | null>(
allProjects.map(p => [p.id, p.category as RoiCategory | null])
);
// 3. 检查去重(按 projectId 分组查已存在的 plane_issue_id)
const issuesByProject = new Map<string, Set<string>>();
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<string>();
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;
}

View File

@ -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<void> {
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));
}

View File

@ -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<ProjectType, string> = {
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<string> {
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<string> {
// 已经有合规 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;
}

View File

@ -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<string, number>;
}
/**
* 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<AmortizeResult> {
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;
}

View File

@ -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<string, any>;
}
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<RevenueIngestResult> {
const result: RevenueIngestResult = {
insertedCount: 0,
unmappedCount: 0,
skippedCount: 0,
fetchedCount: 0,
errors: [],
};
// 1. 拉映射表
const mappings = await db.select().from(projectRevenueMapping);
const businessKeyToProject = new Map<string, string>();
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<string>();
// 拉 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<string>();
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<RemoteResponse> {
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');
}

View File

@ -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<void> {
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<void> {
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(() => {});
}
}

View File

@ -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<string, any>;
}
/** 字符串 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<string, CategoryProfile> = {
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<MockRevenueEvent[]> {
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<MockBusinessProject[]> {
const allProjects = await db.select().from(projects);
return allProjects
.filter(p => p.identifier)
.map(p => ({
businessProjectKey: p.identifier!,
name: p.name,
active: true,
}));
}

View File

@ -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<RoiCategory, StrategyParams> | null = null;
let cacheLoadedAt = 0;
const CACHE_TTL_MS = 60_000; // 1 分钟缓存,改完策略最多 1 分钟生效
async function loadCache(): Promise<Map<RoiCategory, StrategyParams>> {
const now = Date.now();
if (cache && now - cacheLoadedAt < CACHE_TTL_MS) return cache;
const rows = await db.select().from(roiStrategies);
const map = new Map<RoiCategory, StrategyParams>();
for (const row of rows) {
const p = (row.params as Partial<StrategyParams>) || {};
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<StrategyParams> {
const map = await loadCache();
const cat: RoiCategory = category ?? 'cash_cow';
return map.get(cat) ?? DEFAULTS;
}
export function invalidateStrategyCache(): void {
cache = null;
cacheLoadedAt = 0;
}

View File

@ -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<TimeseriesBucket[]> {
// 截断到 launchedAt,跟 aggregate 保持一致
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (project?.launchedAt) {
const launchedAt = project.launchedAt instanceof Date ? project.launchedAt : new Date(project.launchedAt);
if (from < launchedAt) from = launchedAt;
}
const [costEvents, revenueEvents] = await Promise.all([
db.select().from(projectCostEvents).where(and(
eq(projectCostEvents.projectId, projectId),
gte(projectCostEvents.eventDate, from),
lte(projectCostEvents.eventDate, to)
)).orderBy(asc(projectCostEvents.eventDate)),
db.select().from(projectRevenueEvents).where(and(
eq(projectRevenueEvents.projectId, projectId),
gte(projectRevenueEvents.eventDate, from),
lte(projectRevenueEvents.eventDate, to)
)).orderBy(asc(projectRevenueEvents.eventDate)),
]);
// 收集所有桶 key,初始化为 0
const buckets = new Map<string, { cost: number; revenue: number }>();
for (const e of costEvents) {
const k = bucketKey(toDate(e.eventDate), granularity);
if (!buckets.has(k)) buckets.set(k, { cost: 0, revenue: 0 });
buckets.get(k)!.cost += e.amount;
}
for (const e of revenueEvents) {
const k = bucketKey(toDate(e.eventDate), granularity);
if (!buckets.has(k)) buckets.set(k, { cost: 0, revenue: 0 });
buckets.get(k)!.revenue += e.amount;
}
// 按 key 排序输出
const sortedKeys = Array.from(buckets.keys()).sort();
let cumCost = 0, cumRevenue = 0;
return sortedKeys.map(k => {
const { cost, revenue } = buckets.get(k)!;
cumCost += cost;
cumRevenue += revenue;
return {
bucket: k,
cost: round2(cost),
revenue: round2(revenue),
net: round2(revenue - cost),
cumulativeCost: round2(cumCost),
cumulativeRevenue: round2(cumRevenue),
cumulativeRoi: cumCost > 0 ? round2((cumRevenue - cumCost) / cumCost * 100) : null,
};
});
}
function toDate(v: any): Date {
return v instanceof Date ? v : new Date(v);
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}

View File

@ -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;
}

View File

@ -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] 已停止同步任务');
}

View File

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

139
frontend/src/api/roi.ts Normal file
View File

@ -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<string, number>;
revenueBreakdown: Record<string, number>;
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<string, { totalCost: number; totalRevenue: number; netProfit: number; projectCount: number }>;
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 } });

View File

@ -18,18 +18,62 @@ const dashStore = useDashboardStore();
// B-17: Track whether the Projects sub-menu is expanded
const projectsExpanded = ref(false);
const projectList = ref<Array<{ projectId: string; name: string; identifier: string }>>([]);
const projectList = ref<Array<{ projectId: string; name: string; identifier: string; bizSystem: string | null }>>([]);
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<Record<string, boolean>>(
(() => {
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<string, typeof projectList.value> = {
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(() => {
</NTooltip>
</div>
<!-- B-17: Projects sub-menu -->
<!-- B-17: Projects sub-menu, grouped by bizSystem -->
<div
v-if="item.hasSubmenu && projectsExpanded && (!dashStore.sidebarCollapsed || dashStore.isMobile)"
class="submenu"
>
<div
v-for="proj in projectList"
:key="proj.projectId"
class="submenu-item"
:class="{ active: route.path === `/projects/${proj.projectId}` }"
@click="handleProjectSelect(proj.projectId)"
>
<span class="submenu-label">{{ proj.name }}</span>
</div>
<template v-for="group in projectGroups" :key="group.key">
<div
class="submenu-group-title"
:class="{ 'group-collapsed': !isGroupOpen(group.key) }"
:style="{ borderLeftColor: group.color }"
@click="toggleGroup(group.key)"
>
<span class="group-dot" :style="{ background: group.color }"></span>
<span class="group-arrow">{{ isGroupOpen(group.key) ? '▾' : '▸' }}</span>
{{ group.label }}
<span class="group-count">{{ group.projects.length }}</span>
</div>
<template v-if="isGroupOpen(group.key)">
<div
v-for="proj in group.projects"
:key="proj.projectId"
class="submenu-item"
:class="{ active: route.path === `/projects/${proj.projectId}` }"
@click="handleProjectSelect(proj.projectId)"
>
<span class="submenu-label">{{ proj.name }}</span>
</div>
</template>
</template>
<div v-if="!projectList.length" class="submenu-item submenu-empty">
暂无项目
</div>
@ -368,6 +433,51 @@ const roleTagType = computed(() => {
text-overflow: ellipsis;
}
/* 产品线分组标题 */
.submenu-group-title {
padding: 8px 12px 4px;
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: #9CA3AF;
letter-spacing: 0.3px;
border-left: 2px solid transparent;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
transition: color 0.15s, background 0.15s;
border-radius: 4px;
}
.submenu-group-title:hover {
color: #E5E7EB;
background: rgba(255,255,255,0.04);
}
.submenu-group-title.group-collapsed { opacity: 0.7; }
.group-arrow {
font-size: 10px;
width: 10px;
display: inline-flex;
justify-content: center;
color: #6B7280;
}
.group-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.group-count {
margin-left: auto;
font-size: 10px;
color: #6B7280;
background: rgba(255,255,255,0.06);
padding: 1px 6px;
border-radius: 8px;
font-weight: normal;
}
.nav-icon-only {
display: flex;
align-items: center;

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
const props = defineProps<{
byCategory: Record<string, { totalCost: number; totalRevenue: number; netProfit: number; projectCount: number }>;
}>();
const CATEGORY_LABELS: Record<string, string> = {
cash_cow: '💰 现金牛',
efficiency_tool: '⚙️ 效能工具',
moat: '💎 资本护城河',
composite: '🚀 复合型',
uncategorized: '未打标',
};
const option = computed(() => {
const keys = Object.keys(props.byCategory);
const labels = keys.map(k => CATEGORY_LABELS[k] || k);
const costs = keys.map(k => props.byCategory[k].totalCost);
const revenues = keys.map(k => props.byCategory[k].totalRevenue);
const nets = keys.map(k => props.byCategory[k].netProfit);
return {
color: [CHART_COLORS[5], CHART_COLORS[1], CHART_COLORS[0]],
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['成本', '产出', '净利'], top: 0 },
grid: { left: 60, right: 20, top: 35, bottom: 30 },
xAxis: { type: 'category', data: labels, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', axisLabel: { formatter: (v: number) => v >= 10000 ? (v / 10000).toFixed(1) + '万' : v.toFixed(0) } },
series: [
{ name: '成本', type: 'bar', stack: 'cost', data: costs },
{ name: '产出', type: 'bar', stack: 'revenue', data: revenues },
{ name: '净利', type: 'bar', stack: 'net', data: nets, itemStyle: { opacity: 0.6 } },
],
};
});
const { chartRef } = useECharts(option);
</script>
<template>
<div ref="chartRef" class="category-stacked-bar"></div>
</template>
<style scoped>
.category-stacked-bar { width: 100%; height: 320px; }
</style>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue';
import { NTag } from 'naive-ui';
type Confidence = 'high' | 'medium' | 'low';
const props = defineProps<{
confidence: Confidence;
showLabel?: boolean;
}>();
const tagType = computed<'success' | 'warning' | 'error'>(() => {
if (props.confidence === 'high') return 'success';
if (props.confidence === 'medium') return 'warning';
return 'error';
});
const label = computed(() => {
if (props.confidence === 'high') return 'High';
if (props.confidence === 'medium') return 'Medium';
return 'Low';
});
</script>
<template>
<NTag :type="tagType" size="small" round>
<span v-if="showLabel !== false">置信度</span> {{ label }}
</NTag>
</template>

View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import {
NModal, NForm, NFormItem, NSelect, NDatePicker, NInputNumber, NInput, NButton, useMessage,
} from 'naive-ui';
import { createCostEvent, createRevenueEvent } from '@/api/roi';
const props = defineProps<{
show: boolean;
type: 'cost' | 'revenue';
projectId: string;
}>();
const emit = defineEmits<{ 'update:show': [v: boolean]; saved: [] }>();
const message = useMessage();
const saving = ref(false);
const dateTs = ref<number | null>(Date.now());
const form = ref({
costType: 'hardware_bom' as 'dev_hours' | 'hardware_bom' | 'service_fee' | 'amortization' | 'other',
revenueType: 'direct_revenue' as 'direct_revenue' | 'subscription' | 'saved_cost' | 'asset_value_add' | 'refund' | 'other',
amount: 0,
channel: '',
notes: '',
});
const COST_OPTIONS = [
{ label: '研发工时', value: 'dev_hours' },
{ label: '硬件 BOM', value: 'hardware_bom' },
{ label: '服务费/运维', value: 'service_fee' },
{ label: '摊销', value: 'amortization' },
{ label: '其他', value: 'other' },
];
const REVENUE_OPTIONS = [
{ label: '直接营收', value: 'direct_revenue' },
{ label: '订阅', value: 'subscription' },
{ label: '节约成本(效能工具)', value: 'saved_cost' },
{ label: '资产增值(护城河)', value: 'asset_value_add' },
{ label: '退款/冲账', value: 'refund' },
{ label: '其他', value: 'other' },
];
watch(() => props.show, (s) => {
if (s) {
dateTs.value = Date.now();
form.value = { costType: 'hardware_bom', revenueType: 'direct_revenue', amount: 0, channel: '', notes: '' };
}
});
function formatDate(ts: number | null): string {
if (ts === null) return '';
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
async function handleSave() {
if (dateTs.value === null) { message.warning('请选择日期'); return; }
if (props.type === 'cost' && form.value.amount < 0) { message.warning('成本金额必须 >= 0'); return; }
saving.value = true;
try {
const eventDate = formatDate(dateTs.value);
if (props.type === 'cost') {
await createCostEvent(props.projectId, {
eventDate,
costType: form.value.costType,
amount: form.value.amount,
notes: form.value.notes || undefined,
});
} else {
await createRevenueEvent(props.projectId, {
eventDate,
revenueType: form.value.revenueType,
amount: form.value.amount,
channel: form.value.channel || undefined,
notes: form.value.notes || undefined,
});
}
message.success('已保存');
emit('saved');
emit('update:show', false);
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.message || e.message));
} finally {
saving.value = false;
}
}
</script>
<template>
<NModal :show="show" preset="card" :title="type === 'cost' ? '录入成本事件' : '录入产出事件'" style="width:500px"
@update:show="(v: boolean) => emit('update:show', v)">
<NForm label-placement="top" size="medium">
<NFormItem label="发生日期">
<NDatePicker v-model:value="dateTs" type="date" style="width:100%" />
</NFormItem>
<NFormItem v-if="type === 'cost'" label="成本类型">
<NSelect v-model:value="form.costType" :options="COST_OPTIONS" />
</NFormItem>
<NFormItem v-else label="产出类型">
<NSelect v-model:value="form.revenueType" :options="REVENUE_OPTIONS" />
</NFormItem>
<NFormItem :label="type === 'cost' ? '金额(元)' : '金额(元,退款用负数)'">
<NInputNumber v-model:value="form.amount" :min="type === 'cost' ? 0 : -1e8" :max="1e8" style="width:100%" />
</NFormItem>
<NFormItem v-if="type === 'revenue'" label="渠道(可选)">
<NInput v-model:value="form.channel" placeholder="alipay / wechat / stripe ..." />
</NFormItem>
<NFormItem label="备注(可选)">
<NInput v-model:value="form.notes" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" maxlength="500" />
</NFormItem>
<div style="display:flex;justify-content:flex-end;gap:8px">
<NButton @click="emit('update:show', false)">取消</NButton>
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
</div>
</NForm>
</NModal>
</template>

View File

@ -0,0 +1,254 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import {
NTag, NButton, NModal, NForm, NFormItem, NSelect, NCheckboxGroup, NCheckbox,
NDatePicker, NInputNumber, NAlert, NSpin, useMessage,
} from 'naive-ui';
import {
tagProject, suggestTag,
type RoiCategory, type BizSystem, type ProjectType, type TagPayload,
} from '@/api/roi';
const props = defineProps<{
projectId: string;
initialCategory?: RoiCategory | null;
initialCompositeStrategies?: string[] | null;
initialBizSystem?: BizSystem | null;
initialProjectType?: ProjectType | null;
initialLaunchedAt?: string | null;
initialVAsset?: number | null;
canEdit: boolean;
}>();
const emit = defineEmits<{ saved: [] }>();
const message = useMessage();
const open = ref(false);
const saving = ref(false);
const suggesting = ref(false);
interface AiSuggestion {
category: RoiCategory;
bizSystem: BizSystem;
projectType: ProjectType;
confidence: number;
reasoning: string;
}
const suggestion = ref<AiSuggestion | null>(null);
const form = ref<TagPayload>({
category: 'cash_cow',
compositeStrategies: null,
bizSystem: null,
projectType: null,
launchedAt: null,
vAsset: null,
});
const CATEGORY_META: Record<RoiCategory, { label: string; emoji: string; color: 'success' | 'info' | 'warning' | 'error' }> = {
cash_cow: { label: '现金牛', emoji: '💰', color: 'success' },
efficiency_tool: { label: '效能工具', emoji: '⚙️', color: 'info' },
moat: { label: '资本护城河', emoji: '💎', color: 'warning' },
composite: { label: '复合型', emoji: '🚀', color: 'error' },
};
const BIZ_META: Record<BizSystem, { label: string; sub: string }> = {
airhubs: { label: 'airhubs', sub: '硬件与潮玩业务线' },
airflow: { label: 'airflow', sub: '内容生成与效能线' },
aircore: { label: 'aircore', sub: '底层技术基座' },
};
const TYPE_LABEL: Record<ProjectType, string> = {
hardware: '硬件',
software: '软件',
};
const categoryOptions = (Object.keys(CATEGORY_META) as RoiCategory[]).map(k => ({
label: `${CATEGORY_META[k].emoji} ${CATEGORY_META[k].label}`,
value: k,
}));
const bizSystemOptions = (Object.keys(BIZ_META) as BizSystem[]).map(k => ({
label: `${BIZ_META[k].label}${BIZ_META[k].sub}`,
value: k,
}));
const projectTypeOptions = [
{ label: '🔧 硬件 (hardware)', value: 'hardware' },
{ label: '💻 软件 (software)', value: 'software' },
];
const subStrategyOptions = [
{ label: '💰 现金牛', value: 'cash_cow' },
{ label: '⚙️ 效能工具', value: 'efficiency_tool' },
{ label: '💎 资本护城河', value: 'moat' },
];
const needsAsset = computed(() => {
if (form.value.category === 'moat') return true;
if (form.value.category === 'composite' && (form.value.compositeStrategies || []).includes('moat')) return true;
return false;
});
const willRegenerateIdentifier = computed(() =>
!!(form.value.bizSystem && form.value.projectType) &&
(form.value.bizSystem !== props.initialBizSystem || form.value.projectType !== props.initialProjectType)
);
const launchedAtTs = ref<number | null>(null);
function openModal() {
form.value = {
category: props.initialCategory || 'cash_cow',
compositeStrategies: props.initialCompositeStrategies as any || null,
bizSystem: props.initialBizSystem || null,
projectType: props.initialProjectType || null,
launchedAt: props.initialLaunchedAt || null,
vAsset: props.initialVAsset || null,
};
launchedAtTs.value = props.initialLaunchedAt ? new Date(props.initialLaunchedAt).getTime() : null;
suggestion.value = null;
open.value = true;
}
watch(launchedAtTs, (ts) => {
if (ts === null) {
form.value.launchedAt = null;
} else {
const d = new Date(ts);
form.value.launchedAt = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
});
async function handleSuggest() {
suggesting.value = true;
try {
const res = await suggestTag(props.projectId);
const s = res.data.data;
suggestion.value = {
category: s.suggestedCategory,
bizSystem: s.suggestedBizSystem,
projectType: s.suggestedProjectType,
confidence: s.confidence,
reasoning: s.reasoning,
};
} catch (e: any) {
message.error('AI 推荐失败:' + (e?.response?.data?.message || e.message));
} finally {
suggesting.value = false;
}
}
function adoptSuggestion() {
if (!suggestion.value) return;
form.value.category = suggestion.value.category;
form.value.bizSystem = suggestion.value.bizSystem;
form.value.projectType = suggestion.value.projectType;
message.success('已采纳 AI 建议');
}
async function handleSave() {
if (needsAsset.value && (!form.value.vAsset || form.value.vAsset <= 0)) {
message.warning('资本护城河项目必须填写技术资产估值');
return;
}
saving.value = true;
try {
const res = await tagProject(props.projectId, form.value);
const newId = res.data?.data?.identifier;
if (newId && newId !== props.initialCategory) {
message.success(`已保存,新标识:${newId}`);
} else {
message.success('已保存');
}
open.value = false;
emit('saved');
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.message || e.message));
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="project-tag-selector">
<NTag v-if="initialCategory" :type="CATEGORY_META[initialCategory].color" round size="medium">
{{ CATEGORY_META[initialCategory].emoji }} {{ CATEGORY_META[initialCategory].label }}
</NTag>
<NTag v-else type="default" round size="medium">未打标</NTag>
<NButton v-if="canEdit" size="tiny" text type="primary" @click="openModal" style="margin-left:8px">
{{ initialCategory ? '编辑' : '+ 打标' }}
</NButton>
<NModal v-model:show="open" preset="card" title="项目商业定位打标" style="width:640px">
<NSpin :show="saving || suggesting">
<NForm label-placement="top" size="medium">
<NFormItem label="业务体系归属(决定项目标识前缀)">
<NSelect v-model:value="form.bizSystem" :options="bizSystemOptions" placeholder="选择 airhubs / airflow / aircore" clearable />
</NFormItem>
<NFormItem label="技术属性(硬件 / 软件)">
<NSelect v-model:value="form.projectType" :options="projectTypeOptions" placeholder="选择 hardware / software" clearable />
</NFormItem>
<NAlert v-if="willRegenerateIdentifier" type="info" style="margin:-4px 0 12px" :show-icon="false">
保存后将自动生成新标识(格式 {{ form.bizSystem }}-{{ form.projectType === 'hardware' ? 'hw' : 'sw' }}-XXX)
</NAlert>
<NFormItem label="ROI 分类标签">
<NSelect v-model:value="form.category" :options="categoryOptions" />
</NFormItem>
<NFormItem v-if="form.category === 'composite'" label="复合策略组合(至少选 2 个)">
<NCheckboxGroup v-model:value="form.compositeStrategies">
<div style="display:flex;gap:12px">
<NCheckbox v-for="opt in subStrategyOptions" :key="opt.value" :value="opt.value" :label="opt.label" />
</div>
</NCheckboxGroup>
</NFormItem>
<NFormItem label="立项日期(累计 ROI 起算点)">
<NDatePicker v-model:value="launchedAtTs" type="date" clearable style="width:100%" />
</NFormItem>
<NFormItem v-if="needsAsset" label="技术资产估值(元) — 资本护城河必填">
<NInputNumber v-model:value="form.vAsset" :min="0" :max="1e10" placeholder="如 360000" style="width:100%" />
</NFormItem>
<NAlert v-if="suggestion" type="info" style="margin:12px 0">
<div style="display:flex;justify-content:space-between;align-items:start;gap:12px">
<div style="flex:1">
<div style="font-weight:600;line-height:1.6">
AI 建议:
<NTag size="small" :type="CATEGORY_META[suggestion.category].color" round>{{ CATEGORY_META[suggestion.category].emoji }} {{ CATEGORY_META[suggestion.category].label }}</NTag>
<NTag size="small" type="info" round>{{ BIZ_META[suggestion.bizSystem].label }}</NTag>
<NTag size="small" type="default" round>{{ TYPE_LABEL[suggestion.projectType] }}</NTag>
<span style="color:var(--color-text-muted);margin-left:8px;font-weight:normal">置信度 {{ Math.round(suggestion.confidence * 100) }}%</span>
</div>
<div style="margin-top:6px;color:var(--color-text-muted);font-size:13px;line-height:1.5">{{ suggestion.reasoning }}</div>
</div>
<NButton size="small" type="primary" @click="adoptSuggestion">采纳</NButton>
</div>
</NAlert>
<div style="display:flex;justify-content:space-between;margin-top:16px">
<NButton @click="handleSuggest" :loading="suggesting">🤖 AI 推荐</NButton>
<div style="display:flex;gap:8px">
<NButton @click="open = false">取消</NButton>
<NButton type="primary" @click="handleSave" :loading="saving">保存</NButton>
</div>
</div>
</NForm>
</NSpin>
</NModal>
</div>
</template>
<style scoped>
.project-tag-selector {
display: inline-flex;
align-items: center;
gap: 4px;
}
</style>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
const props = defineProps<{
byCategory: Record<string, { totalRevenue: number }>;
}>();
const CATEGORY_LABELS: Record<string, string> = {
cash_cow: '💰 现金牛',
efficiency_tool: '⚙️ 效能工具',
moat: '💎 资本护城河',
composite: '🚀 复合型',
uncategorized: '未打标',
};
const option = computed(() => {
const data = Object.entries(props.byCategory)
.filter(([, v]) => v.totalRevenue > 0)
.map(([k, v]) => ({ name: CATEGORY_LABELS[k] || k, value: Math.round(v.totalRevenue) }));
return {
color: CHART_COLORS,
tooltip: { trigger: 'item', formatter: '{b}: ¥{c} ({d}%)' },
legend: { orient: 'vertical', left: 'left', top: 'middle', textStyle: { fontSize: 12 } },
series: [{
type: 'pie',
radius: ['40%', '70%'],
center: ['65%', '50%'],
label: { formatter: '{b}\n{d}%' },
data,
}],
};
});
const { chartRef } = useECharts(option);
</script>
<template>
<div ref="chartRef" class="revenue-pie"></div>
</template>
<style scoped>
.revenue-pie { width: 100%; height: 320px; }
</style>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
import type { TimeseriesBucket } from '@/api/roi';
const props = defineProps<{
buckets: TimeseriesBucket[];
granularity: 'day' | 'week' | 'month' | 'year';
}>();
const option = computed(() => {
const labels = props.buckets.map(b => b.bucket);
const costs = props.buckets.map(b => b.cost);
const revenues = props.buckets.map(b => b.revenue);
const cumRoi = props.buckets.map(b => b.cumulativeRoi);
return {
color: CHART_COLORS,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['本期成本', '本期产出', '累计 ROI'], top: 0 },
grid: { left: 60, right: 70, top: 35, bottom: 30 },
xAxis: { type: 'category', data: labels, axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '¥', position: 'left', axisLabel: { formatter: (v: number) => v >= 10000 ? (v / 10000).toFixed(1) + '万' : v.toFixed(0) } },
{ type: 'value', name: 'ROI %', position: 'right', axisLabel: { formatter: '{value}%' } },
],
series: [
{ name: '本期成本', type: 'bar', yAxisIndex: 0, data: costs, itemStyle: { color: CHART_COLORS[5] } },
{ name: '本期产出', type: 'bar', yAxisIndex: 0, data: revenues, itemStyle: { color: CHART_COLORS[1] } },
{
name: '累计 ROI',
type: 'line',
yAxisIndex: 1,
data: cumRoi,
smooth: true,
lineStyle: { width: 2 },
itemStyle: { color: CHART_COLORS[0] },
markLine: { silent: true, lineStyle: { color: '#888' }, data: [{ yAxis: 0, label: { formatter: '回本线' } }] },
},
],
};
});
const { chartRef } = useECharts(option);
</script>
<template>
<div ref="chartRef" class="roi-timeseries-chart"></div>
</template>
<style scoped>
.roi-timeseries-chart {
width: 100%;
height: 320px;
}
</style>

View File

@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref, onMounted, h } from 'vue';
import {
NSpin, NButton, NDataTable, NModal, NForm, NFormItem, NInput, NSelect, NSwitch, NTag, useMessage,
} from 'naive-ui';
import { listMapping, createMapping, deleteMapping, listUnmapped } from '@/api/roi';
import request from '@/api/request';
const message = useMessage();
const loading = ref(true);
const mappings = ref<any[]>([]);
const unmapped = ref<any[]>([]);
const projectOptions = ref<{ label: string; value: string }[]>([]);
const showModal = ref(false);
const form = ref({ projectId: '', businessProjectKey: '', enabled: true, notes: '' });
async function load() {
loading.value = true;
try {
const [m, u, p] = await Promise.all([
listMapping(),
listUnmapped(),
request.get('/api/projects'),
]);
mappings.value = m.data.data || [];
unmapped.value = u.data.data || [];
projectOptions.value = (p.data.data || []).map((x: any) => ({
label: `${x.identifier || x.id} - ${x.name}`,
value: x.id,
}));
} finally { loading.value = false; }
}
onMounted(load);
async function handleCreate() {
if (!form.value.projectId || !form.value.businessProjectKey) {
message.warning('请填写所有必填项');
return;
}
try {
await createMapping(form.value);
message.success('已新增映射');
showModal.value = false;
form.value = { projectId: '', businessProjectKey: '', enabled: true, notes: '' };
await load();
} catch (e: any) {
message.error('新增失败:' + (e?.response?.data?.message || e.message));
}
}
async function handleDelete(id: string) {
if (!confirm('确认删除该映射?')) return;
await deleteMapping(id);
message.success('已删除');
await load();
}
const mappingColumns = [
{ title: '业务方 Key', key: 'businessProjectKey' },
{ title: 'DevPerf 项目', key: 'projectId' },
{ title: '启用', key: 'enabled', render: (row: any) => row.enabled ? '✅' : '⛔' },
{ title: '备注', key: 'notes' },
{ title: '操作', key: 'actions', render: (row: any) => h(NButton, {
size: 'tiny', type: 'error', onClick: () => handleDelete(row.id),
}, () => '删除') },
];
const unmappedColumns = [
{ title: '业务方 Key', key: 'businessProjectKey' },
{ title: '日期', key: 'eventDate', render: (row: any) => row.eventDate?.slice(0, 10) },
{ title: '金额', key: 'amount', render: (row: any) => `¥${Number(row.amount).toLocaleString()}` },
{ title: '类型', key: 'revenueType' },
{ title: '状态', key: 'status' },
];
</script>
<template>
<NSpin :show="loading">
<div style="margin-bottom:12px;color:var(--color-text-muted);font-size:13px">
把外部业务系统的"项目 key"映射到 DevPerf 项目新增映射后,未来抓到的营收数据自动归到对应项目;之前堆在"未映射"里的数据需手动处理
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<strong>当前映射 ({{ mappings.length }})</strong>
<NButton type="primary" size="small" @click="showModal = true">+ 添加映射</NButton>
</div>
<NDataTable :columns="mappingColumns" :data="mappings" size="small" :bordered="false" />
<div style="margin-top:24px">
<strong style="color:var(--color-text-muted)"> 未映射的营收事件 ({{ unmapped.length }})</strong>
<div style="font-size:12px;color:var(--color-text-muted);margin:6px 0">
外部 API 拉到但未匹配到 DevPerf 项目的营收事件,先放在收容表里待处理新增对应映射后,后续数据会自动归类
</div>
<NDataTable :columns="unmappedColumns" :data="unmapped" size="small" :bordered="false" :max-height="300" />
</div>
<NModal v-model:show="showModal" preset="card" title="新增项目映射" style="width:500px">
<NForm label-placement="top">
<NFormItem label="业务方项目 Key(外部系统的 key)">
<NInput v-model:value="form.businessProjectKey" placeholder="如 PROD-A001" />
</NFormItem>
<NFormItem label="对应 DevPerf 项目">
<NSelect v-model:value="form.projectId" :options="projectOptions" filterable />
</NFormItem>
<NFormItem label="启用">
<NSwitch v-model:value="form.enabled" />
</NFormItem>
<NFormItem label="备注(可选)">
<NInput v-model:value="form.notes" type="textarea" :autosize="{ minRows: 2, maxRows: 3 }" />
</NFormItem>
<div style="display:flex;justify-content:flex-end;gap:8px">
<NButton @click="showModal = false">取消</NButton>
<NButton type="primary" @click="handleCreate">新增</NButton>
</div>
</NForm>
</NModal>
</NSpin>
</template>

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import {
NSpin, NCard, NForm, NFormItem, NInputNumber, NButton, useMessage,
} from 'naive-ui';
import { fetchStrategies, updateStrategy, type Strategy } from '@/api/roi';
const message = useMessage();
const loading = ref(true);
const strategies = ref<Strategy[]>([]);
const saving = ref<Record<string, boolean>>({});
async function load() {
loading.value = true;
try {
const res = await fetchStrategies();
strategies.value = res.data.data;
} finally { loading.value = false; }
}
onMounted(load);
async function save(s: Strategy) {
saving.value[s.id] = true;
try {
await updateStrategy(s.id, s.params);
message.success('已保存:' + s.name);
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.message || e.message));
} finally {
saving.value[s.id] = false;
}
}
const CATEGORY_LABELS: Record<string, string> = {
cash_cow: '💰 现金牛',
efficiency_tool: '⚙️ 效能工具',
moat: '💎 资本护城河',
composite: '🚀 复合型',
};
</script>
<template>
<NSpin :show="loading">
<div style="margin-bottom:12px;color:var(--color-text-muted);font-size:13px">
调整全局 ROI 计算参数修改后只影响新增的成本事件,历史数据用当时的 R_h 快照,不受影响
</div>
<div class="strategies-grid">
<NCard v-for="s in strategies" :key="s.id" size="small" :title="CATEGORY_LABELS[s.category] || s.category">
<NForm label-placement="left" label-width="160" size="small">
<NFormItem label="综合人时成本(¥/h)">
<NInputNumber v-model:value="s.params.hourlyRate" :min="0" :max="10000" style="width:120px" />
</NFormItem>
<NFormItem v-if="s.category === 'moat' || s.category === 'composite'" label="资产摊销年限(年)">
<NInputNumber v-model:value="s.params.amortYears" :min="1" :max="20" style="width:120px" />
</NFormItem>
<NFormItem label="每 commit 系数(h)">
<NInputNumber v-model:value="s.params.commitHourCoef" :min="0" :max="40" :step="0.1" style="width:120px" />
</NFormItem>
<NFormItem label="每 task 系数(h)">
<NInputNumber v-model:value="s.params.taskHourCoef" :min="0" :max="80" :step="0.5" style="width:120px" />
</NFormItem>
<NButton type="primary" size="small" :loading="saving[s.id]" @click="save(s)">保存</NButton>
</NForm>
</NCard>
</div>
</NSpin>
</template>
<style scoped>
.strategies-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
@media (max-width: 900px) { .strategies-grid { grid-template-columns: 1fr; } }
</style>

View File

@ -1,13 +1,14 @@
import { ref, nextTick, onMounted, onUnmounted, watch, type Ref, shallowRef } from 'vue';
import * as echarts from 'echarts/core';
import { BarChart, LineChart, PieChart, RadarChart, HeatmapChart, CustomChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, DataZoomComponent, ToolboxComponent, VisualMapComponent, CalendarComponent } from 'echarts/components';
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, DataZoomComponent, ToolboxComponent, VisualMapComponent, CalendarComponent, MarkLineComponent, GraphicComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
BarChart, LineChart, PieChart, RadarChart, HeatmapChart, CustomChart,
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
DataZoomComponent, ToolboxComponent, VisualMapComponent, CalendarComponent,
MarkLineComponent, GraphicComponent,
CanvasRenderer,
]);

View File

@ -31,6 +31,17 @@ const router = createRouter({
name: 'ProjectDetail',
component: () => import('@/views/ProjectDetail.vue'),
},
{
path: 'projects/:id/roi',
name: 'ProjectRoiBoard',
component: () => import('@/views/ProjectRoiBoard.vue'),
},
{
path: 'roi',
name: 'RoiDashboard',
component: () => import('@/views/RoiDashboard.vue'),
meta: { roles: ['admin', 'manager'] },
},
// B-17 fix: added member list route
{
path: 'members',

View File

@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue';
import { NTabs, NTabPane, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NSelect, NSpin, NTag, useMessage } from 'naive-ui';
import StrategiesPanel from '@/components/roi/admin/StrategiesPanel.vue';
import MappingPanel from '@/components/roi/admin/MappingPanel.vue';
import { getAdminUsersApi, createUserApi, deleteUserApi, getAuthorMappingsApi, createMappingApi, deleteMappingApi, getSyncLogsApi, triggerSyncApi, getAdminProjectsApi, setUserProjectsApi } from '@/api/admin';
import dayjs from 'dayjs';
@ -314,6 +316,14 @@ const roleOptions = [
<NDataTable :columns="logColumns" :data="logsData" :loading="logsLoading" :bordered="false" size="small" />
</div>
</NTabPane>
<NTabPane name="roi-strategies" tab="ROI 策略">
<StrategiesPanel />
</NTabPane>
<NTabPane name="roi-mapping" tab="ROI 项目映射">
<MappingPanel />
</NTabPane>
</NTabs>
<!-- 创建用户弹窗 -->

View File

@ -6,6 +6,7 @@ import { getProjectDetailApi } from '@/api/projects';
import { createObjectiveApi, createKeyResultApi, updateKeyResultApi, deleteObjectiveApi, deleteKeyResultApi, postponeKRApi, pauseKRApi, resumeKRApi, cancelKRApi, getKRLogsApi } from '@/api/okr';
import request from '@/api/request';
import DataCard from '@/components/shared/DataCard.vue';
import ProjectTagSelector from '@/components/roi/ProjectTagSelector.vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
import { useAuthStore } from '@/stores/auth';
import dayjs from 'dayjs';
@ -34,6 +35,10 @@ onMounted(loadData);
//
watch(() => route.params.id, () => loadData());
function goToRoiBoard() {
router.push(`/projects/${projectId.value}/roi`);
}
//
const allUsers = ref<any[]>([]);
const userOptions = computed(() => allUsers.value.map(u => ({ value: u.id, label: u.displayName })));
@ -324,8 +329,26 @@ function canEditObj(obj: any): boolean {
<!-- 项目标题 + 整体进度 -->
<div class="overall-progress">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2 style="margin:0;font-size:18px">{{ data.project?.identifier }} - {{ data.project?.name }}</h2>
<span class="tabular-nums" style="font-size:22px;font-weight:700;color:var(--color-primary-hex)" v-if="data.okr?.objectives?.length">{{ data.okr.overallProgress }}%</span>
<div style="display:flex;align-items:center;gap:12px">
<h2 style="margin:0;font-size:18px">{{ data.project?.name }}</h2>
<ProjectTagSelector
:project-id="projectId"
:initial-category="data.project?.category || null"
:initial-composite-strategies="data.project?.compositeStrategies || null"
:initial-biz-system="data.project?.bizSystem || null"
:initial-project-type="data.project?.projectType || null"
:initial-launched-at="data.project?.launchedAt || null"
:initial-v-asset="data.project?.vAsset || null"
:can-edit="authStore.canEdit"
@saved="loadData"
/>
</div>
<div style="display:flex;align-items:center;gap:12px">
<NButton size="small" type="primary" ghost @click="goToRoiBoard">
💎 ROI 看板
</NButton>
<span class="tabular-nums" style="font-size:22px;font-weight:700;color:var(--color-primary-hex)" v-if="data.okr?.objectives?.length">{{ data.okr.overallProgress }}%</span>
</div>
</div>
<div v-if="data.okr?.objectives?.length" style="margin-top:8px">
<NProgress type="line" :percentage="clamp(data.okr.overallProgress)" :show-indicator="false" style="width:100%" />

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, h } from 'vue';
import { ref, computed, onMounted, h } from 'vue';
import { useRouter } from 'vue-router';
import { NSpin, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NTag, NEmpty, useMessage } from 'naive-ui';
import { getAdminProjectsApi, createProjectApi, updateProjectApi, deleteProjectApi, bindRepoApi, getProjectReposApi, unbindRepoApi } from '@/api/admin';
@ -11,6 +11,53 @@ const message = useMessage();
const loading = ref(true);
const projects = ref<any[]>([]);
//
const selectedBiz = ref<string[]>([]); // =
const selectedType = ref<string[]>([]); // =
function toggleBiz(k: string) {
const i = selectedBiz.value.indexOf(k);
if (i >= 0) selectedBiz.value.splice(i, 1);
else selectedBiz.value.push(k);
}
function toggleType(k: string) {
const i = selectedType.value.indexOf(k);
if (i >= 0) selectedType.value.splice(i, 1);
else selectedType.value.push(k);
}
function clearFilters() {
selectedBiz.value = [];
selectedType.value = [];
}
// 线/
const bizCounts = computed(() => {
const c: Record<string, number> = { airhubs: 0, airflow: 0, aircore: 0, uncategorized: 0 };
for (const p of projects.value) c[p.bizSystem || 'uncategorized'] = (c[p.bizSystem || 'uncategorized'] || 0) + 1;
return c;
});
const typeCounts = computed(() => {
const c: Record<string, number> = { hardware: 0, software: 0, uncategorized: 0 };
for (const p of projects.value) c[p.projectType || 'uncategorized'] = (c[p.projectType || 'uncategorized'] || 0) + 1;
return c;
});
const filteredProjects = computed(() => {
return projects.value.filter(p => {
if (selectedBiz.value.length > 0 && !selectedBiz.value.includes(p.bizSystem || 'uncategorized')) return false;
if (selectedType.value.length > 0 && !selectedType.value.includes(p.projectType || 'uncategorized')) return false;
return true;
});
});
function chipColor(k: string): string {
const map: Record<string, string> = {
airhubs: '#0D9668', airflow: '#3B5998', aircore: '#D4920A',
hardware: '#7C4DBA', software: '#2B8CA3',
};
return map[k] || '#666';
}
const userRole = (() => {
try { return JSON.parse(localStorage.getItem('user') || '{}').role || 'viewer'; }
catch { return 'viewer'; }
@ -164,9 +211,35 @@ function extractRepoName(raw: string) {
return cleaned;
}
// 线 / meta
const BIZ_META: Record<string, { label: string; color: 'success' | 'info' | 'warning' | 'error' | 'default' }> = {
airhubs: { label: 'airhubs', color: 'success' },
airflow: { label: 'airflow', color: 'info' },
aircore: { label: 'aircore', color: 'warning' },
};
const TYPE_META: Record<string, { label: string; emoji: string }> = {
hardware: { label: '硬件', emoji: '🔧' },
software: { label: '软件', emoji: '💻' },
};
//
const columns = [
{ title: '标识', key: 'identifier', width: 100 },
{
title: '产品线',
key: 'bizSystem',
width: 200,
render: (row: any) => {
if (!row.bizSystem && !row.projectType) {
return h('span', { style: 'color:var(--color-text-muted);font-size:12px' }, '未分类');
}
const biz = row.bizSystem ? BIZ_META[row.bizSystem] : null;
const type = row.projectType ? TYPE_META[row.projectType] : null;
return h('div', { style: 'display:flex;gap:4px;align-items:center;flex-wrap:wrap' }, [
biz ? h(NTag, { size: 'small', type: biz.color, round: true }, { default: () => biz.label }) : null,
type ? h(NTag, { size: 'small', type: 'default', round: true }, { default: () => `${type.emoji} ${type.label}` }) : null,
]);
},
},
{ title: '项目名称', key: 'name', ellipsis: { tooltip: true } },
{
title: '绑定仓库',
@ -210,8 +283,39 @@ const columns = [
</div>
<NSpin :show="loading">
<DataCard v-if="projects.length" title="全部项目" :subtitle="`${projects.length} 个项目`">
<NDataTable :columns="columns" :data="projects" :bordered="false" size="small" />
<DataCard v-if="projects.length" title="全部项目" :subtitle="`显示 ${filteredProjects.length} / ${projects.length} 个项目`">
<div class="filter-bar">
<div class="filter-row">
<span class="filter-label">产品线</span>
<button
v-for="k in ['airhubs', 'airflow', 'aircore']" :key="k"
class="chip"
:class="{ active: selectedBiz.includes(k), disabled: (bizCounts[k] || 0) === 0 }"
:style="selectedBiz.includes(k) ? { background: chipColor(k), color: '#fff', borderColor: chipColor(k) } : {}"
:disabled="(bizCounts[k] || 0) === 0"
@click="toggleBiz(k)"
>{{ k }} <span class="chip-count">{{ bizCounts[k] || 0 }}</span></button>
</div>
<div class="filter-row">
<span class="filter-label">软硬件</span>
<button
class="chip"
:class="{ active: selectedType.includes('hardware'), disabled: (typeCounts.hardware || 0) === 0 }"
:style="selectedType.includes('hardware') ? { background: chipColor('hardware'), color: '#fff', borderColor: chipColor('hardware') } : {}"
:disabled="(typeCounts.hardware || 0) === 0"
@click="toggleType('hardware')"
>🔧 硬件 <span class="chip-count">{{ typeCounts.hardware || 0 }}</span></button>
<button
class="chip"
:class="{ active: selectedType.includes('software'), disabled: (typeCounts.software || 0) === 0 }"
:style="selectedType.includes('software') ? { background: chipColor('software'), color: '#fff', borderColor: chipColor('software') } : {}"
:disabled="(typeCounts.software || 0) === 0"
@click="toggleType('software')"
>💻 软件 <span class="chip-count">{{ typeCounts.software || 0 }}</span></button>
<NButton v-if="selectedBiz.length > 0 || selectedType.length > 0" size="tiny" text type="primary" @click="clearFilters">清空筛选</NButton>
</div>
</div>
<NDataTable :columns="columns" :data="filteredProjects" :bordered="false" size="small" />
</DataCard>
<EmptyState v-else-if="!loading" title="暂无项目" description="点击「创建项目」开始添加。" />
</NSpin>
@ -281,4 +385,25 @@ const columns = [
<style scoped>
.project-list-page { max-width: 960px; }
.filter-bar { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--n-border-color, #eef0f3); }
.filter-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.filter-label { font-size: 12px; color: var(--color-text-muted); min-width: 48px; }
.chip {
border: 1px solid var(--n-border-color, #e5e7eb);
background: transparent;
border-radius: 999px;
padding: 3px 10px;
font-size: 12px;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--color-text, #333);
transition: all 0.15s;
}
.chip:hover:not(.disabled) { border-color: var(--color-primary-hex, #3B5998); }
.chip.active { font-weight: 600; }
.chip.disabled { opacity: 0.35; cursor: not-allowed; }
.chip-count { margin-left: 4px; opacity: 0.7; font-size: 11px; font-weight: normal; }
</style>

View File

@ -0,0 +1,297 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
NSpin, NButton, NSelect, NDatePicker, NTag, NEmpty, NCard, NTimeline, NTimelineItem, useMessage,
} from 'naive-ui';
import dayjs from 'dayjs';
import {
aggregateRoi, timeseriesRoi, listEvents, fetchDriverFactors,
type AggregateResult, type TimeseriesBucket, type DriverFactor,
} from '@/api/roi';
import request from '@/api/request';
import { useAuthStore } from '@/stores/auth';
import ConfidenceBadge from '@/components/roi/ConfidenceBadge.vue';
import RoiTimeSeriesChart from '@/components/roi/RoiTimeSeriesChart.vue';
import EventEntryModal from '@/components/roi/EventEntryModal.vue';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const message = useMessage();
const projectId = computed(() => route.params.id as string);
const loading = ref(true);
const project = ref<any>(null);
//
const lifetimeAgg = ref<AggregateResult | null>(null);
const monthAgg = ref<AggregateResult | null>(null);
const ytdAgg = ref<AggregateResult | null>(null);
const tsBuckets = ref<TimeseriesBucket[]>([]);
const granularity = ref<'week' | 'month' | 'year'>('month');
const driverFactors = ref<DriverFactor[]>([]);
const recentCostEvents = ref<any[]>([]);
const recentRevenueEvents = ref<any[]>([]);
const entryModalType = ref<'cost' | 'revenue'>('revenue');
const showEntryModal = ref(false);
function todayStr(): string {
return dayjs().format('YYYY-MM-DD');
}
function monthStartStr(): string {
return dayjs().startOf('month').format('YYYY-MM-DD');
}
function yearStartStr(): string {
return dayjs().startOf('year').format('YYYY-MM-DD');
}
const launchedAtStr = computed(() => {
if (!project.value?.launchedAt) return null;
return dayjs(project.value.launchedAt).format('YYYY-MM-DD');
});
async function loadProject() {
const res = await request.get(`/api/projects/${projectId.value}`);
project.value = res.data.data.project;
}
async function loadAggregates() {
const today = todayStr();
// ROI launchedAt ; 1900-01-01 aggregator
const lifetimeFrom = launchedAtStr.value || '1900-01-01';
const [lifetime, month, ytd] = await Promise.all([
aggregateRoi(projectId.value, lifetimeFrom, today),
aggregateRoi(projectId.value, monthStartStr(), today),
aggregateRoi(projectId.value, yearStartStr(), today),
]);
lifetimeAgg.value = lifetime.data.data;
monthAgg.value = month.data.data;
ytdAgg.value = ytd.data.data;
}
async function loadTimeseries() {
const today = todayStr();
let from: string;
switch (granularity.value) {
case 'week': from = dayjs().subtract(26, 'week').startOf('week').format('YYYY-MM-DD'); break;
case 'month': from = dayjs().subtract(12, 'month').startOf('month').format('YYYY-MM-DD'); break;
case 'year': from = dayjs().subtract(5, 'year').startOf('year').format('YYYY-MM-DD'); break;
}
const res = await timeseriesRoi(projectId.value, from, today, granularity.value);
tsBuckets.value = res.data.data;
}
async function loadDriverFactors() {
try {
const res = await fetchDriverFactors(projectId.value);
const latest = (res.data.data || [])[0];
driverFactors.value = (latest?.factors as DriverFactor[]) || [];
} catch { driverFactors.value = []; }
}
async function loadRecentEvents() {
const [cost, revenue] = await Promise.all([
listEvents(projectId.value, 'cost', undefined, undefined, 30),
listEvents(projectId.value, 'revenue', undefined, undefined, 30),
]);
recentCostEvents.value = cost.data.data || [];
recentRevenueEvents.value = revenue.data.data || [];
}
async function loadAll() {
loading.value = true;
try {
await loadProject();
await Promise.all([loadAggregates(), loadTimeseries(), loadDriverFactors(), loadRecentEvents()]);
} catch (e: any) {
message.error('加载失败:' + (e?.response?.data?.message || e.message));
} finally {
loading.value = false;
}
}
onMounted(loadAll);
watch(() => projectId.value, loadAll);
watch(granularity, loadTimeseries);
function fmtCurrency(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `¥${Math.round(n).toLocaleString()}`;
}
function fmtPercent(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `${n.toFixed(1)}%`;
}
function roiColor(n: number | null | undefined): string {
if (n === null || n === undefined) return 'var(--color-text-muted)';
if (n >= 100) return '#0D9668';
if (n >= 0) return '#D4920A';
return '#DC2626';
}
function bepDisplay(): string {
const bep = lifetimeAgg.value?.bepDays;
if (bep === null || bep === undefined) return '按当前趋势暂无法回本';
if (bep === 0) return '已回本 ✓';
const months = Math.round(bep / 30 * 10) / 10;
return `预计 ${months} 月回本`;
}
const isBepWarn = computed(() => lifetimeAgg.value?.bepDays === null);
</script>
<template>
<div class="roi-board-page">
<NSpin :show="loading">
<!-- Header -->
<div class="board-header">
<div style="display:flex;align-items:center;gap:12px">
<NButton size="small" @click="router.push(`/projects/${projectId}`)"> 返回项目</NButton>
<h2 style="margin:0;font-size:18px" v-if="project">{{ project.name }} · ROI 看板</h2>
</div>
<div v-if="authStore.canEdit" style="display:flex;gap:8px">
<NButton size="small" @click="entryModalType = 'cost'; showEntryModal = true">+ 录入成本</NButton>
<NButton size="small" type="primary" @click="entryModalType = 'revenue'; showEntryModal = true">+ 录入产出</NButton>
</div>
</div>
<!-- 顶部 4 张大卡片 -->
<div class="kpi-grid">
<NCard size="small">
<div class="kpi-label">历史总造价</div>
<div class="kpi-value tabular-nums">{{ fmtCurrency(lifetimeAgg?.totalCost) }}</div>
<div class="kpi-sub" v-if="lifetimeAgg"> {{ launchedAtStr || '立项' }} 至今</div>
</NCard>
<NCard size="small">
<div class="kpi-label">历史总产出</div>
<div class="kpi-value tabular-nums" style="color:#0D9668">{{ fmtCurrency(lifetimeAgg?.totalRevenue) }}</div>
<div class="kpi-sub" v-if="lifetimeAgg">净利 {{ fmtCurrency(lifetimeAgg?.netProfit) }}</div>
</NCard>
<NCard size="small">
<div class="kpi-label">累计 ROI</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeAgg?.roiValue) }">
{{ fmtPercent(lifetimeAgg?.roiValue) }}
</div>
<div class="kpi-sub" :style="{ color: isBepWarn ? '#DC2626' : 'var(--color-text-muted)' }">
<ConfidenceBadge v-if="lifetimeAgg" :confidence="lifetimeAgg.confidence" :show-label="false" />
<span style="margin-left:6px">{{ bepDisplay() }}</span>
</div>
</NCard>
<NCard size="small">
<div class="kpi-label">本月 ROI 趋势</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(monthAgg?.roiValue) }">
{{ fmtPercent(monthAgg?.roiValue) }}
</div>
<div class="kpi-sub" v-if="monthAgg">本月成本 {{ fmtCurrency(monthAgg.totalCost) }} / 产出 {{ fmtCurrency(monthAgg.totalRevenue) }}</div>
</NCard>
</div>
<!-- YTD 提示 -->
<div v-if="ytdAgg" class="ytd-strip">
<NTag round size="small">本年 (YTD) ROI: <strong style="margin-left:4px">{{ fmtPercent(ytdAgg.roiValue) }}</strong></NTag>
<NTag round size="small" type="info">YTD 成本 {{ fmtCurrency(ytdAgg.totalCost) }} / 产出 {{ fmtCurrency(ytdAgg.totalRevenue) }}</NTag>
</div>
<!-- 折线图 -->
<NCard size="small" style="margin-top:16px">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span>成本 vs 产出 趋势</span>
<NSelect v-model:value="granularity" :options="[
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' },
]" style="width:90px" size="small" />
</div>
</template>
<RoiTimeSeriesChart v-if="tsBuckets.length > 0" :buckets="tsBuckets" :granularity="granularity" />
<NEmpty v-else description="暂无数据" />
</NCard>
<!-- 驱动因子(独占一行)-->
<NCard size="small" title="价值驱动因子(AI 解读)" style="margin-top:16px">
<div v-if="driverFactors.length > 0">
<div v-for="(f, i) in driverFactors" :key="i" class="factor-row">
<NTag :type="f.type === '现金流驱动' ? 'success' : f.type === '降本增效驱动' ? 'info' : 'warning'" size="small">{{ f.type }}</NTag>
<span style="margin-left:8px">{{ f.text }}</span>
</div>
</div>
<NEmpty v-else description="月度 AI 解读暂无(每月 1 号自动生成)" />
</NCard>
<!-- 事件流明细:成本 + 产出 并排 -->
<div class="two-col" style="margin-top:16px">
<NCard size="small">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span>💸 成本事件流</span>
<span class="event-count">{{ recentCostEvents.length }} </span>
</div>
</template>
<NTimeline v-if="recentCostEvents.length > 0">
<NTimelineItem
v-for="e in recentCostEvents.slice(0, 20)"
:key="e.id"
type="error"
:title="`${e.costType} · ${fmtCurrency(e.amount)}`"
:content="e.notes || ''"
:time="dayjs(e.eventDate).format('YYYY-MM-DD') + (e.dataSource ? ` · ${e.dataSource}` : '')"
/>
</NTimeline>
<NEmpty v-else description="暂无成本事件" />
</NCard>
<NCard size="small">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span>💰 产出事件流</span>
<span class="event-count">{{ recentRevenueEvents.length }} </span>
</div>
</template>
<NTimeline v-if="recentRevenueEvents.length > 0">
<NTimelineItem
v-for="e in recentRevenueEvents.slice(0, 20)"
:key="e.id"
:type="e.amount < 0 ? 'warning' : 'success'"
:title="`${e.revenueType} · ${fmtCurrency(e.amount)}`"
:content="e.notes || ''"
:time="dayjs(e.eventDate).format('YYYY-MM-DD') + (e.dataSource ? ` · ${e.dataSource}` : '')"
/>
</NTimeline>
<NEmpty v-else description="暂无产出事件" />
</NCard>
</div>
</NSpin>
<EventEntryModal v-model:show="showEntryModal" :type="entryModalType" :project-id="projectId" @saved="loadAll" />
</div>
</template>
<style scoped>
.roi-board-page { padding: var(--space-4); }
.board-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; }
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.kpi-label { font-size: 13px; color: var(--color-text-muted); }
.kpi-value { font-size: 26px; font-weight: 700; margin-top: 6px; }
.kpi-sub { font-size: 12px; color: var(--color-text-muted); margin-top: 6px; display:flex; align-items:center; }
.ytd-strip { display: flex; gap: 8px; margin-top: 12px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.factor-row { margin: 8px 0; line-height: 1.5; }
.event-count {
font-size: 12px;
color: var(--color-text-muted);
background: rgba(0,0,0,0.05);
padding: 2px 8px;
border-radius: 999px;
}
@media (max-width: 1024px) {
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
.two-col { grid-template-columns: 1fr; }
}
</style>

View File

@ -0,0 +1,330 @@
<script setup lang="ts">
import { h, ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import {
NSpin, NCard, NSelect, NEmpty, NDataTable, NTag, NSwitch, NButton, useMessage,
} from 'naive-ui';
import dayjs from 'dayjs';
import { fetchDashboard, type DashboardResult } from '@/api/roi';
import ConfidenceBadge from '@/components/roi/ConfidenceBadge.vue';
import CategoryStackedBar from '@/components/roi/CategoryStackedBar.vue';
import RevenuePieChart from '@/components/roi/RevenuePieChart.vue';
const router = useRouter();
const message = useMessage();
const loading = ref(true);
type WindowKey = 'lifetime' | 'ytd' | 'mtd' | 'custom';
const windowKey = ref<WindowKey>('lifetime');
function rangeOf(key: WindowKey): { from: string; to: string } {
const today = dayjs().format('YYYY-MM-DD');
if (key === 'lifetime') return { from: '2020-01-01', to: today };
if (key === 'ytd') return { from: dayjs().startOf('year').format('YYYY-MM-DD'), to: today };
return { from: dayjs().startOf('month').format('YYYY-MM-DD'), to: today };
}
const lifetimeData = ref<DashboardResult | null>(null);
const ytdData = ref<DashboardResult | null>(null);
const mtdData = ref<DashboardResult | null>(null);
async function loadAll() {
loading.value = true;
try {
const [l, y, m] = await Promise.all([
fetchDashboard(rangeOf('lifetime').from, rangeOf('lifetime').to),
fetchDashboard(rangeOf('ytd').from, rangeOf('ytd').to),
fetchDashboard(rangeOf('mtd').from, rangeOf('mtd').to),
]);
lifetimeData.value = l.data.data;
ytdData.value = y.data.data;
mtdData.value = m.data.data;
} catch (e: any) {
message.error('加载失败:' + (e?.response?.data?.message || e.message));
} finally {
loading.value = false;
}
}
onMounted(loadAll);
const activeData = computed(() => {
if (windowKey.value === 'lifetime') return lifetimeData.value;
if (windowKey.value === 'ytd') return ytdData.value;
return mtdData.value;
});
function fmtCurrency(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `¥${Math.round(n).toLocaleString()}`;
}
function fmtPercent(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `${n.toFixed(1)}%`;
}
function roiColor(n: number | null | undefined): string {
if (n === null || n === undefined) return 'var(--color-text-muted)';
if (n >= 100) return '#0D9668';
if (n >= 0) return '#D4920A';
return '#DC2626';
}
const CATEGORY_META: Record<string, { emoji: string; label: string; color: 'success' | 'info' | 'warning' | 'error' | 'default' }> = {
cash_cow: { emoji: '💰', label: '现金牛', color: 'success' },
efficiency_tool: { emoji: '⚙️', label: '效能工具', color: 'info' },
moat: { emoji: '💎', label: '资本护城河', color: 'warning' },
composite: { emoji: '🚀', label: '复合型', color: 'error' },
uncategorized: { emoji: '◯', label: '未打标', color: 'default' },
};
//
const selectedCategories = ref<string[]>([]); // =
const hideZeroCost = ref(true);
function toggleCategory(cat: string) {
const i = selectedCategories.value.indexOf(cat);
if (i >= 0) selectedCategories.value.splice(i, 1);
else selectedCategories.value.push(cat);
}
function chipColor(t: string): string {
return ({ success: '#0D9668', info: '#3B5998', warning: '#D4920A', error: '#DC2626', default: '#666' } as Record<string, string>)[t] || '#666';
}
// ()
const categoryStats = computed(() => {
const stats: Record<string, { count: number; totalCost: number; totalRevenue: number }> = {};
for (const k of Object.keys(CATEGORY_META)) {
stats[k] = { count: 0, totalCost: 0, totalRevenue: 0 };
}
const items = activeData.value?.projects || [];
for (const p of items) {
const k = p.category || 'uncategorized';
if (!stats[k]) stats[k] = { count: 0, totalCost: 0, totalRevenue: 0 };
stats[k].count += 1;
stats[k].totalCost += p.totalCost;
stats[k].totalRevenue += p.totalRevenue;
}
return stats;
});
// /
const filteredProjects = computed(() => {
let items = (activeData.value?.projects || []).slice();
if (selectedCategories.value.length > 0) {
items = items.filter(p => selectedCategories.value.includes(p.category || 'uncategorized'));
}
if (hideZeroCost.value) {
items = items.filter(p => p.totalCost > 0);
}
return items;
});
const projectColumns = [
{
title: '项目', key: 'name',
render: (row: any) => row.name,
sorter: (a: any, b: any) => (a.name || '').localeCompare(b.name || ''),
},
{
title: '定位', key: 'category',
render: (row: any) => {
const meta = CATEGORY_META[row.category || 'uncategorized'];
return h(NTag, { type: meta.color, size: 'small', round: true }, () => `${meta.emoji} ${meta.label}`);
},
filterOptions: Object.entries(CATEGORY_META).map(([k, v]) => ({ label: `${v.emoji} ${v.label}`, value: k })),
filter: (value: any, row: any) => (row.category || 'uncategorized') === value,
},
{ title: '成本', key: 'totalCost', render: (row: any) => fmtCurrency(row.totalCost),
sorter: (a: any, b: any) => a.totalCost - b.totalCost },
{ title: '产出', key: 'totalRevenue', render: (row: any) => fmtCurrency(row.totalRevenue),
sorter: (a: any, b: any) => a.totalRevenue - b.totalRevenue },
{
title: 'ROI', key: 'roiValue',
render: (row: any) => h('span', { style: { color: roiColor(row.roiValue), fontWeight: 600 } }, fmtPercent(row.roiValue)),
sorter: (a: any, b: any) => (a.roiValue ?? -Infinity) - (b.roiValue ?? -Infinity),
sortOrder: 'descend' as const, // ROI
defaultSortOrder: 'descend' as const,
},
{ title: '置信度', key: 'confidence', render: (row: any) => h(ConfidenceBadge as any, { confidence: row.confidence, showLabel: false }) },
{
title: '操作', key: 'actions', render: (row: any) => h('a', {
style: { color: 'var(--color-primary-hex)', cursor: 'pointer' },
onClick: () => router.push(`/projects/${row.projectId}/roi`),
}, '查看 →'),
},
];
</script>
<template>
<div class="dashboard-page">
<NSpin :show="loading">
<div class="dashboard-header">
<h2 style="margin:0">管理者决策罗盘</h2>
</div>
<!-- 3 ROI 大卡片 -->
<div class="kpi-grid">
<NCard size="small">
<div class="kpi-label">公司累计 ROI</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeData?.summary.roiValue) }">
{{ fmtPercent(lifetimeData?.summary.roiValue) }}
</div>
<div class="kpi-sub" v-if="lifetimeData">
成本 {{ fmtCurrency(lifetimeData.summary.totalCost) }} / 产出 {{ fmtCurrency(lifetimeData.summary.totalRevenue) }}
</div>
</NCard>
<NCard size="small">
<div class="kpi-label">本月 ROI</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(mtdData?.summary.roiValue) }">
{{ fmtPercent(mtdData?.summary.roiValue) }}
</div>
<div class="kpi-sub" v-if="mtdData">
成本 {{ fmtCurrency(mtdData.summary.totalCost) }} / 产出 {{ fmtCurrency(mtdData.summary.totalRevenue) }}
</div>
</NCard>
<NCard size="small">
<div class="kpi-label">本年累计 ROI (YTD)</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(ytdData?.summary.roiValue) }">
{{ fmtPercent(ytdData?.summary.roiValue) }}
</div>
<div class="kpi-sub" v-if="ytdData">
成本 {{ fmtCurrency(ytdData.summary.totalCost) }} / 产出 {{ fmtCurrency(ytdData.summary.totalRevenue) }}
</div>
</NCard>
</div>
<!-- 切换时间窗口 -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;margin-bottom:12px">
<span style="font-weight:600">业务线分布</span>
<NSelect v-model:value="windowKey" :options="[
{ label: '累计 (LTD)', value: 'lifetime' },
{ label: '本年 (YTD)', value: 'ytd' },
{ label: '本月 (MTD)', value: 'mtd' },
]" style="width:140px" size="small" />
</div>
<!-- 堆叠图 + 饼图 -->
<div class="charts-row">
<NCard size="small" title="各核心业务线 ROI 堆叠图">
<CategoryStackedBar v-if="activeData" :by-category="activeData.byCategory" />
<NEmpty v-else />
</NCard>
<NCard size="small" title="各业务线产出占比">
<RevenuePieChart v-if="activeData" :by-category="activeData.byCategory" />
<NEmpty v-else />
</NCard>
</div>
<!-- 项目明细表 -->
<NCard size="small" style="margin-top:16px">
<template #header>
<div class="table-header">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-weight:600">项目明细</span>
<span class="result-count">显示 {{ filteredProjects.length }} / {{ activeData?.projects.length || 0 }} </span>
</div>
<div class="category-chips">
<button
v-for="(meta, k) in CATEGORY_META"
:key="k"
class="chip"
:class="{ active: selectedCategories.includes(k), disabled: (categoryStats[k]?.count || 0) === 0 }"
:style="selectedCategories.includes(k) ? { background: chipColor(meta.color), color: '#fff', borderColor: chipColor(meta.color) } : {}"
:disabled="(categoryStats[k]?.count || 0) === 0"
@click="toggleCategory(k)"
>
{{ meta.emoji }} {{ meta.label }}
<span class="chip-count">{{ categoryStats[k]?.count || 0 }}</span>
</button>
<NButton v-if="selectedCategories.length > 0" size="tiny" text type="primary" @click="selectedCategories = []">清空</NButton>
<span class="filter-divider">|</span>
<span class="filter-label">仅看有成本数据</span>
<NSwitch v-model:value="hideZeroCost" size="small" />
</div>
</div>
</template>
<NDataTable
v-if="activeData"
:columns="projectColumns"
:data="filteredProjects"
:max-height="500"
:row-key="(row: any) => row.projectId"
striped
/>
<NEmpty v-else />
</NCard>
</NSpin>
</div>
</template>
<style scoped>
.dashboard-page { padding: var(--space-4); }
.dashboard-header { margin-bottom: 16px; }
.kpi-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.kpi-label { font-size: 13px; color: var(--color-text-muted); }
.kpi-value { font-size: 30px; font-weight: 700; margin-top: 6px; }
.kpi-sub { font-size: 12px; color: var(--color-text-muted); margin-top: 6px; }
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 16px;
flex-wrap: wrap;
}
.category-chips {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.chip {
border: 1px solid var(--n-border-color, #e5e7eb);
background: transparent;
border-radius: 999px;
padding: 3px 10px;
font-size: 12px;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--color-text, #333);
transition: all 0.15s;
}
.chip:hover:not(.disabled) {
border-color: var(--color-primary-hex, #3B5998);
}
.chip.active {
font-weight: 600;
}
.chip.disabled {
opacity: 0.35;
cursor: not-allowed;
}
.chip-count {
margin-left: 4px;
opacity: 0.7;
font-size: 11px;
font-weight: normal;
}
.result-count {
font-size: 12px;
color: var(--color-text-muted);
}
.filter-divider {
color: var(--color-text-muted);
margin: 0 4px;
}
.filter-label {
font-size: 12px;
color: var(--color-text-muted);
}
@media (max-width: 1024px) {
.kpi-grid { grid-template-columns: 1fr; }
.charts-row { grid-template-columns: 1fr; }
}
</style>

Binary file not shown.