From e377e714dd5289120abe28c6508c7637e4834031 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 11 Jun 2025 18:29:59 +0200 Subject: [PATCH 1/6] Add currency formatting and rate retrieval functions with unit tests - Introduced `formatCurrency` function to format numbers as USD currency, ensuring proper localization and rounding. - Added `getRateByDate` function to retrieve rates from a historical dataset based on a specified date, with handling for undefined inputs and edge cases. - Created unit tests for both functions to validate their functionality and accuracy, covering various scenarios for currency formatting and rate retrieval. These additions enhance the utility functions for financial calculations and improve test coverage for the application. --- workers/main/src/common/formatUtils.test.ts | 41 +++++++++++++++++++++ workers/main/src/common/formatUtils.ts | 25 +++++++++++++ workers/main/src/common/types.ts | 2 +- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 workers/main/src/common/formatUtils.test.ts create mode 100644 workers/main/src/common/formatUtils.ts diff --git a/workers/main/src/common/formatUtils.test.ts b/workers/main/src/common/formatUtils.test.ts new file mode 100644 index 0000000..07006b8 --- /dev/null +++ b/workers/main/src/common/formatUtils.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { formatCurrency, getRateByDate } from './formatUtils'; + +describe('formatCurrency', () => { + it('formats integer values as USD currency', () => { + expect(formatCurrency(1000)).toBe('$1,000'); + expect(formatCurrency(0)).toBe('$0'); + expect(formatCurrency(1234567)).toBe('$1,234,567'); + }); + + it('rounds down decimal values', () => { + expect(formatCurrency(1234.56)).toBe('$1,235'); + expect(formatCurrency(999.4)).toBe('$999'); + }); +}); + +describe('getRateByDate', () => { + const history = { + '2024-01-01': 100, + '2024-02-01': 200, + '2024-03-01': 300, + }; + + it('returns undefined if history is undefined', () => { + expect(getRateByDate(undefined, '2024-01-01')).toBeUndefined(); + }); + + it('returns the rate for the exact date', () => { + expect(getRateByDate(history, '2024-02-01')).toBe(200); + }); + + it('returns the latest rate before the date', () => { + expect(getRateByDate(history, '2024-02-15')).toBe(200); + expect(getRateByDate(history, '2024-03-15')).toBe(300); + }); + + it('returns undefined if date is before all history', () => { + expect(getRateByDate(history, '2023-12-31')).toBeUndefined(); + }); +}); diff --git a/workers/main/src/common/formatUtils.ts b/workers/main/src/common/formatUtils.ts new file mode 100644 index 0000000..67c24d6 --- /dev/null +++ b/workers/main/src/common/formatUtils.ts @@ -0,0 +1,25 @@ +export function formatCurrency(value: number): string { + return `$${value.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; +} + +export function getRateByDate( + rateHistory: { [date: string]: number } | undefined, + date: string, +): number | undefined { + if (!rateHistory) { + return undefined; + } + + const sortedDates = Object.keys(rateHistory).sort(); + let lastRate: number | undefined = undefined; + + for (const rateDate of sortedDates) { + if (rateDate <= date) { + lastRate = rateHistory[rateDate]; + } else { + break; + } + } + + return lastRate; +} diff --git a/workers/main/src/common/types.ts b/workers/main/src/common/types.ts index 32f007c..ef5dcfb 100644 --- a/workers/main/src/common/types.ts +++ b/workers/main/src/common/types.ts @@ -1,4 +1,4 @@ -import { GroupNameEnum } from '../configs'; +import { GroupNameEnum } from '../configs/weeklyFinancialReport'; export interface TargetUnit { group_id: number; From bf2880191533bf1cbdf16cabb4595fcc1a2ba97a Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 11 Jun 2025 19:40:28 +0200 Subject: [PATCH 2/6] Sort rate history dates in chronological order in getRateByDate function - Updated the `getRateByDate` function to sort the rate history dates chronologically using a custom sorting function. This ensures that the dates are processed in the correct order, improving the accuracy of rate retrieval based on date. These changes enhance the functionality of the rate retrieval process, ensuring more reliable data handling. --- workers/main/src/common/formatUtils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/workers/main/src/common/formatUtils.ts b/workers/main/src/common/formatUtils.ts index 67c24d6..6d339ed 100644 --- a/workers/main/src/common/formatUtils.ts +++ b/workers/main/src/common/formatUtils.ts @@ -10,7 +10,9 @@ export function getRateByDate( return undefined; } - const sortedDates = Object.keys(rateHistory).sort(); + const sortedDates = Object.keys(rateHistory).sort( + (a, b) => new Date(a).getTime() - new Date(b).getTime(), + ); let lastRate: number | undefined = undefined; for (const rateDate of sortedDates) { From 35e6dd3a9170f198706a20d37b24f91df584bed2 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 11 Jun 2025 19:46:03 +0200 Subject: [PATCH 3/6] Add Weekly Financial Report functionality with repository and tests - Introduced `WeeklyFinancialReportRepository` class implementing `IWeeklyFinancialReportRepository` for generating weekly financial reports based on target units, employees, and projects. - Added `IWeeklyFinancialReportRepository` interface and `GenerateReportInput` type for structured input handling. - Created unit tests for the repository to validate report generation, ensuring correct summary and details output for various input scenarios, including handling of empty input arrays. These changes enhance the financial reporting capabilities of the application, providing structured and detailed insights into weekly performance. --- .../IWeeklyFinancialReportRepository.ts | 14 ++ .../WeeklyFinancialReportRepository.test.ts | 108 ++++++++++++++ .../WeeklyFinancialReportRepository.ts | 136 ++++++++++++++++++ .../services/WeeklyFinancialReport/index.ts | 1 + 4 files changed, 259 insertions(+) create mode 100644 workers/main/src/services/WeeklyFinancialReport/IWeeklyFinancialReportRepository.ts create mode 100644 workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts create mode 100644 workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts create mode 100644 workers/main/src/services/WeeklyFinancialReport/index.ts diff --git a/workers/main/src/services/WeeklyFinancialReport/IWeeklyFinancialReportRepository.ts b/workers/main/src/services/WeeklyFinancialReport/IWeeklyFinancialReportRepository.ts new file mode 100644 index 0000000..7b12714 --- /dev/null +++ b/workers/main/src/services/WeeklyFinancialReport/IWeeklyFinancialReportRepository.ts @@ -0,0 +1,14 @@ +import { TargetUnit } from '../../common/types'; +import { Employee, Project } from '../FinApp'; + +export interface GenerateReportInput { + targetUnits: TargetUnit[]; + employees: Employee[]; + projects: Project[]; +} + +export interface IWeeklyFinancialReportRepository { + generateReport( + params: GenerateReportInput, + ): Promise<{ summary: string; details: string }>; +} diff --git a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts new file mode 100644 index 0000000..06c08c8 --- /dev/null +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { WeeklyFinancialReportRepository } from './WeeklyFinancialReportRepository'; + +describe('WeeklyFinancialReportRepository', () => { + const repo = new WeeklyFinancialReportRepository(); + + const targetUnits = [ + { + group_id: 1, + group_name: 'Group A', + project_id: 10, + project_name: 'Project X', + user_id: 100, + username: 'Alice', + spent_on: '2024-06-01', + total_hours: 8, + }, + { + group_id: 1, + group_name: 'Group A', + project_id: 10, + project_name: 'Project X', + user_id: 101, + username: 'Bob', + spent_on: '2024-06-01', + total_hours: 4, + }, + { + group_id: 2, + group_name: 'Group B', + project_id: 20, + project_name: 'Project Y', + user_id: 102, + username: 'Charlie', + spent_on: '2024-06-01', + total_hours: 5, + }, + ]; + const employees = [ + { redmine_id: 100, history: { rate: { '2024-01-01': 100 } } }, + { redmine_id: 101, history: { rate: { '2024-01-01': 200 } } }, + { redmine_id: 102, history: { rate: { '2024-01-01': 300 } } }, + ]; + const projects = [ + { redmine_id: 10, history: { rate: { '2024-01-01': 500 } } }, + { redmine_id: 20, history: { rate: { '2024-01-01': 1000 } } }, + ]; + + it('generates a report with summary and details', async () => { + const { summary, details } = await repo.generateReport({ + targetUnits, + employees, + projects, + }); + + expect(typeof summary).toBe('string'); + expect(typeof details).toBe('string'); + expect(summary.length).toBeGreaterThan(0); + expect(details.length).toBeGreaterThan(0); + + // Check summary content + expect(summary).toContain('Weekly Financial Summary for Target Units'); + expect(summary).toContain('Marginality is 55% or higher'); + expect(summary).toContain('Marginality is between 45-55%'); + expect(summary).toContain('Marginality is under 45%'); + expect(summary).toContain( + 'The specific figures will be available in the thread', + ); + // Group names should appear in summary + expect(summary).toContain('Group A'); + expect(summary).toContain('Group B'); + + // Check details content + expect(details).toContain('Total hours'); + expect(details).toContain('Group A'); + expect(details).toContain('Group B'); + expect(details).toMatch(/\*Period\*: Q\d/); + expect(details).toContain('Revenue'); + expect(details).toContain('COGS'); + expect(details).toContain('Margin'); + expect(details).toContain('Marginality'); + expect(details).toContain('Notes:'); + expect(details).toContain('Contract Type'); + expect(details).toContain('Effective Revenue'); + expect(details).toContain('Dept Tech'); + expect(details).toContain('Legend'); + // Marginality indicators + expect(details).toMatch(/:arrow(up|down):|:large_yellow_circle:/); + // Check for correct currency formatting + expect(details).toMatch(/\$[\d,]+/); + }); + + it('handles empty input arrays', async () => { + const { summary, details } = await repo.generateReport({ + targetUnits: [], + employees: [], + projects: [], + }); + + expect(typeof summary).toBe('string'); + expect(typeof details).toBe('string'); + expect(details).toContain('*Total hours*: 0h'); + expect(details).toContain('Notes:'); + expect(details).toContain('Legend'); + expect(summary).toContain('Weekly Financial Summary for Target Units'); + }); +}); diff --git a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts new file mode 100644 index 0000000..3704860 --- /dev/null +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts @@ -0,0 +1,136 @@ +import { formatCurrency, getRateByDate } from '../../common/formatUtils'; +import { + GenerateReportInput, + IWeeklyFinancialReportRepository, +} from './IWeeklyFinancialReportRepository'; + +function composeWeeklyReportTitle(currentDate: Date): string { + const periodStart = new Date( + currentDate.getFullYear(), + currentDate.getMonth(), + currentDate.getDate() - ((currentDate.getDay() + 6) % 7) - 7, + ) + .toISOString() + .slice(0, 10); + const periodEnd = new Date( + currentDate.getFullYear(), + currentDate.getMonth(), + currentDate.getDate() - ((currentDate.getDay() + 6) % 7) - 1, + ) + .toISOString() + .slice(0, 10); + + return `*Weekly Financial Summary for Target Units* (${periodStart} - ${periodEnd})`; +} + +export class WeeklyFinancialReportRepository + implements IWeeklyFinancialReportRepository +{ + async generateReport({ + targetUnits, + employees, + projects, + }: GenerateReportInput) { + const currentDate = new Date(); + const reportTitle = composeWeeklyReportTitle(currentDate); + + const processedGroupIds = new Set(); + let reportDetails = ``; + let totalReportedHours = 0; + const currentQuarter = `Q${Math.floor(currentDate.getMonth() / 3) + 1}`; + + const highMarginalityGroups: string[] = []; + const mediumMarginalityGroups: string[] = []; + const lowMarginalityGroups: string[] = []; + + for (const targetUnit of targetUnits) { + if (!processedGroupIds.has(targetUnit.group_id)) { + processedGroupIds.add(targetUnit.group_id); + const groupUnits = targetUnits.filter( + (unit) => unit.group_id === targetUnit.group_id, + ); + const groupTotalHours = groupUnits.reduce( + (sum, unit) => sum + unit.total_hours, + 0, + ); + + let groupTotalCogs = 0; + let groupTotalRevenue = 0; + + for (const unit of groupUnits) { + const employee = employees.find((e) => e.redmine_id === unit.user_id); + const project = projects.find( + (p) => p.redmine_id === unit.project_id, + ); + const date = unit.spent_on; + const employeeRate = + getRateByDate(employee?.history?.rate, date) || 0; + const projectRate = getRateByDate(project?.history?.rate, date) || 0; + + groupTotalCogs += employeeRate * unit.total_hours; + groupTotalRevenue += projectRate * unit.total_hours; + } + + const groupMarginAmount = groupTotalRevenue - groupTotalCogs; + const groupMarginalityPercent = + groupTotalRevenue > 0 + ? (groupMarginAmount / groupTotalRevenue) * 100 + : 0; + + let marginalityIndicator = ''; + + if (groupMarginalityPercent >= 55) { + marginalityIndicator = ':arrowup:'; + highMarginalityGroups.push(targetUnit.group_name); + } else if (groupMarginalityPercent >= 45) { + marginalityIndicator = ':large_yellow_circle:'; + mediumMarginalityGroups.push(targetUnit.group_name); + } else { + marginalityIndicator = ':arrowdown:'; + lowMarginalityGroups.push(targetUnit.group_name); + } + + reportDetails += `${marginalityIndicator} *${targetUnit.group_name}* (${groupTotalHours}h)\n`; + reportDetails += `*Period*: ${currentQuarter}\n`; + reportDetails += `*Revenue*: ${formatCurrency(groupTotalRevenue)}\n`; + reportDetails += `*COGS*: ${formatCurrency(groupTotalCogs)}\n`; + reportDetails += `*Margin*: ${formatCurrency(groupMarginAmount)}\n`; + reportDetails += `*Marginality*: ${groupMarginalityPercent.toFixed(0)}%\n\n`; + totalReportedHours += groupTotalHours; + } + } + reportDetails += '\n*Total hours*: ' + totalReportedHours + 'h\n\n'; + reportDetails += '*Notes:*\n'; + reportDetails += '1. *Contract Type* is not implemented\n'; + reportDetails += '2. *Effective Revenue* is not implemented\n'; + reportDetails += '3. *Dept Tech* hours are not implemented\n\n'; + reportDetails += + '*Legend*: Marginality :arrowup: ≥55% :large_yellow_circle: 45-54% :arrowdown: <45%'; + + let reportSummary = `${reportTitle}\n`; + + reportSummary += '________________________________\n'; + reportSummary += ':arrowup: *Marginality is 55% or higher*:\n'; + if (highMarginalityGroups.length) { + reportSummary += highMarginalityGroups.join('\n') + '\n'; + } + reportSummary += '__________________________________\n'; + reportSummary += + ' :large_yellow_circle: *Marginality is between 45-55%*:\n'; + if (mediumMarginalityGroups.length) { + reportSummary += mediumMarginalityGroups.join('\n') + '\n'; + } + reportSummary += '__________________________________\n'; + reportSummary += ':arrowdown: *Marginality is under 45%*:\n'; + if (lowMarginalityGroups.length) { + reportSummary += lowMarginalityGroups.join('\n') + '\n'; + } + reportSummary += ' -------------------------------------------\n'; + reportSummary += 'The specific figures will be available in the thread'; + + return { + details: reportDetails, + summary: reportSummary, + }; + } +} diff --git a/workers/main/src/services/WeeklyFinancialReport/index.ts b/workers/main/src/services/WeeklyFinancialReport/index.ts new file mode 100644 index 0000000..a13ba15 --- /dev/null +++ b/workers/main/src/services/WeeklyFinancialReport/index.ts @@ -0,0 +1 @@ +export * from './WeeklyFinancialReportRepository'; From 1d46c2370919c811d4db52ef4917ca24532144db Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Tue, 17 Jun 2025 18:07:39 +0200 Subject: [PATCH 4/6] feat: Enhance Weekly Financial Report functionality - Added `HIGH_MARGINALITY_THRESHOLD` and `MEDIUM_MARGINALITY_THRESHOLD` constants to define marginality levels. - Introduced `GroupAggregator` class for aggregating target units by group and calculating total hours. - Implemented `MarginalityCalculator` class to calculate marginality metrics based on revenue and COGS. - Created `WeeklyFinancialReportFormatter` class for formatting report details and summaries. - Updated `WeeklyFinancialReportRepository` to utilize new classes for processing target units and generating reports. These changes improve the financial reporting capabilities by providing structured aggregation and calculation of marginality, enhancing the overall reporting process. --- .../main/src/configs/weeklyFinancialReport.ts | 3 + .../WeeklyFinancialReport/GroupAggregator.ts | 15 + .../IWeeklyFinancialReportRepository.ts | 6 + .../MarginalityCalculator.ts | 47 +++ .../WeeklyFinancialReportFormatter.ts | 76 +++++ .../WeeklyFinancialReportRepository.ts | 278 +++++++++++------- 6 files changed, 319 insertions(+), 106 deletions(-) create mode 100644 workers/main/src/services/WeeklyFinancialReport/GroupAggregator.ts create mode 100644 workers/main/src/services/WeeklyFinancialReport/MarginalityCalculator.ts create mode 100644 workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts diff --git a/workers/main/src/configs/weeklyFinancialReport.ts b/workers/main/src/configs/weeklyFinancialReport.ts index e40c0da..0c5ba49 100644 --- a/workers/main/src/configs/weeklyFinancialReport.ts +++ b/workers/main/src/configs/weeklyFinancialReport.ts @@ -2,3 +2,6 @@ export enum GroupNameEnum { SD_REPORT = 'SD Weekly Financial Report', ED_REPORT = 'ED Weekly Financial Report', } + +export const HIGH_MARGINALITY_THRESHOLD = 55; +export const MEDIUM_MARGINALITY_THRESHOLD = 45; diff --git a/workers/main/src/services/WeeklyFinancialReport/GroupAggregator.ts b/workers/main/src/services/WeeklyFinancialReport/GroupAggregator.ts new file mode 100644 index 0000000..d0b0abc --- /dev/null +++ b/workers/main/src/services/WeeklyFinancialReport/GroupAggregator.ts @@ -0,0 +1,15 @@ +import type { TargetUnit } from '../../common/types'; + +export class GroupAggregator { + static aggregateGroup(targetUnits: TargetUnit[], targetUnitId: number) { + const groupUnits = targetUnits.filter( + (targetUnit) => targetUnit.group_id === targetUnitId, + ); + const groupTotalHours = groupUnits.reduce( + (sum, unit) => sum + unit.total_hours, + 0, + ); + + return { groupUnits, groupTotalHours }; + } +} diff --git a/workers/main/src/services/WeeklyFinancialReport/IWeeklyFinancialReportRepository.ts b/workers/main/src/services/WeeklyFinancialReport/IWeeklyFinancialReportRepository.ts index 7b12714..c1b329b 100644 --- a/workers/main/src/services/WeeklyFinancialReport/IWeeklyFinancialReportRepository.ts +++ b/workers/main/src/services/WeeklyFinancialReport/IWeeklyFinancialReportRepository.ts @@ -12,3 +12,9 @@ export interface IWeeklyFinancialReportRepository { params: GenerateReportInput, ): Promise<{ summary: string; details: string }>; } + +export interface AggregateGroupDataInput { + groupUnits: TargetUnit[]; + employees: Employee[]; + projects: Project[]; +} diff --git a/workers/main/src/services/WeeklyFinancialReport/MarginalityCalculator.ts b/workers/main/src/services/WeeklyFinancialReport/MarginalityCalculator.ts new file mode 100644 index 0000000..74c1d68 --- /dev/null +++ b/workers/main/src/services/WeeklyFinancialReport/MarginalityCalculator.ts @@ -0,0 +1,47 @@ +import { + HIGH_MARGINALITY_THRESHOLD, + MEDIUM_MARGINALITY_THRESHOLD, +} from '../../configs/weeklyFinancialReport'; + +export enum MarginalityLevel { + High = 'high', + Medium = 'medium', + Low = 'low', +} + +export interface MarginalityResult { + marginAmount: number; + marginalityPercent: number; + indicator: string; + level: MarginalityLevel; +} + +export class MarginalityCalculator { + static calculate(revenue: number, cogs: number): MarginalityResult { + const marginAmount = revenue - cogs; + const marginalityPercent = revenue > 0 ? (marginAmount / revenue) * 100 : 0; + const level = this.classify(marginalityPercent); + const indicator = this.getIndicator(level); + + return { marginAmount, marginalityPercent, indicator, level }; + } + + static classify(percent: number): MarginalityLevel { + if (percent >= HIGH_MARGINALITY_THRESHOLD) return MarginalityLevel.High; + if (percent >= MEDIUM_MARGINALITY_THRESHOLD) return MarginalityLevel.Medium; + + return MarginalityLevel.Low; + } + + static getIndicator(level: MarginalityLevel): string { + switch (level) { + case MarginalityLevel.High: + return ':arrowup:'; + case MarginalityLevel.Medium: + return ':large_yellow_circle:'; + case MarginalityLevel.Low: + default: + return ':arrowdown:'; + } + } +} diff --git a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts new file mode 100644 index 0000000..4c4a4f7 --- /dev/null +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts @@ -0,0 +1,76 @@ +import { formatCurrency } from '../../common/formatUtils'; +import { + HIGH_MARGINALITY_THRESHOLD, + MEDIUM_MARGINALITY_THRESHOLD, +} from '../../configs/weeklyFinancialReport'; + +export interface formatSummaryInput { + reportTitle: string; + highGroups: string[]; + mediumGroups: string[]; + lowGroups: string[]; +} + +export interface FormatDetailInput { + groupName: string; + groupTotalHours: number; + currentQuarter: string; + groupTotalRevenue: number; + groupTotalCogs: number; + marginAmount: number; + marginalityPercent: number; + indicator: string; +} + +export class WeeklyFinancialReportFormatter { + static formatDetail = ({ + groupName, + groupTotalHours, + currentQuarter, + groupTotalRevenue, + groupTotalCogs, + marginAmount, + marginalityPercent, + indicator, + }: FormatDetailInput) => + `${indicator} *${groupName}* (${groupTotalHours}h)\n` + + `*Period*: ${currentQuarter}\n` + + `*Revenue*: ${formatCurrency(groupTotalRevenue)}\n` + + `*COGS*: ${formatCurrency(groupTotalCogs)}\n` + + `*Margin*: ${formatCurrency(marginAmount)}\n` + + `*Marginality*: ${marginalityPercent.toFixed(0)}%\n\n`; + + static formatSummary = ({ + reportTitle, + highGroups, + mediumGroups, + lowGroups, + }: formatSummaryInput) => { + let summary = `${reportTitle}\n`; + + summary += '________________________________\n'; + summary += `:arrowup: *Marginality is ${HIGH_MARGINALITY_THRESHOLD}% or higher*:\n`; + if (highGroups.length) summary += highGroups.join('\n') + '\n'; + + summary += '__________________________________\n'; + summary += ` :large_yellow_circle: *Marginality is between ${MEDIUM_MARGINALITY_THRESHOLD}-${HIGH_MARGINALITY_THRESHOLD}%*:\n`; + if (mediumGroups.length) summary += mediumGroups.join('\n') + '\n'; + + summary += '__________________________________\n'; + summary += `:arrowdown: *Marginality is under ${MEDIUM_MARGINALITY_THRESHOLD}%*:\n`; + if (lowGroups.length) summary += lowGroups.join('\n') + '\n'; + + summary += ' -------------------------------------------\n'; + summary += 'The specific figures will be available in the thread'; + + return summary; + }; + + static formatFooter = (totalHours: number) => + `\n*Total hours*: ${totalHours}h\n\n` + + '*Notes:*\n' + + '1. *Contract Type* is not implemented\n' + + '2. *Effective Revenue* is not implemented\n' + + '3. *Dept Tech* hours are not implemented\n\n' + + `*Legend*: Marginality :arrowup: ≥${HIGH_MARGINALITY_THRESHOLD}% :large_yellow_circle: ${MEDIUM_MARGINALITY_THRESHOLD}-${HIGH_MARGINALITY_THRESHOLD - 1}% :arrowdown: <${MEDIUM_MARGINALITY_THRESHOLD}%`; +} diff --git a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts index 3704860..88fdf3f 100644 --- a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts @@ -1,26 +1,30 @@ -import { formatCurrency, getRateByDate } from '../../common/formatUtils'; +import { getRateByDate } from '../../common/formatUtils'; +import type { TargetUnit } from '../../common/types'; +import type { Employee, Project } from '../FinApp'; +import { GroupAggregator } from './GroupAggregator'; import { + AggregateGroupDataInput, GenerateReportInput, IWeeklyFinancialReportRepository, } from './IWeeklyFinancialReportRepository'; - -function composeWeeklyReportTitle(currentDate: Date): string { - const periodStart = new Date( - currentDate.getFullYear(), - currentDate.getMonth(), - currentDate.getDate() - ((currentDate.getDay() + 6) % 7) - 7, - ) - .toISOString() - .slice(0, 10); - const periodEnd = new Date( - currentDate.getFullYear(), - currentDate.getMonth(), - currentDate.getDate() - ((currentDate.getDay() + 6) % 7) - 1, - ) - .toISOString() - .slice(0, 10); - - return `*Weekly Financial Summary for Target Units* (${periodStart} - ${periodEnd})`; +import { + MarginalityCalculator, + MarginalityLevel, +} from './MarginalityCalculator'; +import { WeeklyFinancialReportFormatter } from './WeeklyFinancialReportFormatter'; + +interface ProcessTargetUnitInput { + targetUnit: TargetUnit; + targetUnits: TargetUnit[]; + employees: Employee[]; + projects: Project[]; + processedGroupIds: Set; + currentQuarter: string; + highMarginalityGroups: string[]; + mediumMarginalityGroups: string[]; + lowMarginalityGroups: string[]; + updateReportDetails: (detail: string) => void; + updateTotalReportedHours: (hours: number) => void; } export class WeeklyFinancialReportRepository @@ -32,105 +36,167 @@ export class WeeklyFinancialReportRepository projects, }: GenerateReportInput) { const currentDate = new Date(); - const reportTitle = composeWeeklyReportTitle(currentDate); - + const reportTitle = this.composeWeeklyReportTitle(currentDate); const processedGroupIds = new Set(); - let reportDetails = ``; + let reportDetails = ''; let totalReportedHours = 0; const currentQuarter = `Q${Math.floor(currentDate.getMonth() / 3) + 1}`; - - const highMarginalityGroups: string[] = []; - const mediumMarginalityGroups: string[] = []; - const lowMarginalityGroups: string[] = []; + const highGroups: string[] = []; + const mediumGroups: string[] = []; + const lowGroups: string[] = []; for (const targetUnit of targetUnits) { - if (!processedGroupIds.has(targetUnit.group_id)) { - processedGroupIds.add(targetUnit.group_id); - const groupUnits = targetUnits.filter( - (unit) => unit.group_id === targetUnit.group_id, - ); - const groupTotalHours = groupUnits.reduce( - (sum, unit) => sum + unit.total_hours, - 0, - ); - - let groupTotalCogs = 0; - let groupTotalRevenue = 0; - - for (const unit of groupUnits) { - const employee = employees.find((e) => e.redmine_id === unit.user_id); - const project = projects.find( - (p) => p.redmine_id === unit.project_id, - ); - const date = unit.spent_on; - const employeeRate = - getRateByDate(employee?.history?.rate, date) || 0; - const projectRate = getRateByDate(project?.history?.rate, date) || 0; - - groupTotalCogs += employeeRate * unit.total_hours; - groupTotalRevenue += projectRate * unit.total_hours; - } - - const groupMarginAmount = groupTotalRevenue - groupTotalCogs; - const groupMarginalityPercent = - groupTotalRevenue > 0 - ? (groupMarginAmount / groupTotalRevenue) * 100 - : 0; - - let marginalityIndicator = ''; - - if (groupMarginalityPercent >= 55) { - marginalityIndicator = ':arrowup:'; - highMarginalityGroups.push(targetUnit.group_name); - } else if (groupMarginalityPercent >= 45) { - marginalityIndicator = ':large_yellow_circle:'; - mediumMarginalityGroups.push(targetUnit.group_name); - } else { - marginalityIndicator = ':arrowdown:'; - lowMarginalityGroups.push(targetUnit.group_name); - } - - reportDetails += `${marginalityIndicator} *${targetUnit.group_name}* (${groupTotalHours}h)\n`; - reportDetails += `*Period*: ${currentQuarter}\n`; - reportDetails += `*Revenue*: ${formatCurrency(groupTotalRevenue)}\n`; - reportDetails += `*COGS*: ${formatCurrency(groupTotalCogs)}\n`; - reportDetails += `*Margin*: ${formatCurrency(groupMarginAmount)}\n`; - reportDetails += `*Marginality*: ${groupMarginalityPercent.toFixed(0)}%\n\n`; - totalReportedHours += groupTotalHours; - } + this.processTargetUnit({ + targetUnit, + targetUnits, + employees, + projects, + processedGroupIds, + currentQuarter, + highMarginalityGroups: highGroups, + mediumMarginalityGroups: mediumGroups, + lowMarginalityGroups: lowGroups, + updateReportDetails: (detail) => (reportDetails += detail), + updateTotalReportedHours: (hours) => (totalReportedHours += hours), + }); } - reportDetails += '\n*Total hours*: ' + totalReportedHours + 'h\n\n'; - reportDetails += '*Notes:*\n'; - reportDetails += '1. *Contract Type* is not implemented\n'; - reportDetails += '2. *Effective Revenue* is not implemented\n'; - reportDetails += '3. *Dept Tech* hours are not implemented\n\n'; + reportDetails += - '*Legend*: Marginality :arrowup: ≥55% :large_yellow_circle: 45-54% :arrowdown: <45%'; + WeeklyFinancialReportFormatter.formatFooter(totalReportedHours); - let reportSummary = `${reportTitle}\n`; - - reportSummary += '________________________________\n'; - reportSummary += ':arrowup: *Marginality is 55% or higher*:\n'; - if (highMarginalityGroups.length) { - reportSummary += highMarginalityGroups.join('\n') + '\n'; - } - reportSummary += '__________________________________\n'; - reportSummary += - ' :large_yellow_circle: *Marginality is between 45-55%*:\n'; - if (mediumMarginalityGroups.length) { - reportSummary += mediumMarginalityGroups.join('\n') + '\n'; - } - reportSummary += '__________________________________\n'; - reportSummary += ':arrowdown: *Marginality is under 45%*:\n'; - if (lowMarginalityGroups.length) { - reportSummary += lowMarginalityGroups.join('\n') + '\n'; - } - reportSummary += ' -------------------------------------------\n'; - reportSummary += 'The specific figures will be available in the thread'; + const reportSummary = WeeklyFinancialReportFormatter.formatSummary({ + reportTitle, + highGroups, + mediumGroups, + lowGroups, + }); return { details: reportDetails, summary: reportSummary, }; } + + private processTargetUnit({ + targetUnit, + targetUnits, + employees, + projects, + processedGroupIds, + currentQuarter, + highMarginalityGroups, + mediumMarginalityGroups, + lowMarginalityGroups, + updateReportDetails, + updateTotalReportedHours, + }: ProcessTargetUnitInput) { + if (!processedGroupIds.has(targetUnit.group_id)) { + processedGroupIds.add(targetUnit.group_id); + + const { groupUnits, groupTotalHours } = GroupAggregator.aggregateGroup( + targetUnits, + targetUnit.group_id, + ); + const { groupTotalCogs, groupTotalRevenue } = this.aggregateGroupData({ + groupUnits, + employees, + projects, + }); + const marginality = MarginalityCalculator.calculate( + groupTotalRevenue, + groupTotalCogs, + ); + + this.pushGroupByMarginality(marginality.level, targetUnit.group_name, { + highMarginalityGroups, + mediumMarginalityGroups, + lowMarginalityGroups, + }); + updateReportDetails( + WeeklyFinancialReportFormatter.formatDetail({ + groupName: targetUnit.group_name, + groupTotalHours, + currentQuarter, + groupTotalRevenue, + groupTotalCogs, + marginAmount: marginality.marginAmount, + marginalityPercent: marginality.marginalityPercent, + indicator: marginality.indicator, + }), + ); + updateTotalReportedHours(groupTotalHours); + } + } + + private pushGroupByMarginality( + level: MarginalityLevel, + groupName: string, + groups: { + highMarginalityGroups: string[]; + mediumMarginalityGroups: string[]; + lowMarginalityGroups: string[]; + }, + ) { + switch (level) { + case MarginalityLevel.High: + groups.highMarginalityGroups.push(groupName); + break; + case MarginalityLevel.Medium: + groups.mediumMarginalityGroups.push(groupName); + break; + case MarginalityLevel.Low: + groups.lowMarginalityGroups.push(groupName); + break; + } + } + + private composeWeeklyReportTitle(currentDate: Date): string { + const periodStart = new Date( + currentDate.getFullYear(), + currentDate.getMonth(), + currentDate.getDate() - ((currentDate.getDay() + 6) % 7) - 7, + ) + .toISOString() + .slice(0, 10); + const periodEnd = new Date( + currentDate.getFullYear(), + currentDate.getMonth(), + currentDate.getDate() - ((currentDate.getDay() + 6) % 7) - 1, + ) + .toISOString() + .slice(0, 10); + + return `*Weekly Financial Summary for Target Units* (${periodStart} - ${periodEnd})`; + } + + private safeGetRate( + history: Employee['history'] | undefined, + date: string, + ): number { + if (!history || typeof history !== 'object' || !history.rate) return 0; + + return getRateByDate(history.rate, date) || 0; + } + + private aggregateGroupData({ + groupUnits, + employees, + projects, + }: AggregateGroupDataInput) { + let groupTotalCogs = 0; + let groupTotalRevenue = 0; + + for (const unit of groupUnits) { + const employee = employees.find((e) => e.redmine_id === unit.user_id); + const project = projects.find((p) => p.redmine_id === unit.project_id); + const date = unit.spent_on; + const employeeRate = this.safeGetRate(employee?.history, date); + const projectRate = this.safeGetRate(project?.history, date); + + groupTotalCogs += employeeRate * unit.total_hours; + groupTotalRevenue += projectRate * unit.total_hours; + } + + return { groupTotalCogs, groupTotalRevenue }; + } } From 64f49770417be429961dde9b38af33976757acf3 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Tue, 24 Jun 2025 14:28:30 +0200 Subject: [PATCH 5/6] refactor: Improve formatting in WeeklyFinancialReportFormatter - Added a spacer constant to enhance the readability of the formatted report details. - Updated the formatting of the report summary to include the spacer for better alignment of marginality groups. - Ensured consistent indentation across all report sections, improving overall presentation. These changes enhance the clarity and visual structure of the weekly financial report output. --- .../WeeklyFinancialReportFormatter.ts | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts index 4c4a4f7..debae35 100644 --- a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts @@ -22,6 +22,8 @@ export interface FormatDetailInput { indicator: string; } +const spacer = ' '.repeat(4); + export class WeeklyFinancialReportFormatter { static formatDetail = ({ groupName, @@ -34,11 +36,11 @@ export class WeeklyFinancialReportFormatter { indicator, }: FormatDetailInput) => `${indicator} *${groupName}* (${groupTotalHours}h)\n` + - `*Period*: ${currentQuarter}\n` + - `*Revenue*: ${formatCurrency(groupTotalRevenue)}\n` + - `*COGS*: ${formatCurrency(groupTotalCogs)}\n` + - `*Margin*: ${formatCurrency(marginAmount)}\n` + - `*Marginality*: ${marginalityPercent.toFixed(0)}%\n\n`; + `${spacer}*Period*: ${currentQuarter}\n` + + `${spacer}*Revenue*: ${formatCurrency(groupTotalRevenue)}\n` + + `${spacer}*COGS*: ${formatCurrency(groupTotalCogs)}\n` + + `${spacer}*Margin*: ${formatCurrency(marginAmount)}\n` + + `${spacer}*Marginality*: ${marginalityPercent.toFixed(0)}%\n\n`; static formatSummary = ({ reportTitle, @@ -48,17 +50,23 @@ export class WeeklyFinancialReportFormatter { }: formatSummaryInput) => { let summary = `${reportTitle}\n`; - summary += '________________________________\n'; - summary += `:arrowup: *Marginality is ${HIGH_MARGINALITY_THRESHOLD}% or higher*:\n`; - if (highGroups.length) summary += highGroups.join('\n') + '\n'; + if (highGroups.length) { + summary += '________________________________\n'; + summary += `:arrowup: *Marginality is ${HIGH_MARGINALITY_THRESHOLD}% or higher*:\n`; + summary += `${spacer}${highGroups.join(`\n${spacer}`)}\n`; + } - summary += '__________________________________\n'; - summary += ` :large_yellow_circle: *Marginality is between ${MEDIUM_MARGINALITY_THRESHOLD}-${HIGH_MARGINALITY_THRESHOLD}%*:\n`; - if (mediumGroups.length) summary += mediumGroups.join('\n') + '\n'; + if (mediumGroups.length) { + summary += '__________________________________\n'; + summary += ` :large_yellow_circle: *Marginality is between ${MEDIUM_MARGINALITY_THRESHOLD}-${HIGH_MARGINALITY_THRESHOLD}%*:\n`; + summary += `${spacer}${mediumGroups.join(`\n${spacer}`)}\n`; + } - summary += '__________________________________\n'; - summary += `:arrowdown: *Marginality is under ${MEDIUM_MARGINALITY_THRESHOLD}%*:\n`; - if (lowGroups.length) summary += lowGroups.join('\n') + '\n'; + if (lowGroups.length) { + summary += '__________________________________\n'; + summary += `:arrowdown: *Marginality is under ${MEDIUM_MARGINALITY_THRESHOLD}%*:\n`; + summary += `${spacer}${lowGroups.join(`\n${spacer}`)}\n`; + } summary += ' -------------------------------------------\n'; summary += 'The specific figures will be available in the thread'; From 4b2d282b105243e12a09676e862cdedd4764f1c2 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Tue, 24 Jun 2025 14:47:57 +0200 Subject: [PATCH 6/6] feat: Extend Weekly Financial Report tests with additional groups - Added test data for two new groups (Group C and Group D) in the WeeklyFinancialReportRepository tests. - Updated assertions to verify that the report summary and details include the new groups, ensuring comprehensive coverage of the report generation functionality. These changes enhance the test suite by validating the inclusion of all relevant groups in the weekly financial report output. --- .../WeeklyFinancialReportRepository.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts index 06c08c8..96b090f 100644 --- a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts @@ -36,15 +36,39 @@ describe('WeeklyFinancialReportRepository', () => { spent_on: '2024-06-01', total_hours: 5, }, + { + group_id: 3, + group_name: 'Group C', + project_id: 30, + project_name: 'Project Z', + user_id: 103, + username: 'David', + spent_on: '2024-06-01', + total_hours: 100, + }, + { + group_id: 4, + group_name: 'Group D', + project_id: 40, + project_name: 'Project W', + user_id: 104, + username: 'Eve', + spent_on: '2024-06-01', + total_hours: 10, + }, ]; const employees = [ { redmine_id: 100, history: { rate: { '2024-01-01': 100 } } }, { redmine_id: 101, history: { rate: { '2024-01-01': 200 } } }, { redmine_id: 102, history: { rate: { '2024-01-01': 300 } } }, + { redmine_id: 103, history: { rate: { '2024-01-01': 900 } } }, + { redmine_id: 104, history: { rate: { '2024-01-01': 700 } } }, ]; const projects = [ { redmine_id: 10, history: { rate: { '2024-01-01': 500 } } }, { redmine_id: 20, history: { rate: { '2024-01-01': 1000 } } }, + { redmine_id: 30, history: { rate: { '2024-01-01': 1500 } } }, + { redmine_id: 40, history: { rate: { '2024-01-01': 1300 } } }, ]; it('generates a report with summary and details', async () => { @@ -70,11 +94,15 @@ describe('WeeklyFinancialReportRepository', () => { // Group names should appear in summary expect(summary).toContain('Group A'); expect(summary).toContain('Group B'); + expect(summary).toContain('Group C'); + expect(summary).toContain('Group D'); // Check details content expect(details).toContain('Total hours'); expect(details).toContain('Group A'); expect(details).toContain('Group B'); + expect(details).toContain('Group C'); + expect(details).toContain('Group D'); expect(details).toMatch(/\*Period\*: Q\d/); expect(details).toContain('Revenue'); expect(details).toContain('COGS');