feat(ai-analysis): 实现业务分析指标引擎

This commit is contained in:
2026-07-08 21:31:09 +08:00
parent 889a34cfa1
commit 7cd18dabac
2 changed files with 554 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
import { MetricEngine } from './metric-engine';
describe('MetricEngine', () => {
function makeEngine() {
const prisma = {
xiaobaoRiskSummary: { findMany: jest.fn() },
version: { findMany: jest.fn() },
versionPlan: { findMany: jest.fn() },
devTask: { findMany: jest.fn() },
testCase: { findMany: jest.fn() },
bug: { findMany: jest.fn() },
requirement: { findMany: jest.fn() },
taskWorklog: { findMany: jest.fn() },
overtimeRecord: { findMany: jest.fn() },
user: { findMany: jest.fn() },
};
return { prisma, engine: new MetricEngine(prisma as any) };
}
it('returns version risk ranking from Xiaobao summaries', async () => {
const { prisma, engine } = makeEngine();
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
{ versionId: 'ver-1', riskLevel: 'blocked', riskScore: 92, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
{ versionId: 'ver-2', riskLevel: 'at_risk', riskScore: 71, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
]);
prisma.version.findMany.mockResolvedValue([
{ id: 'ver-1', name: 'V1', projectId: 'project-1', productId: 'product-1' },
{ id: 'ver-2', name: 'V2', projectId: 'project-1', productId: 'product-1' },
]);
const result = await engine.executeMetric({
metricRef: { metricId: 'version_risk_score', version: 1 },
analysisType: 'ranking',
dimensions: ['version'],
filters: {},
scope: { type: 'project', projectId: 'project-1' },
limit: 10,
}, new Date('2026-07-08T12:00:00.000Z'));
expect(result.rows).toEqual([
{ versionId: 'ver-1', label: 'V1', value: 92, riskLevel: 'blocked' },
{ versionId: 'ver-2', label: 'V2', value: 71, riskLevel: 'at_risk' },
]);
expect(result.evidence[0]).toMatchObject({ label: '风险版本', value: 2, sourceDomain: 'xiaobao' });
});
it('groups member pending work across plans, tasks, cases, and bugs', async () => {
const { prisma, engine } = makeEngine();
prisma.versionPlan.findMany.mockResolvedValue([{ id: 'p1', ownerId: 'm-1', title: '产品方案', versionId: 'ver-1' }]);
prisma.devTask.findMany.mockResolvedValue([{ id: 'd1', assigneeId: 'm-1', title: '接口', versionId: 'ver-1' }]);
prisma.testCase.findMany.mockResolvedValue([{ id: 't1', assigneeId: 'm-2', title: '测试', versionId: 'ver-1' }]);
prisma.bug.findMany.mockResolvedValue([{ id: 'b1', assigneeId: 'm-1', title: '缺陷', versionId: 'ver-1' }]);
prisma.user.findMany.mockResolvedValue([
{ id: 'm-1', name: '张三', departmentId: '研发' },
{ id: 'm-2', name: '李四', departmentId: '测试' },
]);
const result = await engine.executeMetric({
metricRef: { metricId: 'member_pending_work', version: 1 },
analysisType: 'ranking',
dimensions: ['member'],
filters: {},
scope: { type: 'version', versionId: 'ver-1' },
limit: 10,
});
expect(result.rows).toEqual([
{ memberId: 'm-1', label: '张三', value: 3 },
{ memberId: 'm-2', label: '李四', value: 1 },
]);
});
it('returns no-data evidence for empty requirement trends', async () => {
const { prisma, engine } = makeEngine();
prisma.requirement.findMany.mockResolvedValue([]);
const result = await engine.executeMetric({
metricRef: { metricId: 'requirement_completion_count', version: 1 },
analysisType: 'trend',
dimensions: ['day'],
filters: {},
scope: { type: 'self', userId: 'm-1' },
timeRange: {
start: '2026-06-09T00:00:00.000Z',
end: '2026-07-08T23:59:59.999Z',
policy: 'last_30_days',
},
});
expect(result.rows).toEqual([]);
expect(result.evidence).toEqual([{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }]);
});
});

View File

@@ -0,0 +1,461 @@
import { Injectable } from '@nestjs/common';
import type { AnalysisPlan, DataScope, EvidenceItem, MetricResult } from '@ftb/shared';
import { PrismaService } from '../../../prisma/prisma.service';
import { buildScopedWhere } from './permission-scope-resolver';
type AnalysisDomain =
| 'version'
| 'versionPlan'
| 'devTask'
| 'testCase'
| 'bug'
| 'requirement'
| 'taskWorklog'
| 'overtimeRecord';
type OpenWorkItem = {
id: string;
versionId?: string | null;
title: string;
ownerId?: string | null;
assigneeId?: string | null;
};
type VersionRow = { id: string; name: string; projectId?: string | null; productId?: string | null };
type RiskSummaryRow = { versionId: string; riskLevel: string; riskScore: number; updatedAt: Date };
type UserRow = { id: string; name: string; departmentId?: string | null };
type UserDepartmentRow = { id: string; departmentId?: string | null };
type StatusRow = { status: string };
type RequirementSourceRow = { sourceType: string | null; type: string | null };
type DateRow = { updatedAt: Date };
type WorkHoursRow = { userId: string | null; hours: number };
type BugSeverityRow = { severity: string };
type TestStatusRow = { status: string; updatedAt: Date };
type OvertimeReasonRow = { reason: string; hours: number };
@Injectable()
export class MetricEngine {
constructor(private readonly prisma: PrismaService) {}
async executeMetric(plan: AnalysisPlan, now = new Date()): Promise<MetricResult> {
if (plan.metricRef.metricId === 'version_risk_score') return this.versionRiskRanking(plan, now);
if (plan.metricRef.metricId === 'member_pending_work') return this.memberPendingWork(plan, now);
if (plan.metricRef.metricId === 'department_workload') return this.departmentWorkload(plan, now);
if (plan.metricRef.metricId === 'overdue_item_count') return this.overdueItemCount(plan, now);
if (plan.metricRef.metricId === 'requirement_status_count') return this.requirementStatusCount(plan, now);
if (plan.metricRef.metricId === 'requirement_completion_count') return this.requirementCompletionTrend(plan, now);
if (plan.metricRef.metricId === 'requirement_source_count') return this.requirementSourceCount(plan, now);
if (plan.metricRef.metricId === 'member_effort_hours') return this.memberEffortHours(plan, now);
if (plan.metricRef.metricId === 'bug_severity_count') return this.bugSeverityCount(plan, now);
if (plan.metricRef.metricId === 'test_pass_rate') return this.testPassRate(plan, now);
if (plan.metricRef.metricId === 'overtime_reason_hours') return this.overtimeReasonHours(plan, now);
if (plan.metricRef.metricId === 'completion_trend') return this.completionTrend(plan, now);
if (plan.metricRef.metricId === 'delay_rate') return this.delayRate(plan, now);
if (plan.metricRef.metricId === 'delay_reason_count') return this.delayReasonCount(plan, now);
return emptyResult(plan, now, [{ label: '可统计记录', value: 0, sourceDomain: 'project' }]);
}
private async versionRiskRanking(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const versions = await this.prisma.version.findMany({
where: domainWhere(plan.scope, 'version'),
select: { id: true, name: true, projectId: true, productId: true },
}) as VersionRow[];
const versionById = new Map(versions.map((version) => [version.id, version]));
const versionIds = versions.map((version) => version.id);
const rows: RiskSummaryRow[] = versionIds.length === 0
? []
: await this.prisma.xiaobaoRiskSummary.findMany({
where: { versionId: { in: versionIds } },
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
take: plan.limit ?? 10,
}) as RiskSummaryRow[];
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [
{ id: 'label', label: '版本', type: 'string' },
{ id: 'value', label: '风险分', type: 'number' },
{ id: 'riskLevel', label: '风险等级', type: 'string' },
],
rows: rows.map((row) => ({
versionId: row.versionId,
label: versionById.get(row.versionId)?.name ?? row.versionId,
value: row.riskScore,
riskLevel: row.riskLevel,
})),
evidence: [{ label: '风险版本', value: rows.length, sourceDomain: 'xiaobao' }],
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}
private async memberPendingWork(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const [plans, devTasks, testCases, bugs] = await Promise.all([
this.prisma.versionPlan.findMany({
where: { ...domainWhere(plan.scope, 'versionPlan'), status: { not: 'completed' } },
}),
this.prisma.devTask.findMany({
where: { ...domainWhere(plan.scope, 'devTask'), status: { not: 'submitted' } },
}),
this.prisma.testCase.findMany({
where: { ...domainWhere(plan.scope, 'testCase'), status: { notIn: ['passed', 'failed', 'blocked'] } },
}),
this.prisma.bug.findMany({
where: { ...domainWhere(plan.scope, 'bug'), status: { in: ['open', 'fixing', 'fixed', 'verifying'] } },
}),
]) as [OpenWorkItem[], OpenWorkItem[], OpenWorkItem[], OpenWorkItem[]];
const openItems: OpenWorkItem[] = [
...plans.map((planItem) => ({ ...planItem, assigneeId: planItem.ownerId })),
...devTasks,
...testCases,
...bugs,
];
const counts = countByMember(openItems);
const users = await this.prisma.user.findMany({
where: { id: { in: Array.from(counts.keys()) } },
select: { id: true, name: true, departmentId: true },
}) as UserRow[];
const userById = new Map(users.map((user) => [user.id, user]));
const rows = Array.from(counts.entries())
.map(([memberId, value]) => ({ memberId, label: userById.get(memberId)?.name ?? memberId, value }))
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label))
.slice(0, plan.limit ?? 10);
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [
{ id: 'label', label: '成员', type: 'string' },
{ id: 'value', label: '待办数', type: 'number' },
],
rows,
evidence: [{ label: '未完成事项', value: sumCounts(counts), sourceDomain: 'dev_task' }],
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}
private async departmentWorkload(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const memberResult = await this.memberPendingWork({
...plan,
metricRef: { metricId: 'member_pending_work', version: 1 },
dimensions: ['member'],
}, now);
const memberIds = memberResult.rows.map((row) => String(row.memberId ?? '')).filter(Boolean);
const users = await this.prisma.user.findMany({
where: { id: { in: memberIds } },
select: { id: true, departmentId: true },
}) as UserDepartmentRow[];
const departmentByMember = new Map(users.map((user) => [user.id, user.departmentId || '未分部门']));
const counts = new Map<string, number>();
for (const row of memberResult.rows) {
const department = departmentByMember.get(String(row.memberId)) ?? '未分部门';
counts.set(department, (counts.get(department) ?? 0) + Number(row.value ?? 0));
}
return rowsResult(
plan,
now,
'部门',
'待办数',
Array.from(counts.entries()).map(([label, value]) => ({ label, value })),
'dev_task',
);
}
private async overdueItemCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const [plans, devTasks, testCases, bugs] = await Promise.all([
this.prisma.versionPlan.findMany({
where: { ...domainWhere(plan.scope, 'versionPlan'), status: { not: 'completed' }, expectedEndAt: { lt: now } },
}),
this.prisma.devTask.findMany({
where: { ...domainWhere(plan.scope, 'devTask'), status: { not: 'submitted' }, expectedEndAt: { lt: now } },
}),
this.prisma.testCase.findMany({
where: {
...domainWhere(plan.scope, 'testCase'),
status: { notIn: ['passed', 'failed', 'blocked'] },
plannedEndAt: { lt: now },
},
}),
this.prisma.bug.findMany({
where: { ...domainWhere(plan.scope, 'bug'), status: { in: ['open', 'fixing', 'fixed', 'verifying'] }, plannedFixAt: { lt: now } },
}),
]) as [OpenWorkItem[], OpenWorkItem[], OpenWorkItem[], OpenWorkItem[]];
const byVersion = new Map<string, number>();
for (const item of [...plans, ...devTasks, ...testCases, ...bugs]) {
if (!item.versionId) continue;
byVersion.set(item.versionId, (byVersion.get(item.versionId) ?? 0) + 1);
}
return rowsResult(
plan,
now,
'版本',
'逾期数',
Array.from(byVersion.entries()).map(([label, value]) => ({ label, value })),
'version',
);
}
private async requirementStatusCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const rows = await this.prisma.requirement.findMany({
where: domainWhere(plan.scope, 'requirement'),
select: { status: true },
}) as StatusRow[];
return countRows(plan, now, rows.map((row) => row.status || 'unknown'), '状态', '数量', 'requirement');
}
private async requirementCompletionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where = {
...domainWhere(plan.scope, 'requirement'),
status: { in: ['released', 'closed'] },
...timeFilter(plan, 'updatedAt'),
};
const rows = await this.prisma.requirement.findMany({ where, select: { updatedAt: true } }) as DateRow[];
return trendRows(plan, now, rows.map((row) => row.updatedAt), '完成需求', 'requirement');
}
private async requirementSourceCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const where = { ...domainWhere(plan.scope, 'requirement'), ...timeFilter(plan, 'createdAt') };
const rows = await this.prisma.requirement.findMany({ where, select: { sourceType: true, type: true } }) as RequirementSourceRow[];
const dimension = plan.dimensions.includes('requirement_type') ? 'type' : 'sourceType';
return countRows(plan, now, rows.map((row) => row[dimension] || '未填写'), '类别', '数量', 'requirement');
}
private async memberEffortHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const [worklogs, overtime] = await Promise.all([
this.prisma.taskWorklog.findMany({
where: { ...domainWhere(plan.scope, 'taskWorklog'), ...timeFilter(plan, 'createdAt') },
select: { userId: true, hours: true },
}),
this.prisma.overtimeRecord.findMany({
where: { ...domainWhere(plan.scope, 'overtimeRecord'), ...timeFilter(plan, 'createdAt') },
select: { userId: true, hours: true },
}),
]) as [WorkHoursRow[], WorkHoursRow[]];
const hours = new Map<string, number>();
for (const row of [...worklogs, ...overtime]) {
if (!row.userId) continue;
hours.set(row.userId, (hours.get(row.userId) ?? 0) + Number(row.hours ?? 0));
}
const users = await this.prisma.user.findMany({
where: { id: { in: Array.from(hours.keys()) } },
select: { id: true, name: true },
}) as Array<Pick<UserRow, 'id' | 'name'>>;
const userById = new Map(users.map((user) => [user.id, user.name]));
return rowsResult(
plan,
now,
'成员',
'小时',
Array.from(hours.entries()).map(([memberId, value]) => ({
memberId,
label: userById.get(memberId) ?? memberId,
value,
})),
'task_worklog',
);
}
private async bugSeverityCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const rows = await this.prisma.bug.findMany({
where: { ...domainWhere(plan.scope, 'bug'), status: { notIn: ['closed', 'rejected'] } },
select: { severity: true },
}) as BugSeverityRow[];
return countRows(plan, now, rows.map((row) => row.severity || 'normal'), '严重度', 'Bug 数', 'bug');
}
private async testPassRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const rows = await this.prisma.testCase.findMany({
where: {
...domainWhere(plan.scope, 'testCase'),
status: { in: ['passed', 'failed', 'blocked'] },
...timeFilter(plan, 'updatedAt'),
},
select: { status: true, updatedAt: true },
}) as TestStatusRow[];
const buckets = bucketDates(rows.map((row) => row.updatedAt));
const source = Array.from(buckets.keys()).map((label) => {
const sameDay = rows.filter((row) => dayKey(row.updatedAt) === label);
const passed = sameDay.filter((row) => row.status === 'passed').length;
return { label, value: sameDay.length === 0 ? 0 : Math.round((passed / sameDay.length) * 100) };
});
return rowsResult(plan, now, '日期', '通过率', source, 'test_case');
}
private async overtimeReasonHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const rows = await this.prisma.overtimeRecord.findMany({
where: { ...domainWhere(plan.scope, 'overtimeRecord'), ...timeFilter(plan, 'createdAt') },
select: { reason: true, hours: true },
}) as OvertimeReasonRow[];
const hours = new Map<string, number>();
for (const row of rows) {
const reason = row.reason || '未填写';
hours.set(reason, (hours.get(reason) ?? 0) + Number(row.hours ?? 0));
}
return rowsResult(
plan,
now,
'原因',
'小时',
Array.from(hours.entries()).map(([label, value]) => ({ label, value })),
'overtime',
);
}
private async completionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
return this.requirementCompletionTrend({
...plan,
metricRef: { metricId: 'requirement_completion_count', version: 1 },
dimensions: ['day'],
}, now);
}
private async delayRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
const overdue = await this.overdueItemCount({
...plan,
metricRef: { metricId: 'overdue_item_count', version: 1 },
dimensions: ['version'],
analysisType: 'ranking',
}, now);
const total = overdue.rows.reduce((sum, row) => sum + Number(row.value ?? 0), 0);
return {
...overdue,
metricRef: plan.metricRef,
rows: overdue.rows.map((row) => ({ ...row, value: total === 0 ? 0 : Number(row.value ?? 0) / total })),
};
}
private async delayReasonCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
return this.overtimeReasonHours({
...plan,
metricRef: { metricId: 'overtime_reason_hours', version: 1 },
dimensions: ['delay_reason'],
}, now);
}
}
function domainWhere(scope: DataScope, domain: AnalysisDomain): Record<string, unknown> {
const scopedWhere = buildScopedWhere(scope);
const base = domain === 'version' && 'versionId' in scopedWhere
? { id: scopedWhere.versionId }
: scopedWhere;
if (scope.type !== 'self') return base;
if (domain === 'version') return { id: { in: [] } };
if (domain === 'versionPlan') return { ...base, ownerId: scope.userId };
if (domain === 'devTask' || domain === 'testCase' || domain === 'bug') return { ...base, assigneeId: scope.userId };
if (domain === 'requirement') return { ...base, creatorId: scope.userId };
if (domain === 'taskWorklog' || domain === 'overtimeRecord') return { ...base, userId: scope.userId };
return base;
}
function timeFilter(plan: AnalysisPlan, field: string): Record<string, unknown> {
if (!plan.timeRange) return {};
return { [field]: { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) } };
}
function countByMember(items: OpenWorkItem[]): Map<string, number> {
const counts = new Map<string, number>();
for (const item of items) {
const memberId = item.assigneeId ?? item.ownerId;
if (!memberId) continue;
counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
}
return counts;
}
function sumCounts(counts: Map<string, number>): number {
return Array.from(counts.values()).reduce((sum, count) => sum + count, 0);
}
function rowsResult(
plan: AnalysisPlan,
now: Date,
labelName: string,
valueName: string,
rows: Array<Record<string, string | number | null>>,
sourceDomain: EvidenceItem['sourceDomain'],
): MetricResult {
const sorted = rows
.sort((a, b) => Number(b.value ?? 0) - Number(a.value ?? 0))
.slice(0, plan.limit ?? rows.length);
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [
{ id: 'label', label: labelName, type: 'string' },
{ id: 'value', label: valueName, type: 'number' },
],
rows: sorted,
evidence: [{ label: '可统计记录', value: rows.length, sourceDomain }],
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}
function countRows(
plan: AnalysisPlan,
now: Date,
labels: string[],
labelName: string,
valueName: string,
sourceDomain: EvidenceItem['sourceDomain'],
): MetricResult {
const counts = new Map<string, number>();
for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
return rowsResult(
plan,
now,
labelName,
valueName,
Array.from(counts.entries()).map(([label, value]) => ({ label, value })),
sourceDomain,
);
}
function trendRows(
plan: AnalysisPlan,
now: Date,
dates: Date[],
valueLabel: string,
sourceDomain: EvidenceItem['sourceDomain'],
): MetricResult {
const buckets = bucketDates(dates);
return rowsResult(
plan,
now,
'日期',
valueLabel,
Array.from(buckets.entries()).map(([label, value]) => ({ label, value })),
sourceDomain,
);
}
function bucketDates(dates: Date[]): Map<string, number> {
const buckets = new Map<string, number>();
for (const date of dates) {
const key = dayKey(date);
buckets.set(key, (buckets.get(key) ?? 0) + 1);
}
return buckets;
}
function dayKey(value: Date | string): string {
return new Date(value).toISOString().slice(0, 10);
}
function emptyResult(plan: AnalysisPlan, now: Date, evidence: EvidenceItem[]): MetricResult {
return {
metricRef: plan.metricRef,
analysisType: plan.analysisType,
columns: [],
rows: [],
evidence,
dataScope: plan.scope,
generatedAt: now.toISOString(),
};
}