1
0

feat: wip clickhouse reports

This commit is contained in:
Ahmed Bouhuolia
2026-04-27 14:52:49 +02:00
parent 52c97f1401
commit 5187342f90
22 changed files with 18318 additions and 13 deletions
+8
View File
@@ -106,3 +106,11 @@ STRIPE_PAYMENT_CLIENT_ID=
STRIPE_PAYMENT_WEBHOOKS_SECRET= STRIPE_PAYMENT_WEBHOOKS_SECRET=
# Replace example.com with the correct domain # Replace example.com with the correct domain
STRIPE_PAYMENT_REDIRECT_URL=https://example.com/preferences/payment-methods/stripe/callback STRIPE_PAYMENT_REDIRECT_URL=https://example.com/preferences/payment-methods/stripe/callback
# ClickHouse
CLICKHOUSE_ENABLED=false
CLICKHOUSE_HOST=localhost
CLICKHOUSE_PORT=8123
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=
CLICKHOUSE_DATABASE=bigcapital_analytics
+26
View File
@@ -33,9 +33,11 @@ services:
links: links:
- mysql - mysql
- redis - redis
- clickhouse
depends_on: depends_on:
- mysql - mysql
- redis - redis
- clickhouse
restart: on-failure restart: on-failure
networks: networks:
- bigcapital_network - bigcapital_network
@@ -127,6 +129,14 @@ services:
- STRIPE_PAYMENT_WEBHOOKS_SECRET=${STRIPE_PAYMENT_WEBHOOKS_SECRET} - STRIPE_PAYMENT_WEBHOOKS_SECRET=${STRIPE_PAYMENT_WEBHOOKS_SECRET}
- STRIPE_PAYMENT_REDIRECT_URL=${STRIPE_PAYMENT_REDIRECT_URL} - STRIPE_PAYMENT_REDIRECT_URL=${STRIPE_PAYMENT_REDIRECT_URL}
# ClickHouse
- CLICKHOUSE_ENABLED=${CLICKHOUSE_ENABLED:-true}
- CLICKHOUSE_HOST=${CLICKHOUSE_HOST:-clickhouse}
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT:-8123}
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-default}
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD}
- CLICKHOUSE_DATABASE=${CLICKHOUSE_DATABASE:-bigcapital_analytics}
database_migration: database_migration:
container_name: bigcapital-database-migration container_name: bigcapital-database-migration
build: build:
@@ -175,6 +185,18 @@ services:
networks: networks:
- bigcapital_network - bigcapital_network
clickhouse:
container_name: bigcapital-clickhouse
image: clickhouse/clickhouse-server:24.8
restart: on-failure
expose:
- '8123'
- '9000'
volumes:
- clickhouse:/var/lib/clickhouse
networks:
- bigcapital_network
gotenberg: gotenberg:
image: gotenberg/gotenberg:7 image: gotenberg/gotenberg:7
expose: expose:
@@ -192,6 +214,10 @@ volumes:
name: bigcapital_prod_redis name: bigcapital_prod_redis
driver: local driver: local
clickhouse:
name: bigcapital_prod_clickhouse
driver: local
# Networks # Networks
networks: networks:
bigcapital_network: bigcapital_network:
+18
View File
@@ -42,6 +42,20 @@ services:
ports: ports:
- '9000:3000' - '9000:3000'
clickhouse:
image: clickhouse/clickhouse-server:24.8
container_name: bigcapital-clickhouse
ports:
- '8123:8123'
- '9001:9000'
volumes:
- clickhouse:/var/lib/clickhouse
environment:
CLICKHOUSE_DB: bigcapital_analytics
deploy:
restart_policy:
condition: unless-stopped
# Volumes # Volumes
volumes: volumes:
mysql: mysql:
@@ -51,3 +65,7 @@ volumes:
redis: redis:
name: bigcapital_dev_redis name: bigcapital_dev_redis
driver: local driver: local
clickhouse:
name: bigcapital_dev_clickhouse
driver: local
+1 -1
View File
@@ -1,4 +1,4 @@
FROM mariadb:10.2 FROM mariadb:11.4
USER root USER root
ADD my.cnf /etc/mysql/conf.d/my.cnf ADD my.cnf /etc/mysql/conf.d/my.cnf
+7 -1
View File
@@ -1,3 +1,9 @@
GRANT ALL PRIVILEGES ON *.* TO '{MYSQL_USER}'@'%' IDENTIFIED BY '{MYSQL_PASSWORD}' WITH GRANT OPTION; GRANT ALL PRIVILEGES ON *.* TO '{MYSQL_USER}'@'%' IDENTIFIED BY '{MYSQL_PASSWORD}' WITH GRANT OPTION;
FLUSH PRIVILEGES; -- Create ClickHouse CDC replication user
CREATE USER IF NOT EXISTS 'clickpipes_user'@'%' IDENTIFIED BY 'clickpipes_password';
GRANT SELECT ON *.* TO 'clickpipes_user'@'%';
GRANT REPLICATION CLIENT ON *.* TO 'clickpipes_user'@'%';
GRANT REPLICATION SLAVE ON *.* TO 'clickpipes_user'@'%';
FLUSH PRIVILEGES;
+16 -1
View File
@@ -1,2 +1,17 @@
[mysqld] [mysqld]
bind-address = 0.0.0.0 bind-address = 0.0.0.0
# Binary logging for ClickHouse CDC (MaterializedMySQL)
server_id = 1
log_bin = ON
binlog_format = ROW
binlog_row_image = FULL
binlog_row_metadata = FULL
binlog_expire_logs_seconds = 86400
# Required for replication from replica
log_slave_updates = ON
# Character set
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
+9 -1
View File
@@ -95,4 +95,12 @@ STRIPE_PAYMENT_SECRET_KEY=
STRIPE_PAYMENT_PUBLISHABLE_KEY= STRIPE_PAYMENT_PUBLISHABLE_KEY=
STRIPE_PAYMENT_CLIENT_ID= STRIPE_PAYMENT_CLIENT_ID=
STRIPE_PAYMENT_WEBHOOKS_SECRET= STRIPE_PAYMENT_WEBHOOKS_SECRET=
STRIPE_PAYMENT_REDIRECT_URL= STRIPE_PAYMENT_REDIRECT_URL=
# ClickHouse
CLICKHOUSE_ENABLED=false
CLICKHOUSE_HOST=localhost
CLICKHOUSE_PORT=8123
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=
CLICKHOUSE_DATABASE=bigcapital_analytics
+17449
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -38,13 +38,14 @@
"@bigcapital/pdf-templates": "workspace:*", "@bigcapital/pdf-templates": "workspace:*",
"@bigcapital/sdk-ts": "workspace:*", "@bigcapital/sdk-ts": "workspace:*",
"@bigcapital/utils": "workspace:*", "@bigcapital/utils": "workspace:*",
"@casl/ability": "^5.4.3",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
"@liaoliaots/nestjs-redis": "^10.0.0",
"@nest-lab/throttler-storage-redis": "^1.1.0",
"@bull-board/api": "^5.22.0", "@bull-board/api": "^5.22.0",
"@bull-board/express": "^5.22.0", "@bull-board/express": "^5.22.0",
"@bull-board/nestjs": "^5.22.0", "@bull-board/nestjs": "^5.22.0",
"@casl/ability": "^5.4.3",
"@clickhouse/client": "^1.18.3",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
"@liaoliaots/nestjs-redis": "^10.0.0",
"@nest-lab/throttler-storage-redis": "^1.1.0",
"@nestjs/bull": "^10.2.1", "@nestjs/bull": "^10.2.1",
"@nestjs/bullmq": "^10.2.2", "@nestjs/bullmq": "^10.2.2",
"@nestjs/cache-manager": "^2.2.2", "@nestjs/cache-manager": "^2.2.2",
@@ -0,0 +1,10 @@
import { registerAs } from '@nestjs/config';
export default registerAs('clickhouse', () => ({
enabled: process.env.CLICKHOUSE_ENABLED === 'true',
host: process.env.CLICKHOUSE_HOST || 'localhost',
port: parseInt(process.env.CLICKHOUSE_PORT || '8123', 10),
user: process.env.CLICKHOUSE_USER || 'default',
password: process.env.CLICKHOUSE_PASSWORD || '',
database: process.env.CLICKHOUSE_DATABASE || 'bigcapital_analytics',
}));
@@ -20,6 +20,7 @@ import cloud from './cloud';
import redis from './redis'; import redis from './redis';
import queue from './queue'; import queue from './queue';
import bullBoard from './bull-board'; import bullBoard from './bull-board';
import clickhouse from './clickhouse-database';
export const config = [ export const config = [
app, app,
@@ -44,4 +45,5 @@ export const config = [
redis, redis,
queue, queue,
bullBoard, bullBoard,
clickhouse,
]; ];
@@ -85,6 +85,7 @@ import { TenantDBManagerModule } from '../TenantDBManager/TenantDBManager.module
import { PaymentServicesModule } from '../PaymentServices/PaymentServices.module'; import { PaymentServicesModule } from '../PaymentServices/PaymentServices.module';
import { AuthModule } from '../Auth/Auth.module'; import { AuthModule } from '../Auth/Auth.module';
import { TenancyModule } from '../Tenancy/Tenancy.module'; import { TenancyModule } from '../Tenancy/Tenancy.module';
import { ClickHouseModule } from '../ClickHouse/ClickHouse.module';
import { LoopsModule } from '../Loops/Loops.module'; import { LoopsModule } from '../Loops/Loops.module';
import { AttachmentsModule } from '../Attachments/Attachment.module'; import { AttachmentsModule } from '../Attachments/Attachment.module';
import { S3Module } from '../S3/S3.module'; import { S3Module } from '../S3/S3.module';
@@ -119,6 +120,7 @@ import { AppThrottleModule } from './AppThrottle.module';
}), }),
SystemDatabaseModule, SystemDatabaseModule,
SystemModelsModule, SystemModelsModule,
ClickHouseModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
I18nModule.forRootAsync({ I18nModule.forRootAsync({
useFactory: () => ({ useFactory: () => ({
@@ -0,0 +1,2 @@
export const CLICKHOUSE_CLIENT = Symbol('CLICKHOUSE_CLIENT');
export const CLICKHOUSE_CONNECTION_OPTIONS = Symbol('CLICKHOUSE_CONNECTION_OPTIONS');
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { ClickHouseService } from './ClickHouse.service';
import { ClickHouseMigrationService } from './ClickHouseMigration.service';
@Global()
@Module({
providers: [ClickHouseService, ClickHouseMigrationService],
exports: [ClickHouseService, ClickHouseMigrationService],
})
export class ClickHouseModule {}
@@ -0,0 +1,99 @@
import { Inject, Injectable, Scope } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, ClickHouseClient } from '@clickhouse/client';
import * as LRUCache from 'lru-cache';
import { ClsService } from 'nestjs-cls';
@Injectable({ scope: Scope.DEFAULT })
export class ClickHouseService {
private readonly cache: LRUCache<string, ClickHouseClient>;
constructor(
private readonly configService: ConfigService,
private readonly cls: ClsService,
) {
this.cache = new LRUCache({ max: 100 });
}
/**
* Retrieves or creates a ClickHouse client.
* If database is not specified, uses the tenant-specific database.
*/
getClient(database?: string): ClickHouseClient {
const organizationId = this.cls.get('organizationId');
const tenantId = organizationId ? String(organizationId) : 'system';
const dbName = database || this.getTenantDatabaseName(tenantId);
const cacheKey = `${tenantId}:${dbName}`;
const cachedClient = this.cache.get(cacheKey);
if (cachedClient) {
return cachedClient;
}
const host = this.configService.get<string>('clickhouse.host', 'localhost');
const port = this.configService.get<number>('clickhouse.port', 8123);
const user = this.configService.get<string>('clickhouse.user', 'default');
const password = this.configService.get<string>('clickhouse.password', '');
const client = createClient({
host: `http://${host}:${port}`,
username: user,
password,
database: dbName,
request_timeout: 30000,
max_open_connections: 10,
});
this.cache.set(cacheKey, client);
return client;
}
/**
* Returns the default ClickHouse database name for a tenant.
*/
getTenantDatabaseName(tenantId: string): string {
const prefix = this.configService.get<string>(
'tenantDatabase.dbNamePrefix',
'bigcapital_tenant_',
);
return `${prefix}${tenantId}`;
}
/**
* Executes a query and returns the result.
* Uses the tenant database by default.
*/
async query<T = any>(
query: string,
params?: Record<string, unknown>,
database?: string,
): Promise<T[]> {
const client = this.getClient(database);
const resultSet = await client.query({
query,
query_params: params,
format: 'JSONEachRow',
});
return await resultSet.json<T>();
}
/**
* Executes a command (DDL, INSERT, etc.).
*/
async command(query: string, database?: string): Promise<void> {
const client = this.getClient(database);
await client.command({ query });
}
/**
* Inserts data into a table.
*/
async insert<T = any>(table: string, values: T[], database?: string): Promise<void> {
const client = this.getClient(database);
await client.insert({
table,
values,
format: 'JSONEachRow',
});
}
}
@@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ClickHouseService } from './ClickHouse.service';
@Injectable()
export class ClickHouseMigrationService {
constructor(
private readonly clickHouse: ClickHouseService,
private readonly configService: ConfigService,
) {}
/**
* Bootstraps MaterializedMySQL replication for a tenant database.
*/
async bootstrapTenantReplication(tenantDbName: string): Promise<void> {
const enabled = this.configService.get<boolean>('clickhouse.enabled', false);
if (!enabled) {
return;
}
const mariadbHost = this.configService.get<string>('tenantDatabase.host', 'mariadb');
const mariadbPort = this.configService.get<number>('tenantDatabase.port', 3306);
const mariadbUser = this.configService.get<string>('tenantDatabase.user', 'clickpipes_user');
const mariadbPassword = this.configService.get<string>('tenantDatabase.password', 'clickpipes_password');
// MaterializedMySQL requires the database to not exist before creation
const checkQuery = `SELECT count() FROM system.databases WHERE name = {tenantDb:String}`;
const exists = await this.clickHouse.query<{ count: number }>(checkQuery, {
tenantDb: tenantDbName,
});
if (exists[0]?.count > 0) {
return;
}
const createQuery = `
CREATE DATABASE IF NOT EXISTS ${tenantDbName}
ENGINE = MaterializedMySQL('${mariadbHost}:${mariadbPort}', '${tenantDbName}', '${mariadbUser}', '${mariadbPassword}')
SETTINGS
materialized_mysql_tables_list = 'accounts_transactions',
materialized_mysql_wait_for replication,
materialized_mysql_snapshot_mode = 'standard'
`;
await this.clickHouse.command(createQuery);
}
/**
* Creates pre-aggregated balance tables for a tenant.
* These tables are populated from MaterializedMySQL replicated data.
*/
async createPreAggregatedTables(tenantDbName: string): Promise<void> {
const enabled = this.configService.get<boolean>('clickhouse.enabled', false);
if (!enabled) {
return;
}
// SummingMergeTree for daily account balances
const createBalancesTable = `
CREATE TABLE IF NOT EXISTS ${tenantDbName}.account_balances_daily (
account_id UInt32,
date Date,
credit_sum Decimal(15, 5),
debit_sum Decimal(15, 5),
branch_id Nullable(UInt32)
) ENGINE = SummingMergeTree()
ORDER BY (account_id, date, branch_id)
`;
await this.clickHouse.command(createBalancesTable);
// Materialized view to auto-populate from replicated transactions
const createMv = `
CREATE MATERIALIZED VIEW IF NOT EXISTS ${tenantDbName}.mv_account_balances_daily
TO ${tenantDbName}.account_balances_daily
AS SELECT
account_id,
date,
sum(credit) AS credit_sum,
sum(debit) AS debit_sum,
branch_id
FROM ${tenantDbName}.accounts_transactions
GROUP BY account_id, date, branch_id
`;
await this.clickHouse.command(createMv);
}
}
@@ -9,13 +9,18 @@ import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { FinancialSheetCommonModule } from '../../common/FinancialSheetCommon.module'; import { FinancialSheetCommonModule } from '../../common/FinancialSheetCommon.module';
import { BalanceSheetStatementController } from './BalanceSheet.controller'; import { BalanceSheetStatementController } from './BalanceSheet.controller';
import { BalanceSheetRepository } from './BalanceSheetRepository'; import { BalanceSheetRepository } from './BalanceSheetRepository';
import { BalanceSheetClickHouseRepository } from './BalanceSheetClickHouseRepository';
import { BalanceSheetRepositoryFactory } from './BalanceSheetRepositoryFactory';
import { AccountsModule } from '@/modules/Accounts/Accounts.module'; import { AccountsModule } from '@/modules/Accounts/Accounts.module';
import { ClickHouseModule } from '@/modules/ClickHouse/ClickHouse.module';
@Module({ @Module({
imports: [FinancialSheetCommonModule, AccountsModule], imports: [FinancialSheetCommonModule, AccountsModule, ClickHouseModule],
controllers: [BalanceSheetStatementController], controllers: [BalanceSheetStatementController],
providers: [ providers: [
BalanceSheetRepository, BalanceSheetRepository,
BalanceSheetClickHouseRepository,
BalanceSheetRepositoryFactory,
BalanceSheetInjectable, BalanceSheetInjectable,
BalanceSheetTableInjectable, BalanceSheetTableInjectable,
BalanceSheetExportInjectable, BalanceSheetExportInjectable,
@@ -0,0 +1,438 @@
// @ts-nocheck
import { Inject, Injectable, Scope } from '@nestjs/common';
import * as R from 'ramda';
import { isEmpty } from 'lodash';
import { ModelObject } from 'objection';
import { IBalanceSheetQuery, IAccountTransactionsGroupBy } from './BalanceSheet.types';
import { BalanceSheetQuery } from './BalanceSheetQuery';
import { ILedger } from '@/modules/Ledger/types/Ledger.types';
import { Ledger } from '@/modules/Ledger/Ledger';
import { transformToMapBy } from '@/utils/transform-to-map-by';
import { Account } from '@/modules/Accounts/models/Account.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { ClickHouseService } from '@/modules/ClickHouse/ClickHouse.service';
import { ACCOUNT_PARENT_TYPE } from '@/constants/accounts';
import { IBalanceSheetRepository } from './IBalanceSheetRepository';
/**
* Maps ClickHouse query result row to a ledger-compatible transaction object.
*/
interface CHTransactionRow {
account_id: number;
credit: string | number;
debit: string | number;
date?: string;
account_normal?: string;
account_type?: string;
account_parent_type?: string;
}
@Injectable({ scope: Scope.TRANSIENT })
export class BalanceSheetClickHouseRepository implements IBalanceSheetRepository {
@Inject(Account.name)
public readonly accountModel: TenantModelProxy<typeof Account>;
@Inject(ClickHouseService)
private readonly clickHouse: ClickHouseService;
public query: BalanceSheetQuery;
public accounts: ModelObject<Account>[] = [];
public accountsGraph: any;
public accountsByType: Map<string, ModelObject<Account>[]> = new Map();
public accountsByParentType: Map<string, ModelObject<Account>[]> = new Map();
public totalAccountsLedger: ILedger;
public incomeLedger: ILedger;
public expensesLedger: ILedger;
public periodsAccountsLedger: ILedger;
public periodsOpeningAccountLedger: ILedger;
public PYTotalAccountsLedger: ILedger;
public PYPeriodsAccountsLedger: ILedger;
public PYPeriodsOpeningAccountLedger: ILedger;
public PPTotalAccountsLedger: ILedger;
public PPPeriodsAccountsLedger: ILedger;
public PPPeriodsOpeningAccountLedger: ILedger;
public incomePeriodsAccountsLedger: ILedger;
public incomePeriodsOpeningAccountsLedger: ILedger;
public expensesPeriodsAccountsLedger: ILedger;
public expensesOpeningAccountLedger: ILedger;
public incomePPAccountsLedger: ILedger;
public expensePPAccountsLedger: ILedger;
public incomePPPeriodsAccountsLedger: ILedger;
public incomePPPeriodsOpeningAccountLedger: ILedger;
public expensePPPeriodsAccountsLedger: ILedger;
public expensePPPeriodsOpeningAccountLedger: ILedger;
public incomePYTotalAccountsLedger: ILedger;
public expensePYTotalAccountsLedger: ILedger;
public incomePYPeriodsAccountsLedger: ILedger;
public incomePYPeriodsOpeningAccountLedger: ILedger;
public expensePYPeriodsAccountsLedger: ILedger;
public expensePYPeriodsOpeningAccountLedger: ILedger;
public transactionsGroupType: IAccountTransactionsGroupBy = IAccountTransactionsGroupBy.Month;
// Internal account normal map built from MySQL accounts
private accountNormalMap: Map<number, string> = new Map();
public setQuery(query: IBalanceSheetQuery) {
this.query = new BalanceSheetQuery(query);
this.transactionsGroupType = this.getGroupByFromDisplayColumnsBy(
this.query.displayColumnsBy,
);
}
public asyncInitialize = async (query: IBalanceSheetQuery) => {
this.setQuery(query);
await this.initAccounts();
await this.initAccountsGraph();
await this.initAccountsTotalLedger();
if (this.query.isDatePeriodsColumnsType()) {
await this.initTotalDatePeriods();
}
if (this.query.isPreviousYearActive()) {
await this.initTotalPreviousYear();
}
if (
this.query.isPreviousYearActive() &&
this.query.isDatePeriodsColumnsType()
) {
await this.initPeriodsPreviousYear();
}
if (this.query.isPreviousPeriodActive()) {
await this.initTotalPreviousPeriod();
}
if (
this.query.isPreviousPeriodActive() &&
this.query.isDatePeriodsColumnsType()
) {
await this.initPeriodsPreviousPeriod();
}
await this.asyncInitializeNetIncome();
};
// ----------------------------
// # Accounts
// ----------------------------
public initAccounts = async () => {
const accounts = await this.getAccounts();
this.accounts = accounts;
this.accountsByType = transformToMapBy(accounts, 'accountType');
this.accountsByParentType = transformToMapBy(accounts, 'accountParentType');
// Build account normal map for ledger entries
accounts.forEach((acc) => {
this.accountNormalMap.set(acc.id, acc.accountNormal);
});
};
public initAccountsGraph = async () => {
this.accountsGraph = this.accountModel().toDependencyGraph(this.accounts);
};
public getAccounts = () => {
return this.accountModel().query();
};
// ----------------------------
// # Closing Total
// ----------------------------
public initAccountsTotalLedger = async (): Promise<void> => {
const totalByAccount = await this.closingAccountsTotal(this.query.toDate);
this.totalAccountsLedger = Ledger.fromTransactions(totalByAccount);
};
// ----------------------------
// # Date periods.
// ----------------------------
public initTotalDatePeriods = async (): Promise<void> => {
const [periodsByAccount, periodsOpeningByAccount] = await Promise.all([
this.accountsDatePeriods(
this.query.fromDate,
this.query.toDate,
this.transactionsGroupType,
),
this.closingAccountsTotal(this.query.fromDate),
]);
this.periodsAccountsLedger = Ledger.fromTransactions(periodsByAccount);
this.periodsOpeningAccountLedger = Ledger.fromTransactions(periodsOpeningByAccount);
};
// ----------------------------
// # Previous Year (PY).
// ----------------------------
public initTotalPreviousYear = async (): Promise<void> => {
const PYTotalsByAccounts = await this.closingAccountsTotal(this.query.PYToDate);
this.PYTotalAccountsLedger = Ledger.fromTransactions(PYTotalsByAccounts);
};
public initPeriodsPreviousYear = async (): Promise<void> => {
const [PYPeriodsBYAccounts, periodsOpeningByAccount] = await Promise.all([
this.accountsDatePeriods(
this.query.PYFromDate,
this.query.PYToDate,
this.transactionsGroupType,
),
this.closingAccountsTotal(this.query.PYFromDate),
]);
this.PYPeriodsAccountsLedger = Ledger.fromTransactions(PYPeriodsBYAccounts);
this.PYPeriodsOpeningAccountLedger = Ledger.fromTransactions(periodsOpeningByAccount);
};
// ----------------------------
// # Previous Year (PP).
// ----------------------------
public initTotalPreviousPeriod = async (): Promise<void> => {
const PPTotalsByAccounts = await this.closingAccountsTotal(this.query.PPToDate);
this.PPTotalAccountsLedger = Ledger.fromTransactions(PPTotalsByAccounts);
};
public initPeriodsPreviousPeriod = async (): Promise<void> => {
const [PPPeriodsBYAccounts, periodsOpeningByAccount] = await Promise.all([
this.accountsDatePeriods(
this.query.PPFromDate,
this.query.PPToDate,
this.transactionsGroupType,
),
this.closingAccountsTotal(this.query.PPFromDate),
]);
this.PPPeriodsAccountsLedger = Ledger.fromTransactions(PPPeriodsBYAccounts);
this.PPPeriodsOpeningAccountLedger = Ledger.fromTransactions(periodsOpeningByAccount);
};
// ----------------------------
// # ClickHouse Queries
// ----------------------------
/**
* Retrieve closing accounts total up to a date from ClickHouse.
*/
public closingAccountsTotal = async (toDate: Date | string) => {
const dateStr = this.formatDate(toDate);
const branchFilter = this.buildBranchFilter();
const query = `
SELECT
account_id,
sum(credit) AS credit,
sum(debit) AS debit
FROM accounts_transactions
WHERE date <= {toDate:Date}
${branchFilter}
GROUP BY account_id
`;
const rows = await this.clickHouse.query<CHTransactionRow>(query, { toDate: dateStr });
return this.mapToLedgerTransactions(rows);
};
/**
* Retrieve account transactions grouped by date periods from ClickHouse.
*/
public accountsDatePeriods = async (
fromDate: Date,
toDate: Date,
datePeriodsType: string,
) => {
const fromStr = this.formatDate(fromDate);
const toStr = this.formatDate(toDate);
const groupFormat = this.getClickHouseDateFormat(datePeriodsType);
const branchFilter = this.buildBranchFilter();
const query = `
SELECT
account_id,
formatDateTime(date, '${groupFormat}') AS date,
sum(credit) AS credit,
sum(debit) AS debit
FROM accounts_transactions
WHERE date >= {fromDate:Date}
AND date <= {toDate:Date}
${branchFilter}
GROUP BY account_id, formatDateTime(date, '${groupFormat}')
ORDER BY account_id, date
`;
const rows = await this.clickHouse.query<CHTransactionRow>(query, {
fromDate: fromStr,
toDate: toStr,
});
return this.mapToLedgerTransactions(rows);
};
// ----------------------------
// # Net Income (mirrored from BalanceSheetRepositoryNetIncome)
// ----------------------------
public asyncInitializeNetIncome = async () => {
this.initIncomeAccounts();
this.initExpenseAccounts();
this.initIncomeTotalLedger();
this.initExpensesTotalLedger();
if (this.query.isDatePeriodsColumnsType()) {
this.initNetIncomeDatePeriods();
}
if (this.query.isPreviousYearActive()) {
this.initNetIncomePreviousYear();
}
if (this.query.isPreviousPeriodActive()) {
this.initNetIncomePreviousPeriod();
}
if (
this.query.isPreviousYearActive() &&
this.query.isDatePeriodsColumnsType()
) {
this.initNetIncomePeriodsPreviewYear();
}
if (
this.query.isPreviousPeriodActive() &&
this.query.isDatePeriodsColumnsType()
) {
this.initNetIncomePeriodsPreviousPeriod();
}
};
public incomeAccounts: ModelObject<Account>[] = [];
public incomeAccountsIds: number[] = [];
public expenseAccounts: ModelObject<Account>[] = [];
public expenseAccountsIds: number[] = [];
public initIncomeAccounts = () => {
const incomeAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.INCOME) || [];
const incomeAccountsIds = incomeAccounts.map((a) => a.id);
this.incomeAccounts = incomeAccounts;
this.incomeAccountsIds = incomeAccountsIds;
};
public initExpenseAccounts = () => {
const expenseAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.EXPENSE) || [];
const expenseAccountsIds = expenseAccounts.map((a) => a.id);
this.expenseAccounts = expenseAccounts;
this.expenseAccountsIds = expenseAccountsIds;
};
public initIncomeTotalLedger = (): void => {
const incomeAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.INCOME) || [];
const incomeAccountsIds = incomeAccounts.map((a) => a.id);
this.incomeLedger = this.totalAccountsLedger.whereAccountsIds(incomeAccountsIds);
};
public initExpensesTotalLedger = (): void => {
const expenseAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.EXPENSE) || [];
const expenseAccountsIds = expenseAccounts.map((a) => a.id);
this.expensesLedger = this.totalAccountsLedger.whereAccountsIds(expenseAccountsIds);
};
public initNetIncomeDatePeriods = () => {
const incomeAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.INCOME) || [];
const incomeAccountsIds = incomeAccounts.map((a) => a.id);
const expenseAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.EXPENSE) || [];
const expenseAccountsIds = expenseAccounts.map((a) => a.id);
this.incomePeriodsAccountsLedger = this.periodsAccountsLedger.whereAccountsIds(incomeAccountsIds);
this.incomePeriodsOpeningAccountsLedger = this.periodsOpeningAccountLedger.whereAccountsIds(incomeAccountsIds);
this.expensesPeriodsAccountsLedger = this.periodsAccountsLedger.whereAccountsIds(expenseAccountsIds);
this.expensesOpeningAccountLedger = this.periodsOpeningAccountLedger.whereAccountsIds(expenseAccountsIds);
};
public initNetIncomePreviousPeriod = () => {
const incomeAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.INCOME) || [];
const incomeAccountsIds = incomeAccounts.map((a) => a.id);
const expenseAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.EXPENSE) || [];
const expenseAccountsIds = expenseAccounts.map((a) => a.id);
this.incomePPAccountsLedger = this.PPTotalAccountsLedger.whereAccountsIds(incomeAccountsIds);
this.expensePPAccountsLedger = this.PPTotalAccountsLedger.whereAccountsIds(expenseAccountsIds);
};
public initNetIncomePeriodsPreviousPeriod = () => {
const incomeAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.INCOME) || [];
const incomeAccountsIds = incomeAccounts.map((a) => a.id);
const expenseAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.EXPENSE) || [];
const expenseAccountsIds = expenseAccounts.map((a) => a.id);
this.incomePPPeriodsAccountsLedger = this.PPPeriodsAccountsLedger.whereAccountsIds(incomeAccountsIds);
this.incomePPPeriodsOpeningAccountLedger = this.PPPeriodsOpeningAccountLedger.whereAccountsIds(incomeAccountsIds);
this.expensePPPeriodsAccountsLedger = this.PPPeriodsAccountsLedger.whereAccountsIds(expenseAccountsIds);
this.expensePPPeriodsOpeningAccountLedger = this.PPPeriodsOpeningAccountLedger.whereAccountsIds(expenseAccountsIds);
};
public initNetIncomePreviousYear = () => {
const incomeAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.INCOME) || [];
const incomeAccountsIds = incomeAccounts.map((a) => a.id);
const expenseAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.EXPENSE) || [];
const expenseAccountsIds = expenseAccounts.map((a) => a.id);
this.incomePYTotalAccountsLedger = this.PYTotalAccountsLedger.whereAccountsIds(incomeAccountsIds);
this.expensePYTotalAccountsLedger = this.PYTotalAccountsLedger.whereAccountsIds(expenseAccountsIds);
};
public initNetIncomePeriodsPreviewYear = () => {
const incomeAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.INCOME) || [];
const incomeAccountsIds = incomeAccounts.map((a) => a.id);
const expenseAccounts = this.accountsByParentType.get(ACCOUNT_PARENT_TYPE.EXPENSE) || [];
const expenseAccountsIds = expenseAccounts.map((a) => a.id);
this.incomePYPeriodsAccountsLedger = this.PYPeriodsAccountsLedger.whereAccountsIds(incomeAccountsIds);
this.incomePYPeriodsOpeningAccountLedger = this.PYPeriodsOpeningAccountLedger.whereAccountsIds(incomeAccountsIds);
this.expensePYPeriodsAccountsLedger = this.PYPeriodsAccountsLedger.whereAccountsIds(expenseAccountsIds);
this.expensePYPeriodsOpeningAccountLedger = this.PYPeriodsOpeningAccountLedger.whereAccountsIds(expenseAccountsIds);
};
// ----------------------------
// # Helpers
// ----------------------------
private mapToLedgerTransactions(rows: CHTransactionRow[]): any[] {
return rows.map((row) => ({
accountId: row.account_id,
credit: Number(row.credit) || 0,
debit: Number(row.debit) || 0,
date: row.date,
account: {
accountNormal: this.accountNormalMap.get(row.account_id) || 'debit',
},
}));
}
private formatDate(date: Date | string): string {
const d = new Date(date);
return d.toISOString().split('T')[0];
}
private getClickHouseDateFormat(groupType: string): string {
const formats: Record<string, string> = {
day: '%Y-%m-%d',
month: '%Y-%m',
year: '%Y',
};
return formats[groupType] || '%Y-%m';
}
private buildBranchFilter(): string {
if (!isEmpty(this.query?.branchesIds)) {
const ids = this.query.branchesIds.map((id: number) => String(id)).join(',');
return `AND branch_id IN (${ids})`;
}
return '';
}
private getGroupByFromDisplayColumnsBy(columnsBy: string): IAccountTransactionsGroupBy {
const mapping: Record<string, IAccountTransactionsGroupBy> = {
week: IAccountTransactionsGroupBy.Day,
quarter: IAccountTransactionsGroupBy.Month,
year: IAccountTransactionsGroupBy.Year,
month: IAccountTransactionsGroupBy.Month,
day: IAccountTransactionsGroupBy.Day,
};
return mapping[columnsBy] || IAccountTransactionsGroupBy.Month;
}
}
@@ -3,7 +3,7 @@ import {
IBalanceSheetDOO, IBalanceSheetDOO,
IBalanceSheetQuery, IBalanceSheetQuery,
} from './BalanceSheet.types'; } from './BalanceSheet.types';
import { BalanceSheetRepository } from './BalanceSheetRepository'; import { BalanceSheetRepositoryFactory } from './BalanceSheetRepositoryFactory';
import { BalanceSheetMetaInjectable } from './BalanceSheetMeta'; import { BalanceSheetMetaInjectable } from './BalanceSheetMeta';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -20,7 +20,8 @@ export class BalanceSheetInjectable {
private readonly eventPublisher: EventEmitter2, private readonly eventPublisher: EventEmitter2,
private readonly tenancyContext: TenancyContext, private readonly tenancyContext: TenancyContext,
private readonly i18n: I18nService, private readonly i18n: I18nService,
private readonly balanceSheetRepository: BalanceSheetRepository, @Inject(BalanceSheetRepositoryFactory)
private readonly repositoryFactory: BalanceSheetRepositoryFactory,
) {} ) {}
/** /**
@@ -38,7 +39,8 @@ export class BalanceSheetInjectable {
const tenantMetadata = await this.tenancyContext.getTenantMetadata(true); const tenantMetadata = await this.tenancyContext.getTenantMetadata(true);
// Loads all resources. // Loads all resources.
await this.balanceSheetRepository.asyncInitialize(filter); const repository = this.repositoryFactory.getRepository();
await repository.asyncInitialize(filter);
// Balance sheet meta first to get date format. // Balance sheet meta first to get date format.
const meta = await this.balanceSheetMeta.meta(filter); const meta = await this.balanceSheetMeta.meta(filter);
@@ -46,7 +48,7 @@ export class BalanceSheetInjectable {
// Balance sheet report instance. // Balance sheet report instance.
const balanceSheetInstanace = new BalanceSheet( const balanceSheetInstanace = new BalanceSheet(
filter, filter,
this.balanceSheetRepository, repository,
this.i18n, this.i18n,
{ baseCurrency: tenantMetadata.baseCurrency, dateFormat: meta.dateFormat }, { baseCurrency: tenantMetadata.baseCurrency, dateFormat: meta.dateFormat },
); );
@@ -57,6 +57,11 @@ export class BalanceSheetRepository extends R.compose(
*/ */
public accountsByType: any; public accountsByType: any;
/**
*
*/
public accountsByParentType: any;
/** /**
* PY from date. * PY from date.
* @param {Date} * @param {Date}
@@ -97,6 +102,18 @@ export class BalanceSheetRepository extends R.compose(
*/ */
public expensesLedger: Ledger; public expensesLedger: Ledger;
/**
* Income accounts.
*/
public incomeAccounts: any;
public incomeAccountsIds: number[];
/**
* Expense accounts.
*/
public expenseAccounts: any;
public expenseAccountsIds: number[];
/** /**
* Transactions group type. * Transactions group type.
* @param {IAccountTransactionsGroupBy} * @param {IAccountTransactionsGroupBy}
@@ -0,0 +1,27 @@
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IBalanceSheetRepository } from './IBalanceSheetRepository';
import { BalanceSheetRepository } from './BalanceSheetRepository';
import { BalanceSheetClickHouseRepository } from './BalanceSheetClickHouseRepository';
@Injectable()
export class BalanceSheetRepositoryFactory {
constructor(
@Inject(BalanceSheetRepository)
private readonly mysqlRepository: BalanceSheetRepository,
@Inject(BalanceSheetClickHouseRepository)
private readonly clickHouseRepository: BalanceSheetClickHouseRepository,
private readonly configService: ConfigService,
) {}
/**
* Returns the appropriate balance sheet repository based on configuration.
*/
getRepository(): IBalanceSheetRepository {
const enabled = this.configService.get<boolean>('clickhouse.enabled', false);
if (enabled) {
return this.clickHouseRepository;
}
return this.mysqlRepository;
}
}
@@ -0,0 +1,76 @@
import { ILedger } from '@/modules/Ledger/types/Ledger.types';
import { ModelObject } from 'objection';
import { Account } from '@/modules/Accounts/models/Account.model';
import { IBalanceSheetQuery } from './BalanceSheet.types';
/**
* Balance sheet repository interface.
* Both MySQL and ClickHouse implementations must conform to this interface.
*/
export interface IBalanceSheetRepository {
// Query
query: any;
// Accounts
accounts: ModelObject<Account>[];
accountsGraph: any;
accountsByType: Map<string, ModelObject<Account>[]>;
accountsByParentType: Map<string, ModelObject<Account>[]>;
// Total closing ledger
totalAccountsLedger: ILedger;
// Income / Expense ledgers
incomeLedger: ILedger;
expensesLedger: ILedger;
// Date periods
periodsAccountsLedger: ILedger;
periodsOpeningAccountLedger: ILedger;
// Previous Year (PY)
PYTotalAccountsLedger: ILedger;
PYPeriodsAccountsLedger: ILedger;
PYPeriodsOpeningAccountLedger: ILedger;
// Previous Period (PP)
PPTotalAccountsLedger: ILedger;
PPPeriodsAccountsLedger: ILedger;
PPPeriodsOpeningAccountLedger: ILedger;
// Net Income - Date Periods
incomePeriodsAccountsLedger: ILedger;
incomePeriodsOpeningAccountsLedger: ILedger;
expensesPeriodsAccountsLedger: ILedger;
expensesOpeningAccountLedger: ILedger;
// Net Income - Previous Period
incomePPAccountsLedger: ILedger;
expensePPAccountsLedger: ILedger;
incomePPPeriodsAccountsLedger: ILedger;
incomePPPeriodsOpeningAccountLedger: ILedger;
expensePPPeriodsAccountsLedger: ILedger;
expensePPPeriodsOpeningAccountLedger: ILedger;
// Net Income - Previous Year
incomePYTotalAccountsLedger: ILedger;
expensePYTotalAccountsLedger: ILedger;
incomePYPeriodsAccountsLedger: ILedger;
incomePYPeriodsOpeningAccountLedger: ILedger;
expensePYPeriodsAccountsLedger: ILedger;
expensePYPeriodsOpeningAccountLedger: ILedger;
// Income / Expense account lists
incomeAccounts: ModelObject<Account>[];
incomeAccountsIds: number[];
expenseAccounts: ModelObject<Account>[];
expenseAccountsIds: number[];
// Transactions group type
transactionsGroupType: string;
/**
* Async initialize the repository with the given query.
*/
asyncInitialize(query: IBalanceSheetQuery): Promise<void>;
}