Skip to content
3 changes: 3 additions & 0 deletions workers/main/src/configs/weeklyFinancialReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
@@ -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 };
}
}
Original file line number Diff line number Diff line change
@@ -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[];
}
Original file line number Diff line number Diff line change
@@ -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:';
}
}
}
Original file line number Diff line number Diff line change
@@ -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}%`;
}
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading