diff --git a/apps/server/src/modules/ai/analysis/metric-engine.spec.ts b/apps/server/src/modules/ai/analysis/metric-engine.spec.ts new file mode 100644 index 0000000..3a52fe2 --- /dev/null +++ b/apps/server/src/modules/ai/analysis/metric-engine.spec.ts @@ -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' }]); + }); +}); diff --git a/apps/server/src/modules/ai/analysis/metric-engine.ts b/apps/server/src/modules/ai/analysis/metric-engine.ts new file mode 100644 index 0000000..54d98f6 --- /dev/null +++ b/apps/server/src/modules/ai/analysis/metric-engine.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + 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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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(); + 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>; + 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 { + 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 { + 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 { + 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(); + 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 { + return this.requirementCompletionTrend({ + ...plan, + metricRef: { metricId: 'requirement_completion_count', version: 1 }, + dimensions: ['day'], + }, now); + } + + private async delayRate(plan: AnalysisPlan, now: Date): Promise { + 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 { + return this.overtimeReasonHours({ + ...plan, + metricRef: { metricId: 'overtime_reason_hours', version: 1 }, + dimensions: ['delay_reason'], + }, now); + } +} + +function domainWhere(scope: DataScope, domain: AnalysisDomain): Record { + 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 { + if (!plan.timeRange) return {}; + return { [field]: { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) } }; +} + +function countByMember(items: OpenWorkItem[]): Map { + const counts = new Map(); + 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): number { + return Array.from(counts.values()).reduce((sum, count) => sum + count, 0); +} + +function rowsResult( + plan: AnalysisPlan, + now: Date, + labelName: string, + valueName: string, + rows: Array>, + 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(); + 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 { + const buckets = new Map(); + 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(), + }; +}