Добавлена функция расчёта метрик

This commit is contained in:
Pavel Gnedov 2025-02-06 22:41:19 +07:00
parent d5d604eb40
commit f3a8cded97
4 changed files with 446 additions and 14 deletions

View file

@ -51,6 +51,8 @@ export module RedmineTypes {
done_ratio: number;
spent_hours: number;
total_spent_hours: number;
estimated_hours: number;
total_estimated_hours: number;
custom_fields: CustomField[];
created_on: string;
updated_on?: string;
@ -89,6 +91,8 @@ export module RedmineTypes {
done_ratio: num,
spent_hours: num,
total_spent_hours: num,
estimated_hours: num,
total_estimated_hours: num,
custom_fields: [],
created_on: date,
};

View file

@ -0,0 +1,77 @@
import { Logger } from '@nestjs/common';
import { IssuesService } from '@app/event-emitter/issues/issues.service';
export type Params = {
query: any; // TODO: add type
schedule: string;
};
export type Report = {
id: string;
name: string;
datetime: number;
datetimeFormatted: string;
jobInfo: JobInfo;
params: Params; // query?, schedule?, etc TODO
data: any;
};
export type JobInfo = {
jobId: string;
dashboardId: string;
widgetId: string;
};
export class DailyEccmV2ReportTaskHandlerService {
private logger = new Logger(DailyEccmV2ReportTaskHandlerService.name);
private issuesService: IssuesService | null = null;
constructor(public params: Params, public jobInfo: JobInfo) {
return;
}
setIssuesService(issuesService: IssuesService): void {
this.issuesService = issuesService;
}
getIssuesService(): IssuesService | null {
if (!this.issuesService) {
this.logger.warn(
'DailyEccmV2ReportTaskHandlerService is not initialized, issuesService is null',
);
return null;
}
return this.issuesService;
}
async createReport(): Promise<void> {
try {
const report = await this.prepareReportData();
await this.saveNewReport(report);
} catch (error) {
this.logger.error(error);
}
}
async prepareReportData(): Promise<Report> {
const issuesService = this.getIssuesService();
if (!issuesService) {
throw new Error(
'Cannot create report without issuesService, DailyEccmV2ReportTaskHandlerService is not initialized',
);
}
const issues = await this.issuesService.mergedTreesAndFind(
this.params.query,
);
return {} as Report;
}
async saveNewReport(report: Report): Promise<boolean> {
return true;
}
async updatePreviousReport(report: Report): Promise<boolean> {
return true;
}
}

View file

@ -8,26 +8,19 @@ import { DailyEccmReportsV2Datasource } from 'src/couchdb-datasources/daily-eccm
import { randomUUID } from 'crypto';
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { DateTime } from 'luxon';
export type Params = {
query: any; // TODO: add type
schedule: string;
};
export type Report = {
id: string;
name: string;
datetime: number;
datetimeFormatted: string;
params: Params; // query?, schedule?, etc TODO
data: any;
};
import { Params } from './daily-eccm-v2-report-task-handler';
export type Job = {
id: string;
params: Params;
};
export type JobHandlerParams = {
job: Job;
dashboard: Dashboard;
widget: Widget;
};
export const WIDGET_TYPE = 'daily_eccm_v2';
export const JOB_PREFIX = 'daily_eccm_v2';
@ -160,6 +153,333 @@ export class DailyEccmV2ReportTaskRunnerService {
};
}
private async analyzeIssueMetrics(
dashboard: Dashboard,
widget: Widget,
issues: RedmineTypes.Issue[],
previousIssues?: RedmineTypes.Issue[],
previousMetrics?: Record<string, any>,
): Promise<Record<string, any>> {
this.logger.log(
`Analyzing issues metrics for dashboard ${dashboard.id}, widget ${widget.id}`,
);
// ШАГ 1. Подсчет количества задач по статусам
this.logger.debug(`Step 1. Calculating issues by status`);
const issuesByStatusCount = issues.reduce((acc, issue) => {
const status = issue.status?.name;
if (status) {
if (!acc[status]) {
acc[status] = {
count: 0,
issueIds: [],
byVersion: {} as Record<
string,
{ count: number; issueIds: number[] }
>,
};
}
acc[status].count = acc[status].count + 1;
acc[status].issueIds.push(issue.id);
const version = issue.fixed_version?.name;
if (version) {
if (!acc[status].byVersion[version]) {
acc[status].byVersion[version] = { count: 0, issueIds: [] };
acc[status].byVersion[version].count++;
acc[status].byVersion[version].issueIds.push(issue.id);
}
}
}
return acc;
}, {});
this.logger.debug(
'Step 1. Calculating issues by status - done',
JSON.stringify(issuesByStatusCount),
);
// ШАГ 2. Подсчет количества задач по версиям
this.logger.debug('Step 2. Calculating issues by versions');
const issuesByVersionsCount = issues.reduce((acc, issue) => {
const version = issue.fixed_version?.name;
if (version) {
if (!acc[version]) {
acc[version] = {
count: 0,
issueIds: [],
byStatus: {} as Record<
string,
{ count: number; issueIds: number[] }
>,
};
}
acc[version].count = acc[version].count + 1;
acc[version].issueIds.push(issue.id);
const status = issue.status?.name;
if (status) {
if (!acc[version].byStatus[status]) {
acc[version].byStatus[status] = { count: 0, issueIds: [] };
}
acc[version].byStatus[status].count += 1;
acc[version].byStatus[status].issueIds.push(issue.id);
}
}
return acc;
}, {});
this.logger.debug(
'Step 2. Calculating issues by versions - done',
JSON.stringify(issuesByVersionsCount),
);
// ШАГ 3. Подсчет количества внутренних изменений
this.logger.debug('Step 3. Calculating internal changes');
const changesInterval =
(widget?.dataLoaderParams['changesInterval'] as number) ??
24 * 60 * 60 * 1000;
const changesCount = {
totalChanges: 0,
totalComments: 0,
byIssue: {} as Record<number, { changes: number; comments: number }>,
byStatus: {} as Record<string, { changes: number; comments: number }>,
byVersion: {} as Record<string, { changes: number; comments: number }>,
byUserName: {} as Record<string, { changes: number; comments: number }>,
};
const now = DateTime.now().toMillis();
if (
typeof changesInterval === 'number' &&
changesInterval > 0 &&
issues?.length > 0
) {
issues.forEach((issue) => {
const status = issue.status?.name ?? 'no_status';
const version = issue.fixed_version?.name ?? 'no_version';
const changes = issue.journals?.reduce(
(acc, journal) => {
const createdOnTimestamp = DateTime.fromISO(
journal.created_on,
).toMillis();
const currentUser = journal?.user?.name ?? 'unknown';
if (
createdOnTimestamp > now - changesInterval &&
createdOnTimestamp <= now
) {
if (!changesCount.byStatus[status]) {
changesCount.byStatus[status] = { changes: 0, comments: 0 };
}
if (!changesCount.byVersion[version]) {
changesCount.byVersion[version] = { changes: 0, comments: 0 };
}
if (!changesCount.byUserName[currentUser]) {
changesCount.byUserName[currentUser] = {
changes: 0,
comments: 0,
};
}
acc.changes += 1;
changesCount.totalChanges += 1;
changesCount.byStatus[status].changes += 1;
changesCount.byVersion[version].changes += 1;
changesCount.byUserName[currentUser].changes += 1;
if (
typeof journal.notes === 'string' &&
journal.notes.length > 0
) {
acc.comments += 1;
changesCount.totalComments += 1;
changesCount.byStatus[status].comments += 1;
changesCount.byVersion[version].comments += 1;
changesCount.byUserName[currentUser].comments += 1;
}
}
return acc;
},
{
changes: 0,
comments: 0,
},
);
changesCount.byIssue[issue.id] = changes;
});
}
this.logger.debug(
'Step 3. Calculating internal changes - done',
JSON.stringify(changesCount),
);
// ШАГ 4: Количество задач с оценками трудозатрат
this.logger.debug('Step 4. Counting issues with estimates and spent hours');
const issuesWithEstimatesAndSpenthoursCount = {
withEstimates: { count: 0, issueIds: [] },
withoutEstimates: { count: 0, issueIds: [] },
withSpentHoursOverEstimates: { count: 0, issueIds: [] },
byVersion: {} as Record<
string,
{
withEstimates: { count: number; issueIds: number[] };
withoutEstimates: { count: number; issueIds: number[] };
withSpentHoursOverEstimates: { count: number; issueIds: number[] };
}
>,
byStatus: {} as Record<
string,
{
withEstimates: { count: number; issueIds: number[] };
withoutEstimates: { count: number; issueIds: number[] };
withSpentHoursOverEstimates: { count: number; issueIds: number[] };
}
>,
};
issues.forEach((issue) => {
const version = issue.fixed_version?.name;
if (
version &&
!issuesWithEstimatesAndSpenthoursCount.byVersion[version]
) {
issuesWithEstimatesAndSpenthoursCount.byVersion[version] = {
withEstimates: { count: 0, issueIds: [] },
withoutEstimates: { count: 0, issueIds: [] },
withSpentHoursOverEstimates: { count: 0, issueIds: [] },
};
}
const status = issue.status?.name;
if (status && !issuesWithEstimatesAndSpenthoursCount.byStatus[status]) {
issuesWithEstimatesAndSpenthoursCount.byStatus[status] = {
withEstimates: { count: 0, issueIds: [] },
withoutEstimates: { count: 0, issueIds: [] },
withSpentHoursOverEstimates: { count: 0, issueIds: [] },
};
}
if (
typeof issue.estimated_hours === 'number' &&
issue.estimated_hours > 0
) {
issuesWithEstimatesAndSpenthoursCount.withEstimates.count += 1;
issuesWithEstimatesAndSpenthoursCount.withEstimates.issueIds.push(
issue.id,
);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withEstimates.issueIds.push(issue.id);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withEstimates.issueIds.push(issue.id);
} else {
issuesWithEstimatesAndSpenthoursCount.withoutEstimates.count += 1;
issuesWithEstimatesAndSpenthoursCount.withoutEstimates.issueIds.push(
issue.id,
);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withoutEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withoutEstimates.issueIds.push(issue.id);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withoutEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withoutEstimates.issueIds.push(issue.id);
}
if (
typeof issue.spent_hours === 'number' &&
typeof issue.estimated_hours === 'number' &&
issue.spent_hours > issue.estimated_hours
) {
issuesWithEstimatesAndSpenthoursCount.withSpentHoursOverEstimates.count += 1;
issuesWithEstimatesAndSpenthoursCount.withSpentHoursOverEstimates.issueIds.push(
issue.id,
);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withSpentHoursOverEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byStatus[status].withSpentHoursOverEstimates.issueIds.push(issue.id);
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withSpentHoursOverEstimates.count += 1;
// eslint-disable-next-line prettier/prettier
issuesWithEstimatesAndSpenthoursCount.byVersion[version].withSpentHoursOverEstimates.issueIds.push(issue.id);
}
});
this.logger.debug(
'Step 4. Counting issues with estimates and spent hours - done',
JSON.stringify(issuesWithEstimatesAndSpenthoursCount),
);
// ШАГ 5: Счётчики сравнения с предыдущим отчётом
this.logger.debug('Step 5: Calculating differences with previous report');
const differencesCount = {
newIssues: { count: 0, issueIds: [] },
lostIssues: { count: 0, issueIds: [] },
reopenedIssues: { count: 0, issueIds: [] },
closedIssues: { count: 0, issueIds: [] },
};
issues.forEach((issue) => {
const issueIntoPreviousReport = previousIssues.find(
(prevIssue) => prevIssue.id === issue.id,
);
if (!issueIntoPreviousReport) {
differencesCount.newIssues.count += 1;
differencesCount.newIssues.issueIds.push(issue.id);
}
});
previousIssues.forEach((prevIssue) => {
const issueIntoCurrentReport = issues.find(
(currIssue) => currIssue.id === prevIssue.id,
);
if (!issueIntoCurrentReport) {
differencesCount.lostIssues.count += 1;
differencesCount.lostIssues.issueIds.push(prevIssue.id);
}
});
issues.forEach((issue) => {
const issueStatus: any = issue.status;
if (issueStatus.is_closed) {
const prevReportClosedIssue = previousIssues.find((prevIssue) => {
return (
prevIssue.id === issue.id &&
prevIssue.status &&
(prevIssue.status as any).is_closed
);
});
if (!prevReportClosedIssue) {
differencesCount.closedIssues.count += 1;
differencesCount.closedIssues.issueIds.push(issue.id);
}
} else {
const prevReportOpenIssue = previousIssues.find((prevIssue) => {
return (
prevIssue.id === issue.id &&
prevIssue.status &&
!(prevIssue.status as any)?.is_closed
);
});
if (!prevReportOpenIssue) {
differencesCount.reopenedIssues.count += 1;
differencesCount.reopenedIssues.issueIds.push(issue.id);
}
}
});
this.logger.debug(
'Step 5: Calculating differences with previous report - done',
JSON.stringify(differencesCount),
);
const metrics = {
issuesByStatusCount: issuesByStatusCount,
issuesByVersionsCount: issuesByVersionsCount,
changesCount: changesCount,
issuesWithEstimatesAndSpenthoursCount:
issuesWithEstimatesAndSpenthoursCount,
differencesCount: differencesCount,
};
this.logger.log(
`Metrics generated for dashboard ${dashboard.id}, widget ${widget.id}`,
);
return metrics;
}
private async saveReport(
dashboard: Dashboard,
widget: Widget,
@ -180,6 +500,7 @@ export class DailyEccmV2ReportTaskRunnerService {
updated_on: issue.updated_on,
closed_on: issue.closed_on,
status: issue.status,
fixed_version: issue.fixed_version,
priority: issue.priority,
author: issue.author,
assigned_to: issue.assigned_to,
@ -192,9 +513,33 @@ export class DailyEccmV2ReportTaskRunnerService {
due_date: issue.due_date,
done_ratio: issue.done_ratio,
estimated_hours: issue.estimated_hours,
total_estimated_hours: issue.total_estimated_hours,
spent_hours: issue.spent_hours,
total_spent_hours: issue.total_spent_hours,
};
});
const prevDataResponse = await datasource.find({
selector: {
dashboardId: dashboardId,
widgetId: widget.id,
latest: true,
},
limit: 1,
});
const prevData = prevDataResponse.docs[0];
const prevIssues = prevData ? prevData.reportIssues : [];
const prevMetrics = prevData ? prevData.issuesMetrics : {};
if (prevData) {
prevData.latest = false;
await datasource.insert(prevData);
}
const issuesMetrics = await this.analyzeIssueMetrics(
dashboard,
widget,
issues,
prevIssues,
prevMetrics,
);
const item: any = {
_id: id,
id: id,
@ -203,6 +548,8 @@ export class DailyEccmV2ReportTaskRunnerService {
datetime,
datetimeFormatted,
reportIssues,
issuesMetrics,
latest: true,
};
await datasource.insert(item);
this.logger.debug(

View file

@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class DailyEccmV2ReportService {}