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:
parent
ad66228edc
commit
5af612e3fd
122
backend/drizzle/0003_add_roi_engine.sql
Normal file
122
backend/drizzle/0003_add_roi_engine.sql
Normal 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());
|
||||||
12
backend/drizzle/0004_add_roi_driver_factors.sql
Normal file
12
backend/drizzle/0004_add_roi_driver_factors.sql
Normal 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`);
|
||||||
16
backend/drizzle/0005_add_biz_system.sql
Normal file
16
backend/drizzle/0005_add_biz_system.sql
Normal 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`);
|
||||||
@ -22,6 +22,27 @@
|
|||||||
"when": 1777430400000,
|
"when": 1777430400000,
|
||||||
"tag": "0002_add_ai_okr_fields",
|
"tag": "0002_add_ai_okr_fields",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
67
backend/scripts/ai-classify-all.ts
Normal file
67
backend/scripts/ai-classify-all.ts
Normal 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);
|
||||||
60
backend/scripts/ai-tag-all.ts
Normal file
60
backend/scripts/ai-tag-all.ts
Normal 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);
|
||||||
15
backend/scripts/backfill-cost-events.ts
Normal file
15
backend/scripts/backfill-cost-events.ts
Normal 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);
|
||||||
66
backend/scripts/backfill-launched-at.ts
Normal file
66
backend/scripts/backfill-launched-at.ts
Normal 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);
|
||||||
44
backend/scripts/backfill-revenue.ts
Normal file
44
backend/scripts/backfill-revenue.ts
Normal 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);
|
||||||
@ -29,6 +29,11 @@ const envSchema = z.object({
|
|||||||
AI_API_KEY: z.string().default(''),
|
AI_API_KEY: z.string().default(''),
|
||||||
AI_MODEL: z.string().default('doubao-seed-2-0-pro-260215'),
|
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'),
|
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() {
|
function loadConfig() {
|
||||||
|
|||||||
@ -35,10 +35,21 @@ export const projects = mysqlTable('projects', {
|
|||||||
name: varchar('name', { length: 200 }).notNull(),
|
name: varchar('name', { length: 200 }).notNull(),
|
||||||
identifier: varchar('identifier', { length: 200 }),
|
identifier: varchar('identifier', { length: 200 }),
|
||||||
lastSyncedAt: datetime('last_synced_at'),
|
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(),
|
createdAt: datetime('created_at').notNull(),
|
||||||
updatedAt: datetime('updated_at').notNull(),
|
updatedAt: datetime('updated_at').notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
planeProjectIdx: uniqueIndex('uniq_projects_plane_id').on(table.planeProjectId),
|
planeProjectIdx: uniqueIndex('uniq_projects_plane_id').on(table.planeProjectId),
|
||||||
|
categoryIdx: index('idx_projects_category').on(table.category),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Sprint Snapshots ──
|
// ── Sprint Snapshots ──
|
||||||
@ -236,7 +247,7 @@ export const userProjectPermissions = mysqlTable('user_project_permissions', {
|
|||||||
// ── Sync Logs ──
|
// ── Sync Logs ──
|
||||||
export const syncLogs = mysqlTable('sync_logs', {
|
export const syncLogs = mysqlTable('sync_logs', {
|
||||||
id: varchar('id', { length: 50 }).primaryKey(),
|
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(),
|
status: mysqlEnum('status', ['success', 'error']).notNull(),
|
||||||
message: text('message'),
|
message: text('message'),
|
||||||
recordsProcessed: int('records_processed').default(0),
|
recordsProcessed: int('records_processed').default(0),
|
||||||
@ -252,3 +263,103 @@ export const aiAnalyzedCommits = mysqlTable('ai_analyzed_commits', {
|
|||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
shaIdx: uniqueIndex('uniq_analyzed_sha').on(table.commitSha),
|
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),
|
||||||
|
}));
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import { memberRoutes } from './routes/members';
|
|||||||
import { okrRoutes } from './routes/okr';
|
import { okrRoutes } from './routes/okr';
|
||||||
import { gitRoutes } from './routes/git';
|
import { gitRoutes } from './routes/git';
|
||||||
import { adminRoutes } from './routes/admin';
|
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)
|
// Importing db triggers auto-migration on first load (B-07 fix)
|
||||||
import { db } from './db/index';
|
import { db } from './db/index';
|
||||||
import { seedAdminUser } from './db/seed-auto';
|
import { seedAdminUser } from './db/seed-auto';
|
||||||
@ -44,6 +46,12 @@ app.get('/api/health', (c) => {
|
|||||||
// Auth routes (public)
|
// Auth routes (public)
|
||||||
app.route('/api/auth', authRoutes);
|
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
|
// Protected routes
|
||||||
app.use('/api/*', authMiddleware);
|
app.use('/api/*', authMiddleware);
|
||||||
app.route('/api', overviewRoutes);
|
app.route('/api', overviewRoutes);
|
||||||
@ -52,6 +60,7 @@ app.route('/api', memberRoutes);
|
|||||||
app.route('/api', okrRoutes);
|
app.route('/api', okrRoutes);
|
||||||
app.route('/api', gitRoutes);
|
app.route('/api', gitRoutes);
|
||||||
app.route('/api', adminRoutes);
|
app.route('/api', adminRoutes);
|
||||||
|
app.route('/api', roiRoutes);
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
app.onError(errorHandler);
|
app.onError(errorHandler);
|
||||||
|
|||||||
55
backend/src/middleware/ai-rate-limit.ts
Normal file
55
backend/src/middleware/ai-rate-limit.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
35
backend/src/middleware/project-access.ts
Normal file
35
backend/src/middleware/project-access.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -125,6 +125,10 @@ adminRoutes.get('/admin/projects', async (c) => {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
identifier: p.identifier,
|
identifier: p.identifier,
|
||||||
|
planeIdentifier: p.planeIdentifier || null,
|
||||||
|
bizSystem: p.bizSystem || null,
|
||||||
|
projectType: p.projectType || null,
|
||||||
|
category: p.category || null,
|
||||||
planeProjectId: p.planeProjectId,
|
planeProjectId: p.planeProjectId,
|
||||||
createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt,
|
createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt,
|
||||||
})),
|
})),
|
||||||
|
|||||||
51
backend/src/routes/mock-revenue.ts
Normal file
51
backend/src/routes/mock-revenue.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -26,7 +26,11 @@ projectRoutes.get('/projects', async (c) => {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
identifier: p.identifier,
|
identifier: p.identifier,
|
||||||
|
planeIdentifier: p.planeIdentifier || null,
|
||||||
planeProjectId: p.planeProjectId,
|
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,
|
createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt,
|
||||||
lastSyncedAt: p.lastSyncedAt?.toISOString() || null,
|
lastSyncedAt: p.lastSyncedAt?.toISOString() || null,
|
||||||
})),
|
})),
|
||||||
@ -363,7 +367,15 @@ projectRoutes.get('/projects/:id', async (c) => {
|
|||||||
id: project.id,
|
id: project.id,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
identifier: project.identifier,
|
identifier: project.identifier,
|
||||||
|
planeIdentifier: project.planeIdentifier || null,
|
||||||
lastSyncedAt: project.lastSyncedAt?.toISOString() || 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,
|
currentCycle,
|
||||||
milestones: milestoneData,
|
milestones: milestoneData,
|
||||||
|
|||||||
472
backend/src/routes/roi.ts
Normal file
472
backend/src/routes/roi.ts
Normal 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' });
|
||||||
|
});
|
||||||
150
backend/src/services/roi/aggregator.ts
Normal file
150
backend/src/services/roi/aggregator.ts
Normal 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;
|
||||||
|
}
|
||||||
167
backend/src/services/roi/ai-driver-writer.ts
Normal file
167
backend/src/services/roi/ai-driver-writer.ts
Normal 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(() => {});
|
||||||
|
}
|
||||||
116
backend/src/services/roi/ai-tag-suggester.ts
Normal file
116
backend/src/services/roi/ai-tag-suggester.ts
Normal 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 系统、内容管理平台、视频生成工具、DevOps、IAM、监控等内部效能工具归这里
|
||||||
|
- aircore: 底层技术基座(护城河与技术中台)—— RTC 基座、AR/Live2D 引擎、AI 算法、操作系统适配层等技术中台归这里
|
||||||
|
|
||||||
|
【维度 3:技术属性 projectType】
|
||||||
|
- hardware: 项目核心产物含硬件(嵌入式固件、PCB、模组、终端设备等)
|
||||||
|
- software: 纯软件,包括 Web/App/后端/SDK/工具脚本
|
||||||
|
|
||||||
|
请严格按以下 JSON 结构回复,不添加任何其他字段:
|
||||||
|
{
|
||||||
|
"suggestedCategory": "cash_cow" | "efficiency_tool" | "moat" | "composite",
|
||||||
|
"suggestedBizSystem": "airhubs" | "airflow" | "aircore",
|
||||||
|
"suggestedProjectType": "hardware" | "software",
|
||||||
|
"confidence": 0.0~1.0 的数字,表示整体判断的置信度,
|
||||||
|
"reasoning": "简短中文说明(100 字内),先说 bizSystem 归属,再说 category 和 projectType 依据"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 给项目推荐商业定位标签。
|
||||||
|
* 上下文: 项目名 + identifier + 近 30 天 commit messages 摘要 + 关联 OKR title。
|
||||||
|
*/
|
||||||
|
export async function suggestProjectTag(projectId: string): Promise<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;
|
||||||
|
}
|
||||||
60
backend/src/services/roi/bep-calculator.ts
Normal file
60
backend/src/services/roi/bep-calculator.ts
Normal 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);
|
||||||
|
}
|
||||||
34
backend/src/services/roi/confidence-evaluator.ts
Normal file
34
backend/src/services/roi/confidence-evaluator.ts
Normal 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';
|
||||||
|
}
|
||||||
129
backend/src/services/roi/cost-ingest/from-commits.ts
Normal file
129
backend/src/services/roi/cost-ingest/from-commits.ts
Normal 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;
|
||||||
|
}
|
||||||
104
backend/src/services/roi/cost-ingest/from-plane-tasks.ts
Normal file
104
backend/src/services/roi/cost-ingest/from-plane-tasks.ts
Normal 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;
|
||||||
|
}
|
||||||
65
backend/src/services/roi/cost-ingest/index.ts
Normal file
65
backend/src/services/roi/cost-ingest/index.ts
Normal 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));
|
||||||
|
}
|
||||||
86
backend/src/services/roi/identifier-generator.ts
Normal file
86
backend/src/services/roi/identifier-generator.ts
Normal 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;
|
||||||
|
}
|
||||||
90
backend/src/services/roi/revenue-ingest/asset-amortizer.ts
Normal file
90
backend/src/services/roi/revenue-ingest/asset-amortizer.ts
Normal 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;
|
||||||
|
}
|
||||||
215
backend/src/services/roi/revenue-ingest/from-revenue-api.ts
Normal file
215
backend/src/services/roi/revenue-ingest/from-revenue-api.ts
Normal 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');
|
||||||
|
}
|
||||||
68
backend/src/services/roi/revenue-ingest/index.ts
Normal file
68
backend/src/services/roi/revenue-ingest/index.ts
Normal 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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
145
backend/src/services/roi/revenue-ingest/mock-generator.ts
Normal file
145
backend/src/services/roi/revenue-ingest/mock-generator.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
49
backend/src/services/roi/strategy-params.ts
Normal file
49
backend/src/services/roi/strategy-params.ts
Normal 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;
|
||||||
|
}
|
||||||
97
backend/src/services/roi/timeseries.ts
Normal file
97
backend/src/services/roi/timeseries.ts
Normal 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;
|
||||||
|
}
|
||||||
49
backend/src/services/roi/types.ts
Normal file
49
backend/src/services/roi/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -2,11 +2,17 @@ import { v4 as uuid } from 'uuid';
|
|||||||
import { Cron } from 'croner';
|
import { Cron } from 'croner';
|
||||||
import { syncGitea } from './sync-gitea';
|
import { syncGitea } from './sync-gitea';
|
||||||
import { analyzeCommitsForOKR } from '../services/okr-ai-sync';
|
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 { db } from '../db/index';
|
||||||
import { syncLogs } from '../db/schema';
|
import { syncLogs } from '../db/schema';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
|
|
||||||
let giteaJob: Cron | null = null;
|
let giteaJob: Cron | null = null;
|
||||||
|
let revenueJob: Cron | null = null;
|
||||||
|
let amortizerJob: Cron | null = null;
|
||||||
|
let driverFactorsJob: Cron | null = null;
|
||||||
|
|
||||||
async function runSyncAndAnalyze() {
|
async function runSyncAndAnalyze() {
|
||||||
await syncGitea();
|
await syncGitea();
|
||||||
@ -27,6 +33,12 @@ async function runSyncAndAnalyze() {
|
|||||||
syncedAt: new Date(),
|
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 {
|
export function startScheduler(): void {
|
||||||
@ -36,7 +48,30 @@ export function startScheduler(): void {
|
|||||||
await runSyncAndAnalyze();
|
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] 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({
|
db.insert(syncLogs).values({
|
||||||
@ -57,5 +92,8 @@ export function startScheduler(): void {
|
|||||||
|
|
||||||
export function stopScheduler(): void {
|
export function stopScheduler(): void {
|
||||||
giteaJob?.stop();
|
giteaJob?.stop();
|
||||||
|
revenueJob?.stop();
|
||||||
|
amortizerJob?.stop();
|
||||||
|
driverFactorsJob?.stop();
|
||||||
console.info('[SCHEDULER] 已停止同步任务');
|
console.info('[SCHEDULER] 已停止同步任务');
|
||||||
}
|
}
|
||||||
|
|||||||
86
backend/tests/unit/roi-engine.test.ts
Normal file
86
backend/tests/unit/roi-engine.test.ts
Normal 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
139
frontend/src/api/roi.ts
Normal 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 } });
|
||||||
@ -18,18 +18,62 @@ const dashStore = useDashboardStore();
|
|||||||
|
|
||||||
// B-17: Track whether the Projects sub-menu is expanded
|
// B-17: Track whether the Projects sub-menu is expanded
|
||||||
const projectsExpanded = ref(false);
|
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() {
|
async function loadProjectList() {
|
||||||
try {
|
try {
|
||||||
const res = await getAdminProjectsApi();
|
const res = await getAdminProjectsApi();
|
||||||
const list = res.data.data || [];
|
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 {
|
} catch {
|
||||||
// Silently fail
|
// 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);
|
onMounted(loadProjectList);
|
||||||
|
|
||||||
// 路由变化时刷新项目列表(如从项目列表页返回时)
|
// 路由变化时刷新项目列表(如从项目列表页返回时)
|
||||||
@ -58,6 +102,11 @@ const menuOptions = computed(() => {
|
|||||||
items.push({ label: 'Git 活动', key: '/git', icon: 'git-branch' });
|
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)
|
// B-17: Members nav item (admin/manager only)
|
||||||
if (role === 'admin' || role === 'manager') {
|
if (role === 'admin' || role === 'manager') {
|
||||||
items.push({ label: '成员', key: '/members', icon: 'users' });
|
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('/members/')) return '/members';
|
||||||
if (route.path.startsWith('/okr')) return '/okr';
|
if (route.path.startsWith('/okr')) return '/okr';
|
||||||
if (route.path.startsWith('/git')) return '/git';
|
if (route.path.startsWith('/git')) return '/git';
|
||||||
|
if (route.path.startsWith('/roi')) return '/roi';
|
||||||
if (route.path.startsWith('/admin')) return '/admin';
|
if (route.path.startsWith('/admin')) return '/admin';
|
||||||
return '/';
|
return '/';
|
||||||
});
|
});
|
||||||
@ -165,20 +215,35 @@ const roleTagType = computed(() => {
|
|||||||
</NTooltip>
|
</NTooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- B-17: Projects sub-menu -->
|
<!-- B-17: Projects sub-menu, grouped by bizSystem -->
|
||||||
<div
|
<div
|
||||||
v-if="item.hasSubmenu && projectsExpanded && (!dashStore.sidebarCollapsed || dashStore.isMobile)"
|
v-if="item.hasSubmenu && projectsExpanded && (!dashStore.sidebarCollapsed || dashStore.isMobile)"
|
||||||
class="submenu"
|
class="submenu"
|
||||||
>
|
>
|
||||||
<div
|
<template v-for="group in projectGroups" :key="group.key">
|
||||||
v-for="proj in projectList"
|
<div
|
||||||
:key="proj.projectId"
|
class="submenu-group-title"
|
||||||
class="submenu-item"
|
:class="{ 'group-collapsed': !isGroupOpen(group.key) }"
|
||||||
:class="{ active: route.path === `/projects/${proj.projectId}` }"
|
:style="{ borderLeftColor: group.color }"
|
||||||
@click="handleProjectSelect(proj.projectId)"
|
@click="toggleGroup(group.key)"
|
||||||
>
|
>
|
||||||
<span class="submenu-label">{{ proj.name }}</span>
|
<span class="group-dot" :style="{ background: group.color }"></span>
|
||||||
</div>
|
<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 v-if="!projectList.length" class="submenu-item submenu-empty">
|
||||||
暂无项目
|
暂无项目
|
||||||
</div>
|
</div>
|
||||||
@ -368,6 +433,51 @@ const roleTagType = computed(() => {
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.nav-icon-only {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
48
frontend/src/components/roi/CategoryStackedBar.vue
Normal file
48
frontend/src/components/roi/CategoryStackedBar.vue
Normal 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>
|
||||||
29
frontend/src/components/roi/ConfidenceBadge.vue
Normal file
29
frontend/src/components/roi/ConfidenceBadge.vue
Normal 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>
|
||||||
117
frontend/src/components/roi/EventEntryModal.vue
Normal file
117
frontend/src/components/roi/EventEntryModal.vue
Normal 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>
|
||||||
254
frontend/src/components/roi/ProjectTagSelector.vue
Normal file
254
frontend/src/components/roi/ProjectTagSelector.vue
Normal 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>
|
||||||
45
frontend/src/components/roi/RevenuePieChart.vue
Normal file
45
frontend/src/components/roi/RevenuePieChart.vue
Normal 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>
|
||||||
56
frontend/src/components/roi/RoiTimeSeriesChart.vue
Normal file
56
frontend/src/components/roi/RoiTimeSeriesChart.vue
Normal 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>
|
||||||
119
frontend/src/components/roi/admin/MappingPanel.vue
Normal file
119
frontend/src/components/roi/admin/MappingPanel.vue
Normal 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>
|
||||||
73
frontend/src/components/roi/admin/StrategiesPanel.vue
Normal file
73
frontend/src/components/roi/admin/StrategiesPanel.vue
Normal 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>
|
||||||
@ -1,13 +1,14 @@
|
|||||||
import { ref, nextTick, onMounted, onUnmounted, watch, type Ref, shallowRef } from 'vue';
|
import { ref, nextTick, onMounted, onUnmounted, watch, type Ref, shallowRef } from 'vue';
|
||||||
import * as echarts from 'echarts/core';
|
import * as echarts from 'echarts/core';
|
||||||
import { BarChart, LineChart, PieChart, RadarChart, HeatmapChart, CustomChart } from 'echarts/charts';
|
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';
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
|
||||||
echarts.use([
|
echarts.use([
|
||||||
BarChart, LineChart, PieChart, RadarChart, HeatmapChart, CustomChart,
|
BarChart, LineChart, PieChart, RadarChart, HeatmapChart, CustomChart,
|
||||||
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
|
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
|
||||||
DataZoomComponent, ToolboxComponent, VisualMapComponent, CalendarComponent,
|
DataZoomComponent, ToolboxComponent, VisualMapComponent, CalendarComponent,
|
||||||
|
MarkLineComponent, GraphicComponent,
|
||||||
CanvasRenderer,
|
CanvasRenderer,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,17 @@ const router = createRouter({
|
|||||||
name: 'ProjectDetail',
|
name: 'ProjectDetail',
|
||||||
component: () => import('@/views/ProjectDetail.vue'),
|
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
|
// B-17 fix: added member list route
|
||||||
{
|
{
|
||||||
path: 'members',
|
path: 'members',
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, h } from 'vue';
|
import { ref, computed, onMounted, h } from 'vue';
|
||||||
import { NTabs, NTabPane, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NSelect, NSpin, NTag, useMessage } from 'naive-ui';
|
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 { getAdminUsersApi, createUserApi, deleteUserApi, getAuthorMappingsApi, createMappingApi, deleteMappingApi, getSyncLogsApi, triggerSyncApi, getAdminProjectsApi, setUserProjectsApi } from '@/api/admin';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
@ -314,6 +316,14 @@ const roleOptions = [
|
|||||||
<NDataTable :columns="logColumns" :data="logsData" :loading="logsLoading" :bordered="false" size="small" />
|
<NDataTable :columns="logColumns" :data="logsData" :loading="logsLoading" :bordered="false" size="small" />
|
||||||
</div>
|
</div>
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
|
|
||||||
|
<NTabPane name="roi-strategies" tab="ROI 策略">
|
||||||
|
<StrategiesPanel />
|
||||||
|
</NTabPane>
|
||||||
|
|
||||||
|
<NTabPane name="roi-mapping" tab="ROI 项目映射">
|
||||||
|
<MappingPanel />
|
||||||
|
</NTabPane>
|
||||||
</NTabs>
|
</NTabs>
|
||||||
|
|
||||||
<!-- 创建用户弹窗 -->
|
<!-- 创建用户弹窗 -->
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { getProjectDetailApi } from '@/api/projects';
|
|||||||
import { createObjectiveApi, createKeyResultApi, updateKeyResultApi, deleteObjectiveApi, deleteKeyResultApi, postponeKRApi, pauseKRApi, resumeKRApi, cancelKRApi, getKRLogsApi } from '@/api/okr';
|
import { createObjectiveApi, createKeyResultApi, updateKeyResultApi, deleteObjectiveApi, deleteKeyResultApi, postponeKRApi, pauseKRApi, resumeKRApi, cancelKRApi, getKRLogsApi } from '@/api/okr';
|
||||||
import request from '@/api/request';
|
import request from '@/api/request';
|
||||||
import DataCard from '@/components/shared/DataCard.vue';
|
import DataCard from '@/components/shared/DataCard.vue';
|
||||||
|
import ProjectTagSelector from '@/components/roi/ProjectTagSelector.vue';
|
||||||
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -34,6 +35,10 @@ onMounted(loadData);
|
|||||||
// 侧边栏切换项目时路由参数变化,重新加载数据
|
// 侧边栏切换项目时路由参数变化,重新加载数据
|
||||||
watch(() => route.params.id, () => loadData());
|
watch(() => route.params.id, () => loadData());
|
||||||
|
|
||||||
|
function goToRoiBoard() {
|
||||||
|
router.push(`/projects/${projectId.value}/roi`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 用户列表(给下拉框用)──
|
// ── 用户列表(给下拉框用)──
|
||||||
const allUsers = ref<any[]>([]);
|
const allUsers = ref<any[]>([]);
|
||||||
const userOptions = computed(() => allUsers.value.map(u => ({ value: u.id, label: u.displayName })));
|
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 class="overall-progress">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
<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>
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
<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>
|
<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>
|
||||||
<div v-if="data.okr?.objectives?.length" style="margin-top:8px">
|
<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%" />
|
<NProgress type="line" :percentage="clamp(data.okr.overallProgress)" :show-indicator="false" style="width:100%" />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, h } from 'vue';
|
import { ref, computed, onMounted, h } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { NSpin, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NTag, NEmpty, useMessage } from 'naive-ui';
|
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';
|
import { getAdminProjectsApi, createProjectApi, updateProjectApi, deleteProjectApi, bindRepoApi, getProjectReposApi, unbindRepoApi } from '@/api/admin';
|
||||||
@ -11,6 +11,53 @@ const message = useMessage();
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const projects = ref<any[]>([]);
|
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 = (() => {
|
const userRole = (() => {
|
||||||
try { return JSON.parse(localStorage.getItem('user') || '{}').role || 'viewer'; }
|
try { return JSON.parse(localStorage.getItem('user') || '{}').role || 'viewer'; }
|
||||||
catch { return 'viewer'; }
|
catch { return 'viewer'; }
|
||||||
@ -164,9 +211,35 @@ function extractRepoName(raw: string) {
|
|||||||
return cleaned;
|
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 = [
|
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: '项目名称', key: 'name', ellipsis: { tooltip: true } },
|
||||||
{
|
{
|
||||||
title: '绑定仓库',
|
title: '绑定仓库',
|
||||||
@ -210,8 +283,39 @@ const columns = [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NSpin :show="loading">
|
<NSpin :show="loading">
|
||||||
<DataCard v-if="projects.length" title="全部项目" :subtitle="`${projects.length} 个项目`">
|
<DataCard v-if="projects.length" title="全部项目" :subtitle="`显示 ${filteredProjects.length} / ${projects.length} 个项目`">
|
||||||
<NDataTable :columns="columns" :data="projects" :bordered="false" size="small" />
|
<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>
|
</DataCard>
|
||||||
<EmptyState v-else-if="!loading" title="暂无项目" description="点击「创建项目」开始添加。" />
|
<EmptyState v-else-if="!loading" title="暂无项目" description="点击「创建项目」开始添加。" />
|
||||||
</NSpin>
|
</NSpin>
|
||||||
@ -281,4 +385,25 @@ const columns = [
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.project-list-page { max-width: 960px; }
|
.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>
|
</style>
|
||||||
|
|||||||
297
frontend/src/views/ProjectRoiBoard.vue
Normal file
297
frontend/src/views/ProjectRoiBoard.vue
Normal 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>
|
||||||
330
frontend/src/views/RoiDashboard.vue
Normal file
330
frontend/src/views/RoiDashboard.vue
Normal 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>
|
||||||
BIN
产研部4月份第二周周报.xlsx
Normal file
BIN
产研部4月份第二周周报.xlsx
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user