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 new file mode 100644 index 0000000..c1b329b --- /dev/null +++ b/workers/main/src/services/WeeklyFinancialReport/IWeeklyFinancialReportRepository.ts @@ -0,0 +1,20 @@ +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 }>; +} + +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..debae35 --- /dev/null +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts @@ -0,0 +1,84 @@ +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; +} + +const spacer = ' '.repeat(4); + +export class WeeklyFinancialReportFormatter { + static formatDetail = ({ + groupName, + groupTotalHours, + currentQuarter, + groupTotalRevenue, + groupTotalCogs, + marginAmount, + marginalityPercent, + indicator, + }: FormatDetailInput) => + `${indicator} *${groupName}* (${groupTotalHours}h)\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, + highGroups, + mediumGroups, + lowGroups, + }: formatSummaryInput) => { + let summary = `${reportTitle}\n`; + + if (highGroups.length) { + summary += '________________________________\n'; + summary += `:arrowup: *Marginality is ${HIGH_MARGINALITY_THRESHOLD}% or higher*:\n`; + summary += `${spacer}${highGroups.join(`\n${spacer}`)}\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`; + } + + 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'; + + 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.test.ts b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts new file mode 100644 index 0000000..96b090f --- /dev/null +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts @@ -0,0 +1,136 @@ +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, + }, + { + 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 () => { + 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'); + 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'); + 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..88fdf3f --- /dev/null +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts @@ -0,0 +1,202 @@ +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'; +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 + implements IWeeklyFinancialReportRepository +{ + async generateReport({ + targetUnits, + employees, + projects, + }: GenerateReportInput) { + const currentDate = new Date(); + const reportTitle = this.composeWeeklyReportTitle(currentDate); + const processedGroupIds = new Set(); + let reportDetails = ''; + let totalReportedHours = 0; + const currentQuarter = `Q${Math.floor(currentDate.getMonth() / 3) + 1}`; + const highGroups: string[] = []; + const mediumGroups: string[] = []; + const lowGroups: string[] = []; + + for (const targetUnit of targetUnits) { + this.processTargetUnit({ + targetUnit, + targetUnits, + employees, + projects, + processedGroupIds, + currentQuarter, + highMarginalityGroups: highGroups, + mediumMarginalityGroups: mediumGroups, + lowMarginalityGroups: lowGroups, + updateReportDetails: (detail) => (reportDetails += detail), + updateTotalReportedHours: (hours) => (totalReportedHours += hours), + }); + } + + reportDetails += + WeeklyFinancialReportFormatter.formatFooter(totalReportedHours); + + 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 }; + } +} 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';