From e7143e3bb89dcfe0d0e56f3101d00d806b815624 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 27 Aug 2025 17:06:54 +0200 Subject: [PATCH 1/4] Enhance financial reporting with contract type integration - Updated `FinAppRepository` to include `contractType` in data retrieval for employees and projects. - Introduced `getContractTypeByDate` utility function to fetch contract types based on date. - Modified `WeeklyFinancialReportRepository` and formatter to incorporate contract type in report generation. - Updated tests to reflect changes in data structure and report formatting. These enhancements improve the accuracy and detail of financial reports, providing clearer insights into contract types alongside revenue metrics. --- .../sendReportToSlack.test.ts | 8 ++++++- .../services/FinApp/FinAppRepository.test.ts | 10 ++++++-- .../src/services/FinApp/FinAppRepository.ts | 10 ++++++-- .../main/src/services/FinApp/FinAppSchemas.ts | 1 + .../main/src/services/FinApp/FinAppUtils.ts | 23 ++++++++++++++++++ workers/main/src/services/FinApp/types.ts | 1 + .../WeeklyFinancialReportFormatter.ts | 8 ++++--- .../WeeklyFinancialReportRepository.test.ts | 1 - .../WeeklyFinancialReportRepository.ts | 12 ++++++++++ .../WeeklyFinancialReportSorting.test.ts | 24 +++++++++++++++---- 10 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 workers/main/src/services/FinApp/FinAppUtils.ts diff --git a/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.test.ts b/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.test.ts index 2b02cad2..7d0d7147 100644 --- a/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.test.ts +++ b/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.test.ts @@ -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', () => { diff --git a/workers/main/src/services/FinApp/FinAppRepository.test.ts b/workers/main/src/services/FinApp/FinAppRepository.test.ts index 071be91b..2520b9da 100644 --- a/workers/main/src/services/FinApp/FinAppRepository.test.ts +++ b/workers/main/src/services/FinApp/FinAppRepository.test.ts @@ -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 }, ); }); @@ -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, + }, ); }); diff --git a/workers/main/src/services/FinApp/FinAppRepository.ts b/workers/main/src/services/FinApp/FinAppRepository.ts index fc65dc5e..f1abbcbe 100644 --- a/workers/main/src/services/FinApp/FinAppRepository.ts +++ b/workers/main/src/services/FinApp/FinAppRepository.ts @@ -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(); } catch (error) { throw new FinAppRepositoryError( @@ -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(); } catch (error) { throw new FinAppRepositoryError( diff --git a/workers/main/src/services/FinApp/FinAppSchemas.ts b/workers/main/src/services/FinApp/FinAppSchemas.ts index f03d2dc1..575d0331 100644 --- a/workers/main/src/services/FinApp/FinAppSchemas.ts +++ b/workers/main/src/services/FinApp/FinAppSchemas.ts @@ -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 }, ); diff --git a/workers/main/src/services/FinApp/FinAppUtils.ts b/workers/main/src/services/FinApp/FinAppUtils.ts new file mode 100644 index 00000000..d3f27588 --- /dev/null +++ b/workers/main/src/services/FinApp/FinAppUtils.ts @@ -0,0 +1,23 @@ +export function getContractTypeByDate( + contractTypeHistory: { [date: string]: string } | undefined, + date: string, +): string | undefined { + if (!contractTypeHistory) { + return undefined; + } + + const sortedDates = Object.keys(contractTypeHistory).sort( + (a, b) => new Date(a).getTime() - new Date(b).getTime(), + ); + let lastContractType: string | undefined = undefined; + + for (const contractDate of sortedDates) { + if (contractDate <= date) { + lastContractType = contractTypeHistory[contractDate]; + } else { + break; + } + } + + return lastContractType; +} diff --git a/workers/main/src/services/FinApp/types.ts b/workers/main/src/services/FinApp/types.ts index b2cd0070..c5a03cfe 100644 --- a/workers/main/src/services/FinApp/types.ts +++ b/workers/main/src/services/FinApp/types.ts @@ -1,5 +1,6 @@ export interface History { rate: { [date: string]: number }; + contractType?: { [date: string]: string }; } export interface Employee { diff --git a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts index 57399f25..c73cb996 100644 --- a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportFormatter.ts @@ -25,6 +25,7 @@ export interface FormatDetailInput { effectiveRevenue: number; effectiveMargin: number; effectiveMarginality: number; + contractType?: string; } const spacer = ' '.repeat(4); @@ -42,9 +43,11 @@ export class WeeklyFinancialReportFormatter { effectiveRevenue, effectiveMargin, effectiveMarginality, + contractType, }: FormatDetailInput) => `*${groupName}*\n` + `${spacer}period: ${currentQuarter}\n` + + `${spacer}contract type: ${contractType || 'n/a'}\n` + `${spacer}total hours: ${groupTotalHours.toFixed(1)}\n` + `${spacer}revenue: ${formatCurrency(groupTotalRevenue)}\n` + `${spacer}COGS: ${formatCurrency(groupTotalCogs)}\n` + @@ -130,9 +133,8 @@ export class WeeklyFinancialReportFormatter { return ( '\n*Notes:*\n' + - '1. *Contract Type* is not implemented\n' + - `2. *Effective Revenue* calculated for the last ${qboConfig.effectiveRevenueMonths} months (${startDate} - ${endDate})\n` + - '3. *Dept Tech* hours are not implemented\n\n' + + `1. *Effective Revenue* calculated for the last ${qboConfig.effectiveRevenueMonths} months (${startDate} - ${endDate})\n` + + '2. *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 index 23af2fa6..5713f7c8 100644 --- a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.test.ts @@ -125,7 +125,6 @@ describe('WeeklyFinancialReportRepository', () => { 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'); diff --git a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts index f9dbd5de..1a9cdf2b 100644 --- a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportRepository.ts @@ -1,6 +1,7 @@ import { getRateByDate } from '../../common/formatUtils'; import type { TargetUnit } from '../../common/types'; import type { Employee, Project } from '../FinApp'; +import { getContractTypeByDate } from '../FinApp/FinAppUtils'; import { GroupAggregator } from './GroupAggregator'; import { AggregateGroupDataInput, @@ -23,6 +24,7 @@ interface GroupData { effectiveMargin: number; effectiveMarginality: number; marginality: MarginalityResult; + contractType?: string; } export class WeeklyFinancialReportRepository @@ -132,6 +134,7 @@ export class WeeklyFinancialReportRepository effectiveRevenue, effectiveMargin, effectiveMarginality, + contractType, } = this.aggregateGroupData({ groupUnits, employees, projects }); const marginality = MarginalityCalculator.calculate( groupTotalRevenue, @@ -147,6 +150,7 @@ export class WeeklyFinancialReportRepository effectiveMargin, effectiveMarginality, marginality, + contractType, }; } @@ -184,6 +188,7 @@ export class WeeklyFinancialReportRepository effectiveRevenue: group.effectiveRevenue, effectiveMargin: group.effectiveMargin, effectiveMarginality: group.effectiveMarginality, + contractType: group.contractType, }); } @@ -242,6 +247,7 @@ export class WeeklyFinancialReportRepository employees, projects, }: AggregateGroupDataInput) { + let contractType: string | undefined; let groupTotalCogs = 0; let groupTotalRevenue = 0; let effectiveRevenue = 0; @@ -261,6 +267,11 @@ export class WeeklyFinancialReportRepository effectiveRevenue += project.effectiveRevenue || 0; processedProjects.add(project.redmine_id); } + + contractType = getContractTypeByDate( + project?.history?.contractType, + date, + ); } const effectiveMargin = effectiveRevenue - groupTotalCogs; @@ -273,6 +284,7 @@ export class WeeklyFinancialReportRepository effectiveRevenue, effectiveMargin, effectiveMarginality, + contractType, }; } } diff --git a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportSorting.test.ts b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportSorting.test.ts index 97826a40..695620d9 100644 --- a/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportSorting.test.ts +++ b/workers/main/src/services/WeeklyFinancialReport/WeeklyFinancialReportSorting.test.ts @@ -52,10 +52,26 @@ const createLevelTestData = () => ({ { redmine_id: 103, history: { rate: { '2024-01-01': 50 } } }, ], projects: [ - { redmine_id: 10, history: { rate: { '2024-01-01': 100 } } }, // 50% marginality (Low) - { redmine_id: 20, history: { rate: { '2024-01-01': 200 } } }, // 75% marginality (High) - { redmine_id: 30, history: { rate: { '2024-01-01': 150 } } }, // 67% marginality (Medium) - { redmine_id: 40, history: { rate: { '2024-01-01': 180 } } }, // 72% marginality (High) + { + name: 'Project X', + redmine_id: 10, + history: { rate: { '2024-01-01': 100 } }, + }, // 50% marginality (Low) + { + name: 'Project Y', + redmine_id: 20, + history: { rate: { '2024-01-01': 200 } }, + }, // 75% marginality (High) + { + name: 'Project Z', + redmine_id: 30, + history: { rate: { '2024-01-01': 150 } }, + }, // 67% marginality (Medium) + { + name: 'Project W', + redmine_id: 40, + history: { rate: { '2024-01-01': 180 } }, + }, // 72% marginality (High) ], }); From dbe678c64ec8b23e7bb578f963644f353d7f06a7 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 27 Aug 2025 17:26:29 +0200 Subject: [PATCH 2/4] fix: Improve date comparison in getContractTypeByDate function - Replace string date comparison with timestamp comparison - Add validation to handle invalid dates gracefully - Filter out invalid dates from contract type history - Ensure reliable contract type determination using numeric timestamps --- .../main/src/services/FinApp/FinAppUtils.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/workers/main/src/services/FinApp/FinAppUtils.ts b/workers/main/src/services/FinApp/FinAppUtils.ts index d3f27588..11a9c182 100644 --- a/workers/main/src/services/FinApp/FinAppUtils.ts +++ b/workers/main/src/services/FinApp/FinAppUtils.ts @@ -6,17 +6,18 @@ export function getContractTypeByDate( return undefined; } - const sortedDates = Object.keys(contractTypeHistory).sort( - (a, b) => new Date(a).getTime() - new Date(b).getTime(), - ); - let lastContractType: string | undefined = 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 contractDate of sortedDates) { - if (contractDate <= date) { - lastContractType = contractTypeHistory[contractDate]; - } else { - break; - } + for (const { d, ts } of sorted) { + if (ts <= targetTs) lastContractType = contractTypeHistory[d]; + else break; } return lastContractType; From f27b1e6c7c35f6baabd1bfc24ecec255b35e1756 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 27 Aug 2025 17:59:45 +0200 Subject: [PATCH 3/4] Add unit tests for getContractTypeByDate function - Introduced comprehensive tests for the getContractTypeByDate utility function. - Covered various scenarios including handling of undefined and empty inputs, invalid dates, and correct contract type retrieval based on date. - Ensured robustness by testing edge cases and filtering out invalid entries in contractTypeHistory. These tests enhance the reliability of the contract type determination logic and improve overall code quality. --- .../src/services/FinApp/FinAppUtils.test.ts | 145 ++++++++++++++++++ .../main/src/services/FinApp/FinAppUtils.ts | 3 +- 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 workers/main/src/services/FinApp/FinAppUtils.test.ts diff --git a/workers/main/src/services/FinApp/FinAppUtils.test.ts b/workers/main/src/services/FinApp/FinAppUtils.test.ts new file mode 100644 index 00000000..497e9d76 --- /dev/null +++ b/workers/main/src/services/FinApp/FinAppUtils.test.ts @@ -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(); + }); +}); diff --git a/workers/main/src/services/FinApp/FinAppUtils.ts b/workers/main/src/services/FinApp/FinAppUtils.ts index 11a9c182..977574d6 100644 --- a/workers/main/src/services/FinApp/FinAppUtils.ts +++ b/workers/main/src/services/FinApp/FinAppUtils.ts @@ -7,8 +7,9 @@ export function getContractTypeByDate( } 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)) From b0d69d6daef7bceaf9c69889d4908ad987e10fac Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 3 Sep 2025 11:32:44 +0200 Subject: [PATCH 4/4] Update Dockerfile.n8n to use n8n version 1.109.2 and install additional packages - Upgraded base image from n8nio/n8n:1.89.2 to n8nio/n8n:1.109.2. - Added installation of showdown and slackify-markdown packages with specified versions. - Combined package installations into a single layer for efficiency. - Configured external modules allowlist for Code/Function nodes. These changes enhance the n8n environment by ensuring compatibility with newer package versions and improving the installation process. --- Dockerfile.n8n | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Dockerfile.n8n b/Dockerfile.n8n index f60e55a5..74ac5b40 100644 --- a/Dockerfile.n8n +++ b/Dockerfile.n8n @@ -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 -# 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 + +# Configure external modules allowlist used by Code/Function nodes +ENV NODE_FUNCTION_ALLOW_EXTERNAL="showdown,slackify-markdown" # Create app directory WORKDIR /home/node