feat(ai-analysis): 实现业务分析指标引擎
This commit is contained in:
93
apps/server/src/modules/ai/analysis/metric-engine.spec.ts
Normal file
93
apps/server/src/modules/ai/analysis/metric-engine.spec.ts
Normal 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' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
461
apps/server/src/modules/ai/analysis/metric-engine.ts
Normal file
461
apps/server/src/modules/ai/analysis/metric-engine.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user