diff --git a/libs/event-emitter/src/models/redmine-types.ts b/libs/event-emitter/src/models/redmine-types.ts index 00471c3..b00555e 100644 --- a/libs/event-emitter/src/models/redmine-types.ts +++ b/libs/event-emitter/src/models/redmine-types.ts @@ -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, }; diff --git a/src/reports/daily-eccm-v2-report-task-handler.ts b/src/reports/daily-eccm-v2-report-task-handler.ts new file mode 100644 index 0000000..9426289 --- /dev/null +++ b/src/reports/daily-eccm-v2-report-task-handler.ts @@ -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 { + try { + const report = await this.prepareReportData(); + await this.saveNewReport(report); + } catch (error) { + this.logger.error(error); + } + } + + async prepareReportData(): Promise { + 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 { + return true; + } + + async updatePreviousReport(report: Report): Promise { + return true; + } +} diff --git a/src/reports/daily-eccm-v2-report-task-runner.service.ts b/src/reports/daily-eccm-v2-report-task-runner.service.ts index b184845..76cd5c3 100644 --- a/src/reports/daily-eccm-v2-report-task-runner.service.ts +++ b/src/reports/daily-eccm-v2-report-task-runner.service.ts @@ -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, + ): Promise> { + 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, + byStatus: {} as Record, + byVersion: {} as Record, + byUserName: {} as Record, + }; + 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( diff --git a/src/reports/daily-eccm-v2-report.service.ts b/src/reports/daily-eccm-v2-report.service.ts new file mode 100644 index 0000000..545e219 --- /dev/null +++ b/src/reports/daily-eccm-v2-report.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class DailyEccmV2ReportService {}