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 311062cbd482591822d36c0a91e28faf250cabcc Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 11 Jun 2025 20:56:11 +0200 Subject: [PATCH 4/6] Add sendReportToSlack activity - Introduced `sendReportToSlack` function to handle sending financial reports to Slack, integrating data retrieval and message posting. - Created unit tests for `sendReportToSlack` to validate successful report sending and error handling scenarios, ensuring robustness. - Updated `weeklyFinancialReports/index.ts` to export the new function. These changes enhance the application's ability to communicate financial reports via Slack, improving reporting capabilities and error management. --- .../weeklyFinancialReports/index.ts | 1 + .../sendReportToSlack.test.ts | 141 ++++++++++++++++++ .../sendReportToSlack.ts | 36 +++++ workers/main/src/services/FinApp/types.ts | 5 + 4 files changed, 183 insertions(+) create mode 100644 workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.test.ts create mode 100644 workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.ts diff --git a/workers/main/src/activities/weeklyFinancialReports/index.ts b/workers/main/src/activities/weeklyFinancialReports/index.ts index 8a800f7..7ba3212 100644 --- a/workers/main/src/activities/weeklyFinancialReports/index.ts +++ b/workers/main/src/activities/weeklyFinancialReports/index.ts @@ -1,2 +1,3 @@ export * from './fetchFinancialAppData'; export * from './getTargetUnits'; +export * from './sendReportToSlack'; diff --git a/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.test.ts b/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.test.ts new file mode 100644 index 0000000..2b02cad --- /dev/null +++ b/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; + +import { AppError } from '../../common/errors'; +import { readJsonFile } from '../../common/fileUtils'; +import { TargetUnit } from '../../common/types'; +import { FinancialsAppData } from '../../services/FinApp'; +import { SlackService } from '../../services/SlackService'; +import { WeeklyFinancialReportRepository } from '../../services/WeeklyFinancialReport'; +import { sendReportToSlack } from './sendReportToSlack'; + +vi.mock('../../common/fileUtils', () => ({ + readJsonFile: vi.fn(), +})); +vi.mock('../../services/WeeklyFinancialReport', () => ({ + WeeklyFinancialReportRepository: vi.fn(), +})); +vi.mock('../../services/SlackService', () => ({ + SlackService: vi.fn(), +})); + +const mockTargetUnits: TargetUnit[] = [ + { + group_id: 1, + group_name: 'Group', + project_id: 2, + project_name: 'Project', + user_id: 3, + username: 'User', + spent_on: '2024-06-01', + total_hours: 8, + }, +]; +const mockFinancialsAppData: FinancialsAppData = { + employees: [{ redmine_id: 3, history: { rate: { '2024-06-01': 100 } } }], + projects: [{ redmine_id: 2, history: { rate: { '2024-06-01': 200 } } }], +}; + +describe('sendReportToSlack', () => { + let readJsonFileMock: Mock; + let generateReportMock: Mock; + let postMessageMock: Mock; + + function tryMockReset(obj: unknown) { + if ( + typeof obj === 'function' && + 'mockReset' in obj && + typeof (obj as { mockReset: unknown }).mockReset === 'function' + ) { + (obj as { mockReset: () => void }).mockReset(); + } + } + + beforeEach(() => { + readJsonFileMock = vi.mocked(readJsonFile); + generateReportMock = vi.fn(); + postMessageMock = vi.fn(); + + tryMockReset(WeeklyFinancialReportRepository); + tryMockReset(SlackService); + }); + + it('sends report to Slack and returns success message', async () => { + readJsonFileMock + .mockResolvedValueOnce(mockTargetUnits) + .mockResolvedValueOnce(mockFinancialsAppData); + generateReportMock.mockReturnValue({ + details: 'details', + summary: 'summary', + }); + (WeeklyFinancialReportRepository as unknown as Mock).mockImplementation( + () => ({ + generateReport: generateReportMock, + }), + ); + postMessageMock + .mockResolvedValueOnce({ ts: '123' }) + .mockResolvedValueOnce({}); + (SlackService as unknown as Mock).mockImplementation(() => ({ + postMessage: postMessageMock, + })); + + const result = await sendReportToSlack('target.json', 'finapp.json'); + + expect(result).toBe('Report sent to Slack'); + expect(readJsonFileMock).toHaveBeenCalledTimes(2); + expect(generateReportMock).toHaveBeenCalled(); + expect(postMessageMock).toHaveBeenCalledTimes(2); + expect(postMessageMock).toHaveBeenCalledWith('summary'); + expect(postMessageMock).toHaveBeenCalledWith('details', '123'); + }); + + it('throws AppError if readJsonFile fails', async () => { + readJsonFileMock.mockRejectedValueOnce(new Error('fail')); + await expect( + sendReportToSlack('target.json', 'finapp.json'), + ).rejects.toThrow(AppError); + await expect( + sendReportToSlack('target.json', 'finapp.json'), + ).rejects.toThrow('Failed to send report to Slack'); + }); + + it('throws AppError if generateReport fails', async () => { + readJsonFileMock + .mockResolvedValueOnce(mockTargetUnits) + .mockResolvedValueOnce(mockFinancialsAppData); + generateReportMock.mockRejectedValueOnce(new Error('fail-gen')); + (WeeklyFinancialReportRepository as unknown as Mock).mockImplementation( + () => ({ + generateReport: generateReportMock, + }), + ); + (SlackService as unknown as Mock).mockImplementation(() => ({ + postMessage: postMessageMock, + })); + await expect( + sendReportToSlack('target.json', 'finapp.json'), + ).rejects.toThrow(AppError); + }); + + it('throws AppError if postMessage fails', async () => { + readJsonFileMock + .mockResolvedValueOnce(mockTargetUnits) + .mockResolvedValueOnce(mockFinancialsAppData); + generateReportMock.mockReturnValue({ + details: 'details', + summary: 'summary', + }); + (WeeklyFinancialReportRepository as unknown as Mock).mockImplementation( + () => ({ + generateReport: generateReportMock, + }), + ); + postMessageMock.mockRejectedValueOnce(new Error('fail-post')); + (SlackService as unknown as Mock).mockImplementation(() => ({ + postMessage: postMessageMock, + })); + await expect( + sendReportToSlack('target.json', 'finapp.json'), + ).rejects.toThrow(AppError); + }); +}); diff --git a/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.ts b/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.ts new file mode 100644 index 0000000..e889186 --- /dev/null +++ b/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.ts @@ -0,0 +1,36 @@ +import { AppError } from '../../common/errors'; +import { readJsonFile } from '../../common/fileUtils'; +import { TargetUnit } from '../../common/types'; +import { FinancialsAppData } from '../../services/FinApp'; +import { SlackService } from '../../services/SlackService'; +import { WeeklyFinancialReportRepository } from '../../services/WeeklyFinancialReport'; + +export const sendReportToSlack = async ( + targetUnitsFileLink: string, + financialAppDataFileLink: string, +): Promise => { + try { + const [targetUnits, { employees, projects }] = await Promise.all([ + readJsonFile(targetUnitsFileLink), + readJsonFile(financialAppDataFileLink), + ]); + const weeklyFinancialReportRepository = + new WeeklyFinancialReportRepository(); + const { details, summary } = + await weeklyFinancialReportRepository.generateReport({ + targetUnits, + employees, + projects, + }); + const slackService = new SlackService(); + const message = await slackService.postMessage(summary); + + await slackService.postMessage(details, message.ts); + + return 'Report sent to Slack'; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + throw new AppError('Failed to send report to Slack', message); + } +}; diff --git a/workers/main/src/services/FinApp/types.ts b/workers/main/src/services/FinApp/types.ts index 67021a1..b37ee10 100644 --- a/workers/main/src/services/FinApp/types.ts +++ b/workers/main/src/services/FinApp/types.ts @@ -23,3 +23,8 @@ export interface Project { history?: History; [key: string]: unknown; } + +export interface FinancialsAppData { + projects: Project[]; + employees: Employee[]; +} From 4cd5888df37ebe9af395e355a698e9cc693e1fdc Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 2 Jul 2025 11:22:52 +0200 Subject: [PATCH 5/6] Refactor WeeklyFinancialReportRepository for improved report generation - Introduced a new method `processTargetUnit` to encapsulate the logic for processing each target unit, enhancing code readability and maintainability. - Added `pushGroupByMarginality` method to categorize groups based on their marginality level, streamlining the report generation process. - Refactored the report title generation into a private method `composeWeeklyReportTitle` for better organization. - Updated the report details and summary formatting using `WeeklyFinancialReportFormatter`, ensuring consistent output structure. These changes enhance the clarity and efficiency of the weekly financial report generation process, making it easier to manage and extend in the future. --- .../WeeklyFinancialReportRepository.ts | 278 +++++++++++------- 1 file changed, 172 insertions(+), 106 deletions(-) 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 6639af9fc1596393d539446aa24dbdce845405c4 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 2 Jul 2025 13:13:59 +0200 Subject: [PATCH 6/6] Enhance weekly financial reports workflow to include Slack reporting - Updated the `weeklyFinancialReportsWorkflow` to integrate the `sendReportToSlack` function, allowing for the sending of financial reports directly to Slack. - Modified the workflow to return the Slack link instead of the financial data file link, improving the reporting process. - Added necessary mocks for `sendReportToSlack` in the corresponding test file to ensure proper testing of the new functionality. These changes enhance the application's reporting capabilities by facilitating direct communication of financial reports via Slack, streamlining the workflow process. --- .../weeklyFinancialReports.workflow.test.ts | 15 ++++++++++++++- .../weeklyFinancialReports.workflow.ts | 11 +++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/workers/main/src/workflows/weeklyFinancialReports/weeklyFinancialReports.workflow.test.ts b/workers/main/src/workflows/weeklyFinancialReports/weeklyFinancialReports.workflow.test.ts index 3855722..149f577 100644 --- a/workers/main/src/workflows/weeklyFinancialReports/weeklyFinancialReports.workflow.test.ts +++ b/workers/main/src/workflows/weeklyFinancialReports/weeklyFinancialReports.workflow.test.ts @@ -8,14 +8,17 @@ import { weeklyFinancialReportsWorkflow } from './weeklyFinancialReports.workflo vi.mock('@temporalio/workflow', () => { const getTargetUnitsMock = vi.fn(); const fetchFinancialAppDataMock = vi.fn(); + const sendReportToSlackMock = vi.fn(); return { proxyActivities: () => ({ getTargetUnits: getTargetUnitsMock, fetchFinancialAppData: fetchFinancialAppDataMock, + sendReportToSlack: sendReportToSlackMock, }), __getTargetUnitsMock: () => getTargetUnitsMock, __getFetchFinancialAppDataMock: () => fetchFinancialAppDataMock, + __getSendReportToSlackMock: () => sendReportToSlackMock, }; }); @@ -23,6 +26,7 @@ describe('weeklyFinancialReportsWorkflow', () => { type WorkflowModuleWithMock = typeof workflowModule & { __getTargetUnitsMock: () => ReturnType; __getFetchFinancialAppDataMock: () => ReturnType; + __getSendReportToSlackMock: () => ReturnType; }; const getTargetUnitsMock = ( workflowModule as WorkflowModuleWithMock @@ -30,10 +34,14 @@ describe('weeklyFinancialReportsWorkflow', () => { const fetchFinancialAppDataMock = ( workflowModule as WorkflowModuleWithMock ).__getFetchFinancialAppDataMock(); + const sendReportToSlackMock = ( + workflowModule as WorkflowModuleWithMock + ).__getSendReportToSlackMock(); beforeEach(() => { getTargetUnitsMock.mockReset(); fetchFinancialAppDataMock.mockReset(); + sendReportToSlackMock.mockReset(); }); it('throws AppError for invalid group name', async () => { @@ -72,12 +80,17 @@ describe('weeklyFinancialReportsWorkflow', () => { fetchFinancialAppDataMock.mockResolvedValueOnce({ fileLink: 'result.json', }); + sendReportToSlackMock.mockResolvedValueOnce('slack-link.json'); const result = await weeklyFinancialReportsWorkflow( GroupNameEnum.SD_REPORT, ); - expect(result).toBe('result.json'); + expect(result).toBe('slack-link.json'); expect(getTargetUnitsMock).toHaveBeenCalledWith(GroupNameEnum.SD_REPORT); expect(fetchFinancialAppDataMock).toHaveBeenCalledWith('file.json'); + expect(sendReportToSlackMock).toHaveBeenCalledWith( + 'file.json', + 'result.json', + ); }); }); diff --git a/workers/main/src/workflows/weeklyFinancialReports/weeklyFinancialReports.workflow.ts b/workers/main/src/workflows/weeklyFinancialReports/weeklyFinancialReports.workflow.ts index 1e4a5aa..531b9ec 100644 --- a/workers/main/src/workflows/weeklyFinancialReports/weeklyFinancialReports.workflow.ts +++ b/workers/main/src/workflows/weeklyFinancialReports/weeklyFinancialReports.workflow.ts @@ -5,11 +5,10 @@ import { AppError } from '../../common/errors'; import { GroupName } from '../../common/types'; import { GroupNameEnum } from '../../configs/weeklyFinancialReport'; -const { getTargetUnits, fetchFinancialAppData } = proxyActivities< - typeof activities ->({ - startToCloseTimeout: '10 minutes', -}); +const { getTargetUnits, fetchFinancialAppData, sendReportToSlack } = + proxyActivities({ + startToCloseTimeout: '10 minutes', + }); export async function weeklyFinancialReportsWorkflow( groupName: GroupName, @@ -23,5 +22,5 @@ export async function weeklyFinancialReportsWorkflow( const targetUnits = await getTargetUnits(groupName); const finData = await fetchFinancialAppData(targetUnits.fileLink); - return finData.fileLink; + return await sendReportToSlack(targetUnits.fileLink, finData.fileLink); }