Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e7143e3
Enhance financial reporting with contract type integration
anatolyshipitz Aug 27, 2025
dbe678c
fix: Improve date comparison in getContractTypeByDate function
anatolyshipitz Aug 27, 2025
f27b1e6
Add unit tests for getContractTypeByDate function
anatolyshipitz Aug 27, 2025
b0d69d6
Update Dockerfile.n8n to use n8n version 1.109.2 and install addition…
anatolyshipitz Sep 3, 2025
8096856
Add weekly financial report workflow and enhance marginality calculat…
anatolyshipitz Sep 3, 2025
3748744
Refactor date handling in financial queries and clean up code
anatolyshipitz Sep 4, 2025
a0b30f0
Add docker-compose.override.yml and update package dependencies
anatolyshipitz Sep 5, 2025
9931950
Resolve merge conflict in Dockerfile.n8n - use versioned git package
anatolyshipitz Sep 5, 2025
bd8a9d6
Refactor MarginalityResult and EffectiveMarginalityResult interfaces …
anatolyshipitz Sep 5, 2025
b0f6e76
Refactor date handling and contract type resolution in financial repo…
anatolyshipitz Sep 5, 2025
4201e4e
Remove docker-compose.override.yml file to streamline configuration a…
anatolyshipitz Sep 5, 2025
658fbc1
Refactor weekly report workflow initiation in launchWeeklyReport.ts
anatolyshipitz Sep 5, 2025
ad33549
Implement WeeklyFinancialReportCalculations class for improved financ…
anatolyshipitz Sep 5, 2025
4eea60f
Remove unused EffectiveMarginalityCalculator import from WeeklyFinanc…
anatolyshipitz Sep 5, 2025
b966818
Enhance tests for handleRunError function by adding process.exit mocking
anatolyshipitz Sep 5, 2025
595485c
Update WeeklyFinancialReportFormatter to improve notes formatting and…
anatolyshipitz Sep 5, 2025
623c9c9
Add project_hours to TargetUnit and update related calculations
anatolyshipitz Sep 21, 2025
84fda2d
Merge branch 'main' into feature/add-contract-type
anatolyshipitz Sep 21, 2025
e42cb38
Add project_hours to test data in WeeklyFinancialReport and TargetUni…
anatolyshipitz Sep 21, 2025
5fee636
Merge branch 'feature/add-contract-type' of github.com:speedandfuncti…
anatolyshipitz Sep 21, 2025
24bf804
Refactor test data in WeeklyFinancialReportSorting tests
anatolyshipitz Sep 21, 2025
ac509e6
Refactor sorting tests in WeeklyFinancialReportSorting
anatolyshipitz Sep 21, 2025
1b1fb88
Update TargetUnit interfaces and repository for optional project_hour…
anatolyshipitz Sep 21, 2025
24833df
Fix revenue calculation in WeeklyFinancialReportCalculations to handl…
anatolyshipitz Sep 24, 2025
9b8c5b1
Refactor EffectiveMarginalityCalculator and MarginalityCalculator for…
anatolyshipitz Oct 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions Dockerfile.n8n
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
FROM n8nio/n8n:1.89.2
FROM n8nio/n8n:1.109.2

# Define build arguments
ARG NODE_ENV=production
ARG N8N_PORT=5678
ARG SHOWDOWN_VERSION=^2.1.0
ARG SLACKIFY_MARKDOWN_VERSION=^4.5.0
Comment thread
anatolyshipitz marked this conversation as resolved.

# Install git for backup script
# Install git for backup script and other packages + install external packages in one layer
USER root
RUN apk add --no-cache git=2.47.3-r0
RUN set -eux; \
apk add --no-cache git && \
npm install -g --no-audit --no-fund --ignore-scripts \
--legacy-peer-deps --no-workspaces \
--unsafe-perm \
showdown@${SHOWDOWN_VERSION} \
slackify-markdown@${SLACKIFY_MARKDOWN_VERSION} && \
npm cache clean --force
Comment thread
anatolyshipitz marked this conversation as resolved.

# Configure external modules allowlist used by Code/Function nodes
ENV NODE_FUNCTION_ALLOW_EXTERNAL="showdown,slackify-markdown"

# Create app directory
WORKDIR /home/node
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ const mockTargetUnits: TargetUnit[] = [
];
const mockFinancialsAppData: FinancialsAppData = {
employees: [{ redmine_id: 3, history: { rate: { '2024-06-01': 100 } } }],
projects: [{ redmine_id: 2, history: { rate: { '2024-06-01': 200 } } }],
projects: [
{
name: 'Test Project',
redmine_id: 2,
history: { rate: { '2024-06-01': 200 } },
},
],
};

describe('sendReportToSlack', () => {
Expand Down
1 change: 1 addition & 0 deletions workers/main/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface TargetUnit {
total_hours: number;
rate?: number;
projectRate?: number;
effectiveMarginalityIndicator?: string;
}

export type GroupName = (typeof GroupNameEnum)[keyof typeof GroupNameEnum];
4 changes: 4 additions & 0 deletions workers/main/src/configs/weeklyFinancialReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ export const REPORT_FILTER_FIELD_ID = 253;

// ID of the custom field in Redmine used to link issue to Billable project
export const RELATED_PROJECT_FIELD_ID = 20;

export const HIGH_EFFECTIVE_MARGINALITY_THRESHOLD = 45;
export const MEDIUM_EFFECTIVE_MARGINALITY_THRESHOLD = 25;
export const LOW_EFFECTIVE_MARGINALITY_THRESHOLD = 15;
26 changes: 26 additions & 0 deletions workers/main/src/launchWeeklyReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Client, Connection } from '@temporalio/client';

import { temporalConfig } from './configs/temporal';
import { workerConfig } from './configs/worker';
import { weeklyFinancialReportsWorkflow } from './workflows';

async function run() {
const connection = await Connection.connect(temporalConfig);
const client = new Client({ connection });

const handle = await client.workflow.start(weeklyFinancialReportsWorkflow, {
...workerConfig,
workflowId: 'weekly-financial-report-' + Date.now(),
});

try {
await handle.result();
} catch (err) {
console.error('Workflow failed:', err);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

run().catch((err) => {
console.error(err);
process.exit(1);
});
10 changes: 8 additions & 2 deletions workers/main/src/services/FinApp/FinAppRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe('FinAppRepository', () => {
expect(result).toEqual(mockEmployees);
expect(vi.mocked(EmployeeModel).find).toHaveBeenCalledWith(
{ redmine_id: { $in: [1] } },
{ 'redmine_id': 1, 'history.rate': 1 },
{ 'redmine_id': 1, 'history.rate': 1, 'history.contractType': 1 },
);
});

Expand All @@ -120,7 +120,13 @@ describe('FinAppRepository', () => {
expect(result).toEqual(mockProjects);
expect(vi.mocked(ProjectModel).find).toHaveBeenCalledWith(
{ redmine_id: { $in: [550] } },
{ 'name': 1, 'redmine_id': 1, 'quick_books_id': 1, 'history.rate': 1 },
{
'name': 1,
'redmine_id': 1,
'quick_books_id': 1,
'history.rate': 1,
'history.contractType': 1,
},
);
});

Expand Down
10 changes: 8 additions & 2 deletions workers/main/src/services/FinApp/FinAppRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export class FinAppRepository implements IFinAppRepository {
try {
return await EmployeeModel.find(
{ redmine_id: { $in: redmineIds } },
{ 'redmine_id': 1, 'history.rate': 1 },
{ 'redmine_id': 1, 'history.rate': 1, 'history.contractType': 1 },
).lean<Employee[]>();
} catch (error) {
throw new FinAppRepositoryError(
Expand All @@ -21,7 +21,13 @@ export class FinAppRepository implements IFinAppRepository {
try {
return await ProjectModel.find(
{ redmine_id: { $in: redmineIds } },
{ 'name': 1, 'redmine_id': 1, 'quick_books_id': 1, 'history.rate': 1 },
{
'name': 1,
'redmine_id': 1,
'quick_books_id': 1,
'history.rate': 1,
'history.contractType': 1,
},
).lean<Project[]>();
} catch (error) {
throw new FinAppRepositoryError(
Expand Down
1 change: 1 addition & 0 deletions workers/main/src/services/FinApp/FinAppSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Employee, Project } from './types';
export const historySchema = new mongoose.Schema(
{
rate: { type: Map, of: Number },
contractType: { type: Map, of: String },
},
{ _id: false },
);
Expand Down
145 changes: 145 additions & 0 deletions workers/main/src/services/FinApp/FinAppUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';

import { getContractTypeByDate } from './FinAppUtils';

describe('getContractTypeByDate', () => {
it('should return undefined when contractTypeHistory is undefined', () => {
const result = getContractTypeByDate(undefined, '2024-01-01');

expect(result).toBeUndefined();
});

it('should return undefined when contractTypeHistory is empty', () => {
const result = getContractTypeByDate({}, '2024-01-01');

expect(result).toBeUndefined();
});

it('should return undefined when input date is invalid', () => {
const contractTypeHistory = {
'2024-01-01': 'Full-time',
'2024-06-01': 'Part-time',
};
const result = getContractTypeByDate(contractTypeHistory, 'invalid-date');

expect(result).toBeUndefined();
});

it('should return the correct contract type for a date that matches exactly', () => {
const contractTypeHistory = {
'2024-01-01': 'Full-time',
'2024-06-01': 'Part-time',
};
const result = getContractTypeByDate(contractTypeHistory, '2024-01-01');

expect(result).toBe('Full-time');
});

it('should return the most recent contract type for a date between entries', () => {
const contractTypeHistory = {
'2024-01-01': 'Full-time',
'2024-06-01': 'Part-time',
};
const result = getContractTypeByDate(contractTypeHistory, '2024-03-15');

expect(result).toBe('Full-time');
});

it('should return the latest contract type for a date after all entries', () => {
const contractTypeHistory = {
'2024-01-01': 'Full-time',
'2024-06-01': 'Part-time',
};
const result = getContractTypeByDate(contractTypeHistory, '2024-12-01');

expect(result).toBe('Part-time');
});

it('should handle dates in different formats correctly', () => {
const contractTypeHistory = {
'2024-01-01': 'Full-time',
'2024-06-01': 'Part-time',
};
const result = getContractTypeByDate(
contractTypeHistory,
'2024-01-01T00:00:00.000Z',
);

expect(result).toBe('Full-time');
});

it('should filter out invalid dates from contractTypeHistory', () => {
const contractTypeHistory = {
'2024-01-01': 'Full-time',
'definitely-not-a-date': 'Should-be-ignored',
'2024-06-01': 'Part-time',
};
const result = getContractTypeByDate(contractTypeHistory, '2024-03-15');

expect(result).toBe('Full-time');
});

it('should handle multiple invalid dates in contractTypeHistory', () => {
const contractTypeHistory = {
'definitely-not-a-date': 'Should-be-ignored-1',
'2024-01-01': 'Full-time',
'invalid-date-string': 'Should-be-ignored-2',
'2024-06-01': 'Part-time',
};
const result = getContractTypeByDate(contractTypeHistory, '2024-12-01');

expect(result).toBe('Part-time');
});

it('should return undefined when all dates in contractTypeHistory are invalid', () => {
const contractTypeHistory = {
'definitely-not-a-date': 'Should-be-ignored-1',
'invalid-date-string': 'Should-be-ignored-2',
};
const result = getContractTypeByDate(contractTypeHistory, '2024-01-01');

expect(result).toBeUndefined();
});

it('should handle single entry correctly', () => {
const contractTypeHistory = {
'2024-01-01': 'Full-time',
};
const result = getContractTypeByDate(contractTypeHistory, '2024-06-01');

expect(result).toBe('Full-time');
});

it('should handle date before first entry correctly', () => {
const contractTypeHistory = {
'2024-06-01': 'Part-time',
'2024-12-01': 'Contract',
};
const result = getContractTypeByDate(contractTypeHistory, '2024-01-01');

expect(result).toBeUndefined();
});

it('should handle ISO date strings correctly', () => {
const contractTypeHistory = {
'2024-01-01T00:00:00.000Z': 'Full-time',
'2024-06-01T00:00:00.000Z': 'Part-time',
};
const result = getContractTypeByDate(
contractTypeHistory,
'2024-03-15T00:00:00.000Z',
);

expect(result).toBe('Full-time');
});

it('should handle edge case with only invalid dates and valid input date', () => {
const contractTypeHistory = {
'definitely-not-a-date': 'Invalid-entry-1',
'invalid-date-string': 'Invalid-entry-2',
};
const result = getContractTypeByDate(contractTypeHistory, '2024-01-01');

expect(result).toBeUndefined();
});
});
25 changes: 25 additions & 0 deletions workers/main/src/services/FinApp/FinAppUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export function getContractTypeByDate(
contractTypeHistory: { [date: string]: string } | undefined,
date: string,
): string | undefined {
if (!contractTypeHistory) {
return undefined;
}

const targetTs = Date.parse(date);

if (Number.isNaN(targetTs)) return undefined;

const sorted = Object.keys(contractTypeHistory)
.map((d) => ({ d, ts: Date.parse(d) }))
.filter(({ ts }) => !Number.isNaN(ts))
.sort((a, b) => a.ts - b.ts);
let lastContractType: string | undefined;

for (const { d, ts } of sorted) {
if (ts <= targetTs) lastContractType = contractTypeHistory[d];
else break;
}

return lastContractType;
}
Comment thread
anatolyshipitz marked this conversation as resolved.
1 change: 1 addition & 0 deletions workers/main/src/services/FinApp/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface History {
rate: { [date: string]: number };
contractType?: { [date: string]: string };
}

export interface Employee {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import {
HIGH_EFFECTIVE_MARGINALITY_THRESHOLD,
HIGH_MARGINALITY_THRESHOLD,
LOW_EFFECTIVE_MARGINALITY_THRESHOLD,
MEDIUM_EFFECTIVE_MARGINALITY_THRESHOLD,
MEDIUM_MARGINALITY_THRESHOLD,
} from '../../configs/weeklyFinancialReport';

Expand All @@ -9,13 +12,27 @@ export enum MarginalityLevel {
Low = 'low',
}

export enum EffectiveMarginalityLevel {
High = 'high',
Medium = 'medium',
Low = 'low',
VeryLow = 'veryLow',
}

export interface MarginalityResult {
marginAmount: number;
marginalityPercent: number;
indicator: string;
level: MarginalityLevel;
}

export interface EffectiveMarginalityResult {
marginAmount: number;
marginalityPercent: number;
indicator: string;
level: EffectiveMarginalityLevel;
}

export class MarginalityCalculator {
Comment thread
anatolyshipitz marked this conversation as resolved.
static calculate(revenue: number, cogs: number): MarginalityResult {
const marginAmount = revenue - cogs;
Expand Down Expand Up @@ -45,3 +62,39 @@ export class MarginalityCalculator {
}
}
}

export class EffectiveMarginalityCalculator {
static calculate(revenue: number, cogs: number): EffectiveMarginalityResult {
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): EffectiveMarginalityLevel {
if (percent >= HIGH_EFFECTIVE_MARGINALITY_THRESHOLD)
return EffectiveMarginalityLevel.High;
if (percent >= MEDIUM_EFFECTIVE_MARGINALITY_THRESHOLD)
return EffectiveMarginalityLevel.Medium;
if (percent >= LOW_EFFECTIVE_MARGINALITY_THRESHOLD)
return EffectiveMarginalityLevel.Low;

return EffectiveMarginalityLevel.VeryLow;
}

static getIndicator(level: EffectiveMarginalityLevel): string {
switch (level) {
case EffectiveMarginalityLevel.High:
return `:large_green_circle:`;
case EffectiveMarginalityLevel.Medium:
return `:large_yellow_circle:`;
case EffectiveMarginalityLevel.Low:
return `:red_circle:`;
case EffectiveMarginalityLevel.VeryLow:
default:
return `:no_entry:`;
}
}
}
Loading