diff --git a/Dockerfile.n8n b/Dockerfile.n8n index f60e55a..74ac5b4 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 diff --git a/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.test.ts b/workers/main/src/activities/weeklyFinancialReports/sendReportToSlack.test.ts index 2b02cad..7d0d714 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 071be91..2520b9d 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 fc65dc5..f1abbcb 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 f03d2dc..575d033 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.test.ts b/workers/main/src/services/FinApp/FinAppUtils.test.ts new file mode 100644 index 0000000..497e9d7 --- /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 new file mode 100644 index 0000000..977574d --- /dev/null +++ b/workers/main/src/services/FinApp/FinAppUtils.ts @@ -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; +} diff --git a/workers/main/src/services/FinApp/types.ts b/workers/main/src/services/FinApp/types.ts index b2cd007..c5a03cf 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 57399f2..c73cb99 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 23af2fa..5713f7c 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 f9dbd5d..1a9cdf2 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 97826a4..695620d 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) ], });