Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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

# 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
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
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;
}
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
Expand Up @@ -25,6 +25,7 @@ export interface FormatDetailInput {
effectiveRevenue: number;
effectiveMargin: number;
effectiveMarginality: number;
contractType?: string;
}

const spacer = ' '.repeat(4);
Expand All @@ -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` +
Expand Down Expand Up @@ -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}%`
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,6 +24,7 @@ interface GroupData {
effectiveMargin: number;
effectiveMarginality: number;
marginality: MarginalityResult;
contractType?: string;
}

export class WeeklyFinancialReportRepository
Expand Down Expand Up @@ -132,6 +134,7 @@ export class WeeklyFinancialReportRepository
effectiveRevenue,
effectiveMargin,
effectiveMarginality,
contractType,
} = this.aggregateGroupData({ groupUnits, employees, projects });
const marginality = MarginalityCalculator.calculate(
groupTotalRevenue,
Expand All @@ -147,6 +150,7 @@ export class WeeklyFinancialReportRepository
effectiveMargin,
effectiveMarginality,
marginality,
contractType,
};
}

Expand Down Expand Up @@ -184,6 +188,7 @@ export class WeeklyFinancialReportRepository
effectiveRevenue: group.effectiveRevenue,
effectiveMargin: group.effectiveMargin,
effectiveMarginality: group.effectiveMarginality,
contractType: group.contractType,
});
}

Expand Down Expand Up @@ -242,6 +247,7 @@ export class WeeklyFinancialReportRepository
employees,
projects,
}: AggregateGroupDataInput) {
let contractType: string | undefined;
let groupTotalCogs = 0;
let groupTotalRevenue = 0;
let effectiveRevenue = 0;
Expand All @@ -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;
Expand All @@ -273,6 +284,7 @@ export class WeeklyFinancialReportRepository
effectiveRevenue,
effectiveMargin,
effectiveMarginality,
contractType,
};
}
}
Loading