feat(server): add budgeting CRUD and budget vs actual report
Implement budget management with CRUD operations and a Budget vs Actual financial report that supports both Profit & Loss and Balance Sheet budget types. Budget CRUD: - Budgets and BudgetEntries models with Objection.js - Create, edit, delete, activate, close, bulk-delete operations - Budget validators (date range, account existence, status transitions) - Event-driven architecture with lifecycle hooks - CASL permission guards and Swagger API docs Budget vs Actual Report: - Dual-mode report driven by budget.budgetType (P&L or BS) - Reuses existing P&L and Balance Sheet schemas via import - Computes budget, actual, variance, and variance percentage per account - Supports date period breakdowns (monthly, quarterly, annual) - P&L mode: period activity actuals from accounts_transactions - BS mode: cumulative closing balance actuals - Follows FinancialStatements mixin architecture (FinancialSheet, FinancialSheetStructure, FinancialEvaluateEquation, etc.) - Fully typed with no @ts-nocheck directives - Named exports only (no default exports)
This commit is contained in:
@@ -773,5 +773,20 @@ export const events = {
|
|||||||
onVendorTransactionsViewed: 'onVendorTransactionsViewed',
|
onVendorTransactionsViewed: 'onVendorTransactionsViewed',
|
||||||
onSalesByItemViewed: 'onSalesByItemViewed',
|
onSalesByItemViewed: 'onSalesByItemViewed',
|
||||||
onPurchasesByItemViewed: 'onPurchasesByItemViewed',
|
onPurchasesByItemViewed: 'onPurchasesByItemViewed',
|
||||||
|
onBudgetVsActualViewed: 'onBudgetVsActualViewed',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Budgets
|
||||||
|
budgets: {
|
||||||
|
onCreating: 'onBudgetCreating',
|
||||||
|
onCreated: 'onBudgetCreated',
|
||||||
|
onEditing: 'onBudgetEditing',
|
||||||
|
onEdited: 'onBudgetEdited',
|
||||||
|
onDeleting: 'onBudgetDeleting',
|
||||||
|
onDeleted: 'onBudgetDeleted',
|
||||||
|
onActivating: 'onBudgetActivating',
|
||||||
|
onActivated: 'onBudgetActivated',
|
||||||
|
onClosing: 'onBudgetClosing',
|
||||||
|
onClosed: 'onBudgetClosed',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.createTable('budgets', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('name').notNullable();
|
||||||
|
table.text('description').nullable();
|
||||||
|
table.date('start_date').notNullable();
|
||||||
|
table.date('end_date').notNullable();
|
||||||
|
table
|
||||||
|
.enu('budget_type', ['profit_and_loss', 'balance_sheet'])
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo('profit_and_loss');
|
||||||
|
table
|
||||||
|
.enu('period_type', ['monthly', 'quarterly', 'annual'])
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo('monthly');
|
||||||
|
table
|
||||||
|
.enu('status', ['draft', 'active', 'closed'])
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo('draft');
|
||||||
|
table.timestamps();
|
||||||
|
})
|
||||||
|
.createTable('budget_entries', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table
|
||||||
|
.integer('budget_id')
|
||||||
|
.unsigned()
|
||||||
|
.references('id')
|
||||||
|
.inTable('budgets')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table
|
||||||
|
.integer('account_id')
|
||||||
|
.unsigned()
|
||||||
|
.references('id')
|
||||||
|
.inTable('accounts')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table.decimal('amount', 19, 4).notNullable().defaultTo(0);
|
||||||
|
table.date('period_date').notNullable();
|
||||||
|
table.timestamps();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.dropTableIfExists('budget_entries')
|
||||||
|
.dropTableIfExists('budgets');
|
||||||
|
};
|
||||||
@@ -46,7 +46,6 @@ export class AccountsApplication {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new account.
|
* Creates a new account.
|
||||||
* @param {number} tenantId
|
|
||||||
* @param {IAccountCreateDTO} accountDTO
|
* @param {IAccountCreateDTO} accountDTO
|
||||||
* @returns {Promise<IAccount>}
|
* @returns {Promise<IAccount>}
|
||||||
*/
|
*/
|
||||||
@@ -59,7 +58,6 @@ export class AccountsApplication {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes the given account.
|
* Deletes the given account.
|
||||||
* @param {number} tenantId
|
|
||||||
* @param {number} accountId
|
* @param {number} accountId
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import { ContactsModule } from '../Contacts/Contacts.module';
|
|||||||
import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module';
|
import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module';
|
||||||
import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module';
|
import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module';
|
||||||
import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module';
|
import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module';
|
||||||
|
import { BudgetsModule } from '../Budgeting/Budgets.module';
|
||||||
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
|
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
|
||||||
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
|
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
|
||||||
import { SocketModule } from '../Socket/Socket.module';
|
import { SocketModule } from '../Socket/Socket.module';
|
||||||
@@ -258,6 +259,7 @@ import { AppThrottleModule } from './AppThrottle.module';
|
|||||||
ContactsModule,
|
ContactsModule,
|
||||||
SocketModule,
|
SocketModule,
|
||||||
ExchangeRatesModule,
|
ExchangeRatesModule,
|
||||||
|
BudgetsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Query,
|
||||||
|
ParseIntPipe,
|
||||||
|
Put,
|
||||||
|
HttpCode,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { BudgetsApplication } from './BudgetsApplication.service';
|
||||||
|
import { CreateBudgetDto, EditBudgetDto } from './dtos/CreateBudget.dto';
|
||||||
|
import {
|
||||||
|
ApiExtraModels,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
getSchemaPath,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { BudgetResponseDto } from './dtos/BudgetResponse.dto';
|
||||||
|
import { GetBudgetsQueryDto } from './dtos/GetBudgetsQuery.dto';
|
||||||
|
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
||||||
|
import {
|
||||||
|
BulkDeleteDto,
|
||||||
|
ValidateBulkDeleteResponseDto,
|
||||||
|
} from '@/common/dtos/BulkDelete.dto';
|
||||||
|
import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator';
|
||||||
|
import { PermissionGuard } from '@/modules/Roles/Permission.guard';
|
||||||
|
import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard';
|
||||||
|
import { AbilitySubject } from '@/modules/Roles/Roles.types';
|
||||||
|
import { BudgetAction } from './types/Budgets.types';
|
||||||
|
|
||||||
|
@Controller('budgets')
|
||||||
|
@ApiTags('Budgets')
|
||||||
|
@ApiExtraModels(BudgetResponseDto)
|
||||||
|
@ApiExtraModels(ValidateBulkDeleteResponseDto)
|
||||||
|
@ApiCommonHeaders()
|
||||||
|
@UseGuards(AuthorizationGuard, PermissionGuard)
|
||||||
|
export class BudgetsController {
|
||||||
|
constructor(private readonly budgetsApplication: BudgetsApplication) {}
|
||||||
|
|
||||||
|
@Post('validate-bulk-delete')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermission(BudgetAction.Delete, AbilitySubject.Budget)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Validates which budgets can be deleted and returns counts of deletable and non-deletable budgets.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description:
|
||||||
|
'Validation completed. Returns counts and IDs of deletable and non-deletable budgets.',
|
||||||
|
schema: {
|
||||||
|
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async validateBulkDeleteBudgets(
|
||||||
|
@Body() bulkDeleteDto: BulkDeleteDto,
|
||||||
|
): Promise<ValidateBulkDeleteResponseDto> {
|
||||||
|
return this.budgetsApplication.validateBulkDeleteBudgets(
|
||||||
|
bulkDeleteDto.ids,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermission(BudgetAction.Delete, AbilitySubject.Budget)
|
||||||
|
@ApiOperation({ summary: 'Deletes multiple budgets in bulk.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The budgets have been successfully deleted.',
|
||||||
|
})
|
||||||
|
async bulkDeleteBudgets(@Body() bulkDeleteDto: BulkDeleteDto) {
|
||||||
|
return this.budgetsApplication.bulkDeleteBudgets(bulkDeleteDto.ids, {
|
||||||
|
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission(BudgetAction.Create, AbilitySubject.Budget)
|
||||||
|
@ApiOperation({ summary: 'Create a new budget.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: 'The budget has been successfully created.',
|
||||||
|
schema: { $ref: getSchemaPath(BudgetResponseDto) },
|
||||||
|
})
|
||||||
|
async createBudget(@Body() budgetDTO: CreateBudgetDto) {
|
||||||
|
return this.budgetsApplication.createBudget(budgetDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@RequirePermission(BudgetAction.Edit, AbilitySubject.Budget)
|
||||||
|
@ApiOperation({ summary: 'Edit the given budget.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The budget has been successfully updated.',
|
||||||
|
schema: { $ref: getSchemaPath(BudgetResponseDto) },
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: 'The budget not found.' })
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
required: true,
|
||||||
|
type: Number,
|
||||||
|
description: 'The budget id',
|
||||||
|
})
|
||||||
|
async editBudget(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() budgetDTO: EditBudgetDto,
|
||||||
|
) {
|
||||||
|
return this.budgetsApplication.editBudget(id, budgetDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermission(BudgetAction.Delete, AbilitySubject.Budget)
|
||||||
|
@ApiOperation({ summary: 'Delete the given budget.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The budget has been successfully deleted.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: 'The budget not found.' })
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
required: true,
|
||||||
|
type: Number,
|
||||||
|
description: 'The budget id',
|
||||||
|
})
|
||||||
|
async deleteBudget(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.budgetsApplication.deleteBudget(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/activate')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermission(BudgetAction.Edit, AbilitySubject.Budget)
|
||||||
|
@ApiOperation({ summary: 'Activate the given budget.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The budget has been successfully activated.',
|
||||||
|
schema: { $ref: getSchemaPath(BudgetResponseDto) },
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
required: true,
|
||||||
|
type: Number,
|
||||||
|
description: 'The budget id',
|
||||||
|
})
|
||||||
|
async activateBudget(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.budgetsApplication.activateBudget(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/close')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermission(BudgetAction.Edit, AbilitySubject.Budget)
|
||||||
|
@ApiOperation({ summary: 'Close the given budget.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The budget has been successfully closed.',
|
||||||
|
schema: { $ref: getSchemaPath(BudgetResponseDto) },
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
required: true,
|
||||||
|
type: Number,
|
||||||
|
description: 'The budget id',
|
||||||
|
})
|
||||||
|
async closeBudget(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.budgetsApplication.closeBudget(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission(BudgetAction.View, AbilitySubject.Budget)
|
||||||
|
@ApiOperation({ summary: 'Retrieves the budget details.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The budget details have been successfully retrieved.',
|
||||||
|
schema: { $ref: getSchemaPath(BudgetResponseDto) },
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: 'The budget not found.' })
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
required: true,
|
||||||
|
type: Number,
|
||||||
|
description: 'The budget id',
|
||||||
|
})
|
||||||
|
async getBudget(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.budgetsApplication.getBudget(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission(BudgetAction.View, AbilitySubject.Budget)
|
||||||
|
@ApiOperation({ summary: 'Retrieves the budgets list.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The budgets list has been successfully retrieved.',
|
||||||
|
})
|
||||||
|
async getBudgets(@Query() filterDTO: GetBudgetsQueryDto) {
|
||||||
|
return this.budgetsApplication.getBudgets(filterDTO);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
|
||||||
|
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
||||||
|
import { BudgetsController } from './Budgets.controller';
|
||||||
|
import { BudgetsApplication } from './BudgetsApplication.service';
|
||||||
|
import { CreateBudgetService } from './commands/CreateBudget.service';
|
||||||
|
import { EditBudgetService } from './commands/EditBudget.service';
|
||||||
|
import { DeleteBudgetService } from './commands/DeleteBudget.service';
|
||||||
|
import { ActivateBudgetService } from './commands/ActivateBudget.service';
|
||||||
|
import { CloseBudgetService } from './commands/CloseBudget.service';
|
||||||
|
import { BulkDeleteBudgetsService } from './commands/BulkDeleteBudgets.service';
|
||||||
|
import { ValidateBulkDeleteBudgetsService } from './commands/ValidateBulkDeleteBudgets.service';
|
||||||
|
import { GetBudgetService } from './queries/GetBudget.service';
|
||||||
|
import { GetBudgetsService } from './queries/GetBudgets.service';
|
||||||
|
import { BudgetValidators } from './commands/BudgetValidators.service';
|
||||||
|
import { BudgetRepository } from './repositories/Budget.repository';
|
||||||
|
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TenancyDatabaseModule, DynamicListModule],
|
||||||
|
controllers: [BudgetsController],
|
||||||
|
providers: [
|
||||||
|
BudgetsApplication,
|
||||||
|
CreateBudgetService,
|
||||||
|
EditBudgetService,
|
||||||
|
DeleteBudgetService,
|
||||||
|
ActivateBudgetService,
|
||||||
|
CloseBudgetService,
|
||||||
|
BulkDeleteBudgetsService,
|
||||||
|
ValidateBulkDeleteBudgetsService,
|
||||||
|
GetBudgetService,
|
||||||
|
GetBudgetsService,
|
||||||
|
BudgetValidators,
|
||||||
|
BudgetRepository,
|
||||||
|
TransformerInjectable,
|
||||||
|
],
|
||||||
|
exports: [BudgetRepository],
|
||||||
|
})
|
||||||
|
export class BudgetsModule {}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CreateBudgetService } from './commands/CreateBudget.service';
|
||||||
|
import { EditBudgetService } from './commands/EditBudget.service';
|
||||||
|
import { DeleteBudgetService } from './commands/DeleteBudget.service';
|
||||||
|
import { ActivateBudgetService } from './commands/ActivateBudget.service';
|
||||||
|
import { CloseBudgetService } from './commands/CloseBudget.service';
|
||||||
|
import { BulkDeleteBudgetsService } from './commands/BulkDeleteBudgets.service';
|
||||||
|
import { ValidateBulkDeleteBudgetsService } from './commands/ValidateBulkDeleteBudgets.service';
|
||||||
|
import { GetBudgetService } from './queries/GetBudget.service';
|
||||||
|
import { GetBudgetsService } from './queries/GetBudgets.service';
|
||||||
|
import { CreateBudgetDto, EditBudgetDto } from './dtos/CreateBudget.dto';
|
||||||
|
import { GetBudgetsQueryDto } from './dtos/GetBudgetsQuery.dto';
|
||||||
|
import { Budget } from './models/Budget.model';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BudgetsApplication {
|
||||||
|
constructor(
|
||||||
|
private createBudgetService: CreateBudgetService,
|
||||||
|
private editBudgetService: EditBudgetService,
|
||||||
|
private deleteBudgetService: DeleteBudgetService,
|
||||||
|
private activateBudgetService: ActivateBudgetService,
|
||||||
|
private closeBudgetService: CloseBudgetService,
|
||||||
|
private getBudgetService: GetBudgetService,
|
||||||
|
private getBudgetsService: GetBudgetsService,
|
||||||
|
private bulkDeleteBudgetsService: BulkDeleteBudgetsService,
|
||||||
|
private validateBulkDeleteBudgetsService: ValidateBulkDeleteBudgetsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public createBudget = (budgetDTO: CreateBudgetDto): Promise<Budget> => {
|
||||||
|
return this.createBudgetService.createBudget(budgetDTO);
|
||||||
|
};
|
||||||
|
|
||||||
|
public editBudget = (
|
||||||
|
budgetId: number,
|
||||||
|
budgetDTO: EditBudgetDto,
|
||||||
|
): Promise<Budget> => {
|
||||||
|
return this.editBudgetService.editBudget(budgetId, budgetDTO);
|
||||||
|
};
|
||||||
|
|
||||||
|
public deleteBudget = (budgetId: number): Promise<void> => {
|
||||||
|
return this.deleteBudgetService.deleteBudget(budgetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
public activateBudget = (budgetId: number): Promise<Budget> => {
|
||||||
|
return this.activateBudgetService.activateBudget(budgetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
public closeBudget = (budgetId: number): Promise<Budget> => {
|
||||||
|
return this.closeBudgetService.closeBudget(budgetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
public getBudget = (budgetId: number): Promise<Budget> => {
|
||||||
|
return this.getBudgetService.getBudget(budgetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
public getBudgets = (filterDTO: GetBudgetsQueryDto) => {
|
||||||
|
return this.getBudgetsService.getBudgets(filterDTO);
|
||||||
|
};
|
||||||
|
|
||||||
|
public bulkDeleteBudgets = (
|
||||||
|
budgetIds: number[],
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
) => {
|
||||||
|
return this.bulkDeleteBudgetsService.bulkDeleteBudgets(
|
||||||
|
budgetIds,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
public validateBulkDeleteBudgets = (budgetIds: number[]) => {
|
||||||
|
return this.validateBulkDeleteBudgetsService.validateBulkDeleteBudgets(
|
||||||
|
budgetIds,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
import { BudgetValidators } from './BudgetValidators.service';
|
||||||
|
import {
|
||||||
|
IBudgetActivatingPayload,
|
||||||
|
IBudgetActivatedPayload,
|
||||||
|
} from '../types/Budgets.types';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { ERRORS } from '../constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActivateBudgetService {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly validator: BudgetValidators,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
|
||||||
|
@Inject(Budget.name)
|
||||||
|
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public activateBudget = async (budgetId: number): Promise<Budget> => {
|
||||||
|
const budget = await this.budgetModel()
|
||||||
|
.query()
|
||||||
|
.findById(budgetId);
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validator.validateCanActivate(budget);
|
||||||
|
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
await this.eventPublisher.emitAsync(events.budgets.onActivating, {
|
||||||
|
budgetId,
|
||||||
|
trx,
|
||||||
|
} as IBudgetActivatingPayload);
|
||||||
|
|
||||||
|
const updatedBudget = await this.budgetModel()
|
||||||
|
.query(trx)
|
||||||
|
.patchAndFetchById(budgetId, {
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.eventPublisher.emitAsync(events.budgets.onActivated, {
|
||||||
|
budget: updatedBudget,
|
||||||
|
trx,
|
||||||
|
} as IBudgetActivatedPayload);
|
||||||
|
|
||||||
|
return updatedBudget;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { difference } from 'lodash';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { CreateBudgetDto, EditBudgetDto } from '../dtos/CreateBudget.dto';
|
||||||
|
import { ERRORS } from '../constants';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BudgetValidators {
|
||||||
|
constructor(
|
||||||
|
@Inject(Account.name)
|
||||||
|
private readonly accountModel: TenantModelProxy<typeof Account>,
|
||||||
|
|
||||||
|
@Inject(Budget.name)
|
||||||
|
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public validateStartDateBeforeEndDate(dto: CreateBudgetDto | EditBudgetDto) {
|
||||||
|
if (new Date(dto.startDate) >= new Date(dto.endDate)) {
|
||||||
|
throw new ServiceError(ERRORS.START_DATE_AFTER_END_DATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async validateEntriesAccountsExist(
|
||||||
|
dto: CreateBudgetDto | EditBudgetDto,
|
||||||
|
) {
|
||||||
|
const accountsIds = dto.entries.map((e) => e.accountId);
|
||||||
|
const accounts = await this.accountModel()
|
||||||
|
.query()
|
||||||
|
.whereIn('id', accountsIds);
|
||||||
|
|
||||||
|
const storedAccountsIds = accounts.map((a) => a.id);
|
||||||
|
const missingIds = difference(accountsIds, storedAccountsIds);
|
||||||
|
|
||||||
|
if (missingIds.length > 0) {
|
||||||
|
throw new ServiceError(ERRORS.ENTRIES_ACCOUNTS_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public validateNotActive(budget: Budget) {
|
||||||
|
if (budget.isActive) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_ACTIVE_CANNOT_EDIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public validateNotClosed(budget: Budget) {
|
||||||
|
if (budget.isClosed) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_CLOSED_CANNOT_EDIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public validateCanDelete(budget: Budget) {
|
||||||
|
if (budget.isActive) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_ACTIVE_CANNOT_DELETE);
|
||||||
|
}
|
||||||
|
if (budget.isClosed) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_CLOSED_CANNOT_DELETE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public validateCanActivate(budget: Budget) {
|
||||||
|
if (budget.isActive) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_ALREADY_ACTIVE);
|
||||||
|
}
|
||||||
|
if (budget.isClosed) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_DRAFT_ONLY_ACTIVATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public validateCanClose(budget: Budget) {
|
||||||
|
if (budget.isClosed) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_ALREADY_CLOSED);
|
||||||
|
}
|
||||||
|
if (budget.isDraft) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_ACTIVE_ONLY_CLOSE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
import { BudgetValidators } from './BudgetValidators.service';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { ERRORS } from '../constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BulkDeleteBudgetsService {
|
||||||
|
constructor(
|
||||||
|
private readonly validator: BudgetValidators,
|
||||||
|
|
||||||
|
@Inject(Budget.name)
|
||||||
|
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public bulkDeleteBudgets = async (
|
||||||
|
budgetIds: number[],
|
||||||
|
options?: { skipUndeletable?: boolean },
|
||||||
|
): Promise<void> => {
|
||||||
|
const budgets = await this.budgetModel()
|
||||||
|
.query()
|
||||||
|
.whereIn('id', budgetIds);
|
||||||
|
|
||||||
|
const deletableBudgets = budgets.filter((budget) => {
|
||||||
|
try {
|
||||||
|
this.validator.validateCanDelete(budget);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletableIds = deletableBudgets.map((b) => b.id);
|
||||||
|
|
||||||
|
if (!options?.skipUndeletable && deletableIds.length < budgetIds.length) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_ACTIVE_CANNOT_DELETE);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.budgetModel()
|
||||||
|
.query()
|
||||||
|
.whereIn('id', deletableIds)
|
||||||
|
.delete();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
import { BudgetValidators } from './BudgetValidators.service';
|
||||||
|
import {
|
||||||
|
IBudgetClosingPayload,
|
||||||
|
IBudgetClosedPayload,
|
||||||
|
} from '../types/Budgets.types';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { ERRORS } from '../constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CloseBudgetService {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly validator: BudgetValidators,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
|
||||||
|
@Inject(Budget.name)
|
||||||
|
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public closeBudget = async (budgetId: number): Promise<Budget> => {
|
||||||
|
const budget = await this.budgetModel()
|
||||||
|
.query()
|
||||||
|
.findById(budgetId);
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validator.validateCanClose(budget);
|
||||||
|
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
await this.eventPublisher.emitAsync(events.budgets.onClosing, {
|
||||||
|
budgetId,
|
||||||
|
trx,
|
||||||
|
} as IBudgetClosingPayload);
|
||||||
|
|
||||||
|
const updatedBudget = await this.budgetModel()
|
||||||
|
.query(trx)
|
||||||
|
.patchAndFetchById(budgetId, {
|
||||||
|
status: 'closed',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.eventPublisher.emitAsync(events.budgets.onClosed, {
|
||||||
|
budget: updatedBudget,
|
||||||
|
trx,
|
||||||
|
} as IBudgetClosedPayload);
|
||||||
|
|
||||||
|
return updatedBudget;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
import { CreateBudgetDto } from '../dtos/CreateBudget.dto';
|
||||||
|
import { BudgetValidators } from './BudgetValidators.service';
|
||||||
|
import { IBudgetCreatedPayload } from '../types/Budgets.types';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CreateBudgetService {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly validator: BudgetValidators,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
|
||||||
|
@Inject(Budget.name)
|
||||||
|
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public createBudget = async (
|
||||||
|
budgetDTO: CreateBudgetDto,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<Budget> => {
|
||||||
|
this.validator.validateStartDateBeforeEndDate(budgetDTO);
|
||||||
|
await this.validator.validateEntriesAccountsExist(budgetDTO);
|
||||||
|
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
const budgetObj = {
|
||||||
|
name: budgetDTO.name,
|
||||||
|
description: budgetDTO.description || null,
|
||||||
|
startDate: budgetDTO.startDate,
|
||||||
|
endDate: budgetDTO.endDate,
|
||||||
|
budgetType: budgetDTO.budgetType,
|
||||||
|
periodType: budgetDTO.periodType,
|
||||||
|
status: 'draft',
|
||||||
|
entries: budgetDTO.entries.map((entry) => ({
|
||||||
|
accountId: entry.accountId,
|
||||||
|
amount: entry.amount,
|
||||||
|
periodDate: entry.periodDate,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const budget = await this.budgetModel()
|
||||||
|
.query(trx)
|
||||||
|
.upsertGraph(budgetObj, { relate: true });
|
||||||
|
|
||||||
|
await this.eventPublisher.emitAsync(events.budgets.onCreated, {
|
||||||
|
budget,
|
||||||
|
trx,
|
||||||
|
} as IBudgetCreatedPayload);
|
||||||
|
|
||||||
|
return budget;
|
||||||
|
}, trx);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
import { BudgetValidators } from './BudgetValidators.service';
|
||||||
|
import {
|
||||||
|
IBudgetDeletingPayload,
|
||||||
|
IBudgetDeletedPayload,
|
||||||
|
} from '../types/Budgets.types';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { ERRORS } from '../constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeleteBudgetService {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly validator: BudgetValidators,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
|
||||||
|
@Inject(Budget.name)
|
||||||
|
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public deleteBudget = async (budgetId: number): Promise<void> => {
|
||||||
|
const budget = await this.budgetModel()
|
||||||
|
.query()
|
||||||
|
.findById(budgetId);
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validator.validateCanDelete(budget);
|
||||||
|
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
await this.eventPublisher.emitAsync(events.budgets.onDeleting, {
|
||||||
|
budgetId,
|
||||||
|
trx,
|
||||||
|
} as IBudgetDeletingPayload);
|
||||||
|
|
||||||
|
await this.budgetModel().query(trx).findById(budgetId).delete();
|
||||||
|
|
||||||
|
await this.eventPublisher.emitAsync(events.budgets.onDeleted, {
|
||||||
|
budgetId,
|
||||||
|
trx,
|
||||||
|
} as IBudgetDeletedPayload);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
import { EditBudgetDto } from '../dtos/CreateBudget.dto';
|
||||||
|
import { BudgetValidators } from './BudgetValidators.service';
|
||||||
|
import {
|
||||||
|
IBudgetEditingPayload,
|
||||||
|
IBudgetEditedPayload,
|
||||||
|
} from '../types/Budgets.types';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { ERRORS } from '../constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EditBudgetService {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly validator: BudgetValidators,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
|
||||||
|
@Inject(Budget.name)
|
||||||
|
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public editBudget = async (
|
||||||
|
budgetId: number,
|
||||||
|
budgetDTO: EditBudgetDto,
|
||||||
|
): Promise<Budget> => {
|
||||||
|
const budget = await this.budgetModel()
|
||||||
|
.query()
|
||||||
|
.findById(budgetId);
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validator.validateNotActive(budget);
|
||||||
|
this.validator.validateNotClosed(budget);
|
||||||
|
this.validator.validateStartDateBeforeEndDate(budgetDTO);
|
||||||
|
await this.validator.validateEntriesAccountsExist(budgetDTO);
|
||||||
|
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
await this.eventPublisher.emitAsync(events.budgets.onEditing, {
|
||||||
|
budgetId,
|
||||||
|
budgetDTO,
|
||||||
|
trx,
|
||||||
|
} as IBudgetEditingPayload);
|
||||||
|
|
||||||
|
const updatedBudget = await this.budgetModel()
|
||||||
|
.query(trx)
|
||||||
|
.upsertGraph(
|
||||||
|
{
|
||||||
|
id: budgetId,
|
||||||
|
name: budgetDTO.name,
|
||||||
|
description: budgetDTO.description || null,
|
||||||
|
startDate: budgetDTO.startDate,
|
||||||
|
endDate: budgetDTO.endDate,
|
||||||
|
budgetType: budgetDTO.budgetType,
|
||||||
|
periodType: budgetDTO.periodType,
|
||||||
|
entries: budgetDTO.entries.map((entry) => ({
|
||||||
|
accountId: entry.accountId,
|
||||||
|
amount: entry.amount,
|
||||||
|
periodDate: entry.periodDate,
|
||||||
|
...(entry['id'] ? { id: entry['id'] } : {}),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ relate: true, unrelate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.eventPublisher.emitAsync(events.budgets.onEdited, {
|
||||||
|
budget: updatedBudget,
|
||||||
|
budgetDTO,
|
||||||
|
trx,
|
||||||
|
} as IBudgetEditedPayload);
|
||||||
|
|
||||||
|
return updatedBudget;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
import { BudgetValidators } from './BudgetValidators.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateBulkDeleteBudgetsService {
|
||||||
|
constructor(
|
||||||
|
private readonly validator: BudgetValidators,
|
||||||
|
|
||||||
|
@Inject(Budget.name)
|
||||||
|
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public validateBulkDeleteBudgets = async (budgetIds: number[]) => {
|
||||||
|
const budgets = await this.budgetModel()
|
||||||
|
.query()
|
||||||
|
.whereIn('id', budgetIds);
|
||||||
|
|
||||||
|
const deletableIds: number[] = [];
|
||||||
|
const nonDeletableIds: number[] = [];
|
||||||
|
|
||||||
|
budgets.forEach((budget) => {
|
||||||
|
try {
|
||||||
|
this.validator.validateCanDelete(budget);
|
||||||
|
deletableIds.push(budget.id);
|
||||||
|
} catch {
|
||||||
|
nonDeletableIds.push(budget.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletableIds,
|
||||||
|
nonDeletableIds,
|
||||||
|
deletableCount: deletableIds.length,
|
||||||
|
nonDeletableCount: nonDeletableIds.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const ERRORS = {
|
||||||
|
BUDGET_NOT_FOUND: 'BUDGET_NOT_FOUND',
|
||||||
|
BUDGET_ACTIVE_CANNOT_EDIT: 'BUDGET_ACTIVE_CANNOT_EDIT',
|
||||||
|
BUDGET_CLOSED_CANNOT_EDIT: 'BUDGET_CLOSED_CANNOT_EDIT',
|
||||||
|
BUDGET_ACTIVE_CANNOT_DELETE: 'BUDGET_ACTIVE_CANNOT_DELETE',
|
||||||
|
BUDGET_CLOSED_CANNOT_DELETE: 'BUDGET_CLOSED_CANNOT_DELETE',
|
||||||
|
BUDGET_ALREADY_ACTIVE: 'BUDGET_ALREADY_ACTIVE',
|
||||||
|
BUDGET_ALREADY_CLOSED: 'BUDGET_ALREADY_CLOSED',
|
||||||
|
BUDGET_DRAFT_ONLY_ACTIVATE: 'BUDGET_DRAFT_ONLY_ACTIVATE',
|
||||||
|
BUDGET_ACTIVE_ONLY_CLOSE: 'BUDGET_ACTIVE_ONLY_CLOSE',
|
||||||
|
ENTRIES_ACCOUNTS_NOT_FOUND: 'ENTRIES_ACCOUNTS_NOT_FOUND',
|
||||||
|
START_DATE_AFTER_END_DATE: 'START_DATE_AFTER_END_DATE',
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ToNumber } from '@/common/decorators/Validators';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class BudgetEntryDto {
|
||||||
|
@ApiProperty({ description: 'Account ID', example: 1 })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@ToNumber()
|
||||||
|
@IsInt()
|
||||||
|
accountId: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Budget amount for the period', example: 5000.0 })
|
||||||
|
@ToNumber()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Period date (first day of the period)',
|
||||||
|
example: '2026-01-01',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
periodDate: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
class BudgetEntryResponseDto {
|
||||||
|
@ApiProperty({ description: 'Entry ID', example: 1 })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Account ID', example: 1 })
|
||||||
|
accountId: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Budget amount', example: 5000.0 })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Period date', example: '2026-01-01' })
|
||||||
|
periodDate: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Account details' })
|
||||||
|
account?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BudgetResponseDto {
|
||||||
|
@ApiProperty({ description: 'Budget ID', example: 1 })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Budget name', example: '2026 Annual Budget' })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Budget description',
|
||||||
|
example: 'Annual operating budget',
|
||||||
|
})
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Start date', example: '2026-01-01' })
|
||||||
|
startDate: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'End date', example: '2026-12-31' })
|
||||||
|
endDate: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Budget type',
|
||||||
|
enum: ['profit_and_loss', 'balance_sheet'],
|
||||||
|
})
|
||||||
|
budgetType: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Period type',
|
||||||
|
enum: ['monthly', 'quarterly', 'annual'],
|
||||||
|
})
|
||||||
|
periodType: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Status',
|
||||||
|
enum: ['draft', 'active', 'closed'],
|
||||||
|
})
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Is draft', example: true })
|
||||||
|
isDraft: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Is active', example: false })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Is closed', example: false })
|
||||||
|
isClosed: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Created at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Updated at' })
|
||||||
|
updatedAt?: Date;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Budget entries',
|
||||||
|
type: [BudgetEntryResponseDto],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => BudgetEntryResponseDto)
|
||||||
|
entries: BudgetEntryResponseDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { IsOptional, ToNumber } from '@/common/decorators/Validators';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional as IsOpt,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { BudgetEntryDto } from './BudgetEntry.dto';
|
||||||
|
|
||||||
|
export class CommandBudgetDto {
|
||||||
|
@ApiProperty({ description: 'Budget name', example: '2026 Annual Budget' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Budget description',
|
||||||
|
example: 'Annual operating budget for 2026',
|
||||||
|
})
|
||||||
|
@IsOpt()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Budget start date', example: '2026-01-01' })
|
||||||
|
@IsDateString()
|
||||||
|
startDate: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Budget end date', example: '2026-12-31' })
|
||||||
|
@IsDateString()
|
||||||
|
endDate: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Budget type',
|
||||||
|
enum: ['profit_and_loss', 'balance_sheet'],
|
||||||
|
example: 'profit_and_loss',
|
||||||
|
})
|
||||||
|
@IsEnum(['profit_and_loss', 'balance_sheet'])
|
||||||
|
budgetType: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Period type',
|
||||||
|
enum: ['monthly', 'quarterly', 'annual'],
|
||||||
|
example: 'monthly',
|
||||||
|
})
|
||||||
|
@IsEnum(['monthly', 'quarterly', 'annual'])
|
||||||
|
periodType: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Budget entries', type: [BudgetEntryDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => BudgetEntryDto)
|
||||||
|
entries: BudgetEntryDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateBudgetDto extends CommandBudgetDto {}
|
||||||
|
|
||||||
|
export class EditBudgetDto extends CommandBudgetDto {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto';
|
||||||
|
|
||||||
|
export class GetBudgetsQueryDto extends DynamicFilterQueryDto {}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export const BudgetMeta = {
|
||||||
|
defaultFilterField: 'start_date',
|
||||||
|
defaultSort: {
|
||||||
|
sortOrder: 'DESC',
|
||||||
|
sortField: 'created_at',
|
||||||
|
},
|
||||||
|
|
||||||
|
fields: {
|
||||||
|
name: {
|
||||||
|
name: 'budget.field.name',
|
||||||
|
column: 'name',
|
||||||
|
fieldType: 'text',
|
||||||
|
},
|
||||||
|
start_date: {
|
||||||
|
name: 'budget.field.start_date',
|
||||||
|
column: 'start_date',
|
||||||
|
fieldType: 'date',
|
||||||
|
},
|
||||||
|
end_date: {
|
||||||
|
name: 'budget.field.end_date',
|
||||||
|
column: 'end_date',
|
||||||
|
fieldType: 'date',
|
||||||
|
},
|
||||||
|
budget_type: {
|
||||||
|
name: 'budget.field.budget_type',
|
||||||
|
column: 'budget_type',
|
||||||
|
fieldType: 'enumeration',
|
||||||
|
options: [
|
||||||
|
{ key: 'profit_and_loss', label: 'Profit & Loss' },
|
||||||
|
{ key: 'balance_sheet', label: 'Balance Sheet' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
period_type: {
|
||||||
|
name: 'budget.field.period_type',
|
||||||
|
column: 'period_type',
|
||||||
|
fieldType: 'enumeration',
|
||||||
|
options: [
|
||||||
|
{ key: 'monthly', label: 'Monthly' },
|
||||||
|
{ key: 'quarterly', label: 'Quarterly' },
|
||||||
|
{ key: 'annual', label: 'Annual' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
name: 'budget.field.status',
|
||||||
|
column: 'status',
|
||||||
|
fieldType: 'enumeration',
|
||||||
|
options: [
|
||||||
|
{ key: 'draft', label: 'Draft' },
|
||||||
|
{ key: 'active', label: 'Active' },
|
||||||
|
{ key: 'closed', label: 'Closed' },
|
||||||
|
],
|
||||||
|
filterCustomQuery(query, role) {
|
||||||
|
query.modify('filterByStatus', role.value);
|
||||||
|
},
|
||||||
|
sortCustomQuery(query, role) {
|
||||||
|
query.orderBy('budgets.status', role.order);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
name: 'budget.field.created_at',
|
||||||
|
column: 'created_at',
|
||||||
|
fieldType: 'date',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: {
|
||||||
|
name: {
|
||||||
|
name: 'budget.field.name',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
startDate: {
|
||||||
|
name: 'budget.field.start_date',
|
||||||
|
type: 'date',
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
name: 'budget.field.end_date',
|
||||||
|
type: 'date',
|
||||||
|
},
|
||||||
|
budgetType: {
|
||||||
|
name: 'budget.field.budget_type',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
periodType: {
|
||||||
|
name: 'budget.field.period_type',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
name: 'budget.field.status',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
name: 'budget.field.created_at',
|
||||||
|
type: 'date',
|
||||||
|
printable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Model } from 'objection';
|
||||||
|
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator';
|
||||||
|
import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator';
|
||||||
|
import { BudgetMeta } from './Budget.meta';
|
||||||
|
|
||||||
|
@InjectModelMeta(BudgetMeta)
|
||||||
|
export class Budget extends TenantBaseModel {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
budgetType: string;
|
||||||
|
periodType: string;
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
entries;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
static get tableName() {
|
||||||
|
return 'budgets';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get timestamps() {
|
||||||
|
return ['createdAt', 'updatedAt'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get virtualAttributes() {
|
||||||
|
return ['isActive', 'isDraft', 'isClosed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
get isActive() {
|
||||||
|
return this.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDraft() {
|
||||||
|
return this.status === 'draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isClosed() {
|
||||||
|
return this.status === 'closed';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get resourceable() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get modifiers() {
|
||||||
|
return {
|
||||||
|
filterByStatus(query, status) {
|
||||||
|
if (status) {
|
||||||
|
query.where('budgets.status', status);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filterByType(query, budgetType) {
|
||||||
|
if (budgetType) {
|
||||||
|
query.where('budgets.budget_type', budgetType);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filterByPeriod(query, periodType) {
|
||||||
|
if (periodType) {
|
||||||
|
query.where('budgets.period_type', periodType);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get relationMappings() {
|
||||||
|
const { BudgetEntry } = require('./BudgetEntry.model');
|
||||||
|
return {
|
||||||
|
entries: {
|
||||||
|
relation: Model.HasManyRelation,
|
||||||
|
modelClass: BudgetEntry,
|
||||||
|
join: {
|
||||||
|
from: 'budgets.id',
|
||||||
|
to: 'budget_entries.budget_id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get searchRoles() {
|
||||||
|
return [
|
||||||
|
{ condition: 'or', fieldKey: 'name', comparator: 'contains' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Model } from 'objection';
|
||||||
|
import { BaseModel } from '@/models/Model';
|
||||||
|
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||||
|
|
||||||
|
export class BudgetEntry extends BaseModel {
|
||||||
|
budgetId: number;
|
||||||
|
accountId: number;
|
||||||
|
amount: number;
|
||||||
|
periodDate: string;
|
||||||
|
|
||||||
|
account?: Account;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
static get tableName() {
|
||||||
|
return 'budget_entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
get timestamps() {
|
||||||
|
return ['createdAt', 'updatedAt'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get relationMappings() {
|
||||||
|
const { Account } = require('@/modules/Accounts/models/Account.model');
|
||||||
|
const { Budget } = require('./Budget.model');
|
||||||
|
|
||||||
|
return {
|
||||||
|
account: {
|
||||||
|
relation: Model.BelongsToOneRelation,
|
||||||
|
modelClass: Account,
|
||||||
|
join: {
|
||||||
|
from: 'budget_entries.account_id',
|
||||||
|
to: 'accounts.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
budget: {
|
||||||
|
relation: Model.BelongsToOneRelation,
|
||||||
|
modelClass: Budget,
|
||||||
|
join: {
|
||||||
|
from: 'budget_entries.budget_id',
|
||||||
|
to: 'budgets.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Transformer } from '@/modules/Transformer/Transformer';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
|
||||||
|
export class BudgetTransformer extends Transformer {
|
||||||
|
public includeAttributes = (): string[] => {
|
||||||
|
return [
|
||||||
|
'formattedStartDate',
|
||||||
|
'formattedEndDate',
|
||||||
|
'formattedCreatedAt',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
protected formattedStartDate = (budget: Budget): string => {
|
||||||
|
return this.formatDate(budget.startDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
protected formattedEndDate = (budget: Budget): string => {
|
||||||
|
return this.formatDate(budget.endDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
protected formattedCreatedAt = (budget: Budget): string => {
|
||||||
|
return this.formatDate(budget.createdAt);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { ERRORS } from '../constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetBudgetService {
|
||||||
|
constructor(
|
||||||
|
@Inject(Budget.name)
|
||||||
|
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public getBudget = async (budgetId: number): Promise<Budget> => {
|
||||||
|
const budget = await this.budgetModel()
|
||||||
|
.query()
|
||||||
|
.findById(budgetId)
|
||||||
|
.withGraphFetched('entries.account');
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return budget;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as R from 'ramda';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
|
||||||
|
import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model';
|
||||||
|
import { GetBudgetsQueryDto } from '../dtos/GetBudgetsQuery.dto';
|
||||||
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { BudgetTransformer } from './BudgetTransformer';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetBudgetsService {
|
||||||
|
constructor(
|
||||||
|
private readonly dynamicListService: DynamicListService,
|
||||||
|
private readonly transformer: TransformerInjectable,
|
||||||
|
|
||||||
|
@Inject(Budget.name)
|
||||||
|
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private parseListFilterDTO = (filterDTO) => {
|
||||||
|
return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO);
|
||||||
|
};
|
||||||
|
|
||||||
|
public getBudgets = async (
|
||||||
|
filterDTO: GetBudgetsQueryDto,
|
||||||
|
): Promise<{
|
||||||
|
budgets: Budget[];
|
||||||
|
pagination: IPaginationMeta;
|
||||||
|
filterMeta: IFilterMeta;
|
||||||
|
}> => {
|
||||||
|
const _filterDto = {
|
||||||
|
sortOrder: 'desc',
|
||||||
|
columnSortBy: 'created_at',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 12,
|
||||||
|
...filterDTO,
|
||||||
|
};
|
||||||
|
|
||||||
|
const filter = this.parseListFilterDTO(_filterDto);
|
||||||
|
|
||||||
|
const dynamicService = await this.dynamicListService.dynamicList(
|
||||||
|
this.budgetModel(),
|
||||||
|
filter,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { results, pagination } = await this.budgetModel()
|
||||||
|
.query()
|
||||||
|
.onBuild((builder) => {
|
||||||
|
dynamicService.buildQuery()(builder);
|
||||||
|
builder.withGraphFetched('entries.account');
|
||||||
|
})
|
||||||
|
.pagination(filter.page - 1, filter.pageSize);
|
||||||
|
|
||||||
|
const budgets = await this.transformer.transform(
|
||||||
|
results,
|
||||||
|
new BudgetTransformer(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
budgets,
|
||||||
|
pagination,
|
||||||
|
filterMeta: dynamicService.getResponseMeta(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||||
|
import { TenantRepository } from '@/common/repository/TenantRepository';
|
||||||
|
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
|
||||||
|
import { Budget } from '../models/Budget.model';
|
||||||
|
|
||||||
|
@Injectable({ scope: Scope.REQUEST })
|
||||||
|
export class BudgetRepository extends TenantRepository {
|
||||||
|
constructor(
|
||||||
|
@Inject(TENANCY_DB_CONNECTION)
|
||||||
|
private readonly tenantDBKnex: () => Knex,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get model(): typeof Budget {
|
||||||
|
return Budget.bindKnex(this.tenantDBKnex());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByIdWithEntries(budgetId: number) {
|
||||||
|
return this.model
|
||||||
|
.query()
|
||||||
|
.findById(budgetId)
|
||||||
|
.withGraphFetched('entries.account');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findById(budgetId: number) {
|
||||||
|
return this.model.query().findById(budgetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteByIds(budgetIds: number[]) {
|
||||||
|
await this.model.query().whereIn('id', budgetIds).delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findActiveByIds(budgetIds: number[]) {
|
||||||
|
return this.model
|
||||||
|
.query()
|
||||||
|
.whereIn('id', budgetIds)
|
||||||
|
.where('status', 'active');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export enum BudgetStatus {
|
||||||
|
Draft = 'draft',
|
||||||
|
Active = 'active',
|
||||||
|
Closed = 'closed',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BudgetType {
|
||||||
|
ProfitAndLoss = 'profit_and_loss',
|
||||||
|
BalanceSheet = 'balance_sheet',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PeriodType {
|
||||||
|
Monthly = 'monthly',
|
||||||
|
Quarterly = 'quarterly',
|
||||||
|
Annual = 'annual',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BudgetAction {
|
||||||
|
View = 'View',
|
||||||
|
Create = 'Create',
|
||||||
|
Edit = 'Edit',
|
||||||
|
Delete = 'Delete',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetCreatedPayload {
|
||||||
|
budget;
|
||||||
|
trx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetEditingPayload {
|
||||||
|
budgetId: number;
|
||||||
|
budgetDTO;
|
||||||
|
trx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetEditedPayload {
|
||||||
|
budget;
|
||||||
|
budgetDTO;
|
||||||
|
trx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetDeletingPayload {
|
||||||
|
budgetId: number;
|
||||||
|
trx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetDeletedPayload {
|
||||||
|
budgetId: number;
|
||||||
|
trx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetActivatingPayload {
|
||||||
|
budgetId: number;
|
||||||
|
trx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetActivatedPayload {
|
||||||
|
budget;
|
||||||
|
trx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetClosingPayload {
|
||||||
|
budgetId: number;
|
||||||
|
trx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetClosedPayload {
|
||||||
|
budget;
|
||||||
|
trx;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { ProfitLossSheetModule } from './modules/ProfitLossSheet/ProfitLossSheet
|
|||||||
import { CashflowStatementModule } from './modules/CashFlowStatement/CashflowStatement.module';
|
import { CashflowStatementModule } from './modules/CashFlowStatement/CashflowStatement.module';
|
||||||
import { VendorBalanceSummaryModule } from './modules/VendorBalanceSummary/VendorBalanceSummary.module';
|
import { VendorBalanceSummaryModule } from './modules/VendorBalanceSummary/VendorBalanceSummary.module';
|
||||||
import { BalanceSheetModule } from './modules/BalanceSheet/BalanceSheet.module';
|
import { BalanceSheetModule } from './modules/BalanceSheet/BalanceSheet.module';
|
||||||
|
import { BudgetVsActualModule } from './modules/BudgetVsActual/BudgetVsActual.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -38,6 +39,7 @@ import { BalanceSheetModule } from './modules/BalanceSheet/BalanceSheet.module';
|
|||||||
JournalSheetModule,
|
JournalSheetModule,
|
||||||
ProfitLossSheetModule,
|
ProfitLossSheetModule,
|
||||||
CashflowStatementModule,
|
CashflowStatementModule,
|
||||||
|
BudgetVsActualModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FinancialStatementsModule {}
|
export class FinancialStatementsModule {}
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
Headers,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { BudgetVsActualApplication } from './BudgetVsActualApplication';
|
||||||
|
import { BudgetVsActualQueryDto } from './BudgetVsActualQuery.dto';
|
||||||
|
import {
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { BudgetVsActualResponseDto } from './BudgetVsActualResponse.dto';
|
||||||
|
import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator';
|
||||||
|
import { PermissionGuard } from '@/modules/Roles/Permission.guard';
|
||||||
|
import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard';
|
||||||
|
import { AbilitySubject } from '@/modules/Roles/Roles.types';
|
||||||
|
import { ReportsAction } from '../../types/Report.types';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
@Controller('/reports/budget-vs-actual')
|
||||||
|
@ApiTags('Reports')
|
||||||
|
@UseGuards(AuthorizationGuard, PermissionGuard)
|
||||||
|
export class BudgetVsActualController {
|
||||||
|
constructor(
|
||||||
|
private readonly budgetVsActualApp: BudgetVsActualApplication,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('/')
|
||||||
|
@RequirePermission(ReportsAction.READ_BUDGET_VS_ACTUAL, AbilitySubject.Report)
|
||||||
|
@ApiOperation({ summary: 'Retrieves the Budget vs Actual report.' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'The Budget vs Actual report has been successfully retrieved.',
|
||||||
|
})
|
||||||
|
async budgetVsActual(
|
||||||
|
@Query() query: BudgetVsActualQueryDto,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
@Headers('accept') acceptHeader: string,
|
||||||
|
) {
|
||||||
|
if (acceptHeader === 'application/csv') {
|
||||||
|
return this.budgetVsActualApp.csv(query);
|
||||||
|
}
|
||||||
|
if (acceptHeader === 'application/json+table') {
|
||||||
|
return this.budgetVsActualApp.table(query);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
acceptHeader ===
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
) {
|
||||||
|
return this.budgetVsActualApp.xlsx(query);
|
||||||
|
}
|
||||||
|
if (acceptHeader === 'application/pdf') {
|
||||||
|
return this.budgetVsActualApp.pdf(query);
|
||||||
|
}
|
||||||
|
return this.budgetVsActualApp.sheet(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FinancialSheetCommonModule } from '../../common/FinancialSheetCommon.module';
|
||||||
|
import { AccountsModule } from '@/modules/Accounts/Accounts.module';
|
||||||
|
import { BudgetsModule } from '@/modules/Budgeting/Budgets.module';
|
||||||
|
import { BudgetVsActualController } from './BudgetVsActual.controller';
|
||||||
|
import { BudgetVsActualApplication } from './BudgetVsActualApplication';
|
||||||
|
import { BudgetVsActualService } from './BudgetVsActualService';
|
||||||
|
import { BudgetVsActualRepository } from './BudgetVsActualRepository';
|
||||||
|
import { BudgetVsActualMeta } from './BudgetVsActualMeta';
|
||||||
|
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [FinancialSheetCommonModule, AccountsModule, BudgetsModule],
|
||||||
|
controllers: [BudgetVsActualController],
|
||||||
|
providers: [
|
||||||
|
BudgetVsActualApplication,
|
||||||
|
BudgetVsActualService,
|
||||||
|
BudgetVsActualRepository,
|
||||||
|
BudgetVsActualMeta,
|
||||||
|
TenancyContext,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BudgetVsActualModule {}
|
||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
import * as R from 'ramda';
|
||||||
|
import { ModelObject } from 'objection';
|
||||||
|
import { I18nService } from 'nestjs-i18n';
|
||||||
|
import {
|
||||||
|
IBudgetVsActualQuery,
|
||||||
|
IBudgetVsActualNode,
|
||||||
|
IBudgetVsActualAccountNode,
|
||||||
|
IBudgetVsActualAggregateNode,
|
||||||
|
IBudgetVsActualEquationNode,
|
||||||
|
} from './BudgetVsActual.types';
|
||||||
|
import { BUDGET_VS_ACTUAL_NODE_TYPE } from './constants';
|
||||||
|
import { BudgetVsActualSchema } from './BudgetVsActualSchema';
|
||||||
|
import { BudgetVsActualDatePeriods } from './BudgetVsActualDatePeriods';
|
||||||
|
import { BudgetVsActualBase } from './BudgetVsActualBase';
|
||||||
|
import { BudgetVsActualQuery } from './BudgetVsActualQuery';
|
||||||
|
import { BudgetVsActualRepository } from './BudgetVsActualRepository';
|
||||||
|
import { FinancialDateRanges } from '../../common/FinancialDateRanges';
|
||||||
|
import { FinancialEvaluateEquation } from '../../common/FinancialEvaluateEquation';
|
||||||
|
import { FinancialSheetStructure } from '../../common/FinancialSheetStructure';
|
||||||
|
import { FinancialSheet } from '../../common/FinancialSheet';
|
||||||
|
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||||
|
import { flatToNestedArray } from '@/utils/flat-to-nested-array';
|
||||||
|
import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types';
|
||||||
|
import { INumberFormatQuery } from '../../types/Report.types';
|
||||||
|
|
||||||
|
interface IBudgetVsActualContext {
|
||||||
|
repository: BudgetVsActualRepository;
|
||||||
|
query: BudgetVsActualQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BudgetVsActualSheet extends R.pipe(
|
||||||
|
BudgetVsActualDatePeriods,
|
||||||
|
BudgetVsActualSchema,
|
||||||
|
BudgetVsActualBase,
|
||||||
|
FinancialDateRanges,
|
||||||
|
FinancialEvaluateEquation,
|
||||||
|
FinancialSheetStructure,
|
||||||
|
)(FinancialSheet) {
|
||||||
|
readonly query: BudgetVsActualQuery;
|
||||||
|
readonly baseCurrency: string;
|
||||||
|
readonly repository: BudgetVsActualRepository;
|
||||||
|
readonly i18n: I18nService;
|
||||||
|
readonly numberFormat: INumberFormatQuery;
|
||||||
|
readonly dateFormat: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
repository: BudgetVsActualRepository,
|
||||||
|
query: IBudgetVsActualQuery,
|
||||||
|
i18n: I18nService,
|
||||||
|
meta: IFinancialReportMeta,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.query = new BudgetVsActualQuery(query);
|
||||||
|
this.repository = repository;
|
||||||
|
this.baseCurrency = meta.baseCurrency;
|
||||||
|
this.numberFormat = this.query.query.numberFormat;
|
||||||
|
this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat;
|
||||||
|
this.i18n = i18n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get ctx(): IBudgetVsActualContext {
|
||||||
|
return this as unknown as IBudgetVsActualContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private accountNodeMapper = (
|
||||||
|
account: ModelObject<Account>,
|
||||||
|
): IBudgetVsActualAccountNode => {
|
||||||
|
const childrenAccountIds: number[] = this.repository.accountsGraph.dependenciesOf(
|
||||||
|
account.id,
|
||||||
|
);
|
||||||
|
const accountIds = R.uniq(R.append(account.id, childrenAccountIds));
|
||||||
|
|
||||||
|
const actual = this.repository.getActualClosingBalance(accountIds);
|
||||||
|
const budget = this.repository.getBudgetAmountForAccounts(accountIds);
|
||||||
|
const variance = this.getVariance(actual, budget);
|
||||||
|
const variancePct = this.getVariancePercentage(actual, budget);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
name: account.name,
|
||||||
|
code: account.code,
|
||||||
|
nodeType: BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNT,
|
||||||
|
budget: this.getAmountMeta(budget),
|
||||||
|
actual: this.getAmountMeta(actual),
|
||||||
|
variance: this.getAmountMeta(variance),
|
||||||
|
variancePercentage: this.getPercentageAmountMeta(variancePct),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
private accountNodeCompose = (account: ModelObject<Account>): IBudgetVsActualAccountNode => {
|
||||||
|
let result: any = this.accountNodeMapper(account);
|
||||||
|
if (this.query.isDatePeriodsColumnsType()) {
|
||||||
|
result = this.assocAccountNodeDatePeriods(result);
|
||||||
|
}
|
||||||
|
return result as IBudgetVsActualAccountNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
private getAccountsNodesByTypes = (types: string[]): IBudgetVsActualAccountNode[] => {
|
||||||
|
const accounts = this.repository.getAccountsByType(types);
|
||||||
|
const accountsTree = flatToNestedArray(accounts, {
|
||||||
|
id: 'id',
|
||||||
|
parentId: 'parentAccountId',
|
||||||
|
});
|
||||||
|
return this.mapNodesDeep(accountsTree, this.accountNodeCompose);
|
||||||
|
};
|
||||||
|
|
||||||
|
private accountsSchemaNodeMapper = (node: any): IBudgetVsActualAggregateNode => {
|
||||||
|
const accountsTypes: string[] = node.accountsTypes || node.accountTypes;
|
||||||
|
const children: IBudgetVsActualNode[] = accountsTypes
|
||||||
|
? this.getAccountsNodesByTypes(accountsTypes)
|
||||||
|
: [];
|
||||||
|
const schemaChildren: IBudgetVsActualNode[] = node.children
|
||||||
|
? this.parseSchemaChildren(node.children)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const allChildren = [...children, ...schemaChildren];
|
||||||
|
|
||||||
|
const budgetTotal = this.getBudgetOfNodes(allChildren);
|
||||||
|
const actualTotal = this.getActualOfNodes(allChildren);
|
||||||
|
const variance = this.getVariance(actualTotal, budgetTotal);
|
||||||
|
const variancePct = this.getVariancePercentage(actualTotal, budgetTotal);
|
||||||
|
|
||||||
|
const nodeType = node.type === 'AGGREGATE'
|
||||||
|
? BUDGET_VS_ACTUAL_NODE_TYPE.AGGREGATE
|
||||||
|
: BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNTS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
name: this.i18n.t(node.name),
|
||||||
|
nodeType: nodeType as 'ACCOUNTS' | 'AGGREGATE',
|
||||||
|
budget: this.getTotalAmountMeta(budgetTotal),
|
||||||
|
actual: this.getTotalAmountMeta(actualTotal),
|
||||||
|
variance: this.getTotalAmountMeta(variance),
|
||||||
|
variancePercentage: this.getPercentageTotalAmountMeta(variancePct),
|
||||||
|
children: allChildren,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
private accountsSchemaNodeCompose = (node: any): IBudgetVsActualAggregateNode => {
|
||||||
|
let result: any = this.accountsSchemaNodeMapper(node);
|
||||||
|
if (this.query.isDatePeriodsColumnsType()) {
|
||||||
|
result = this.assocAggregateDatePeriod(result);
|
||||||
|
}
|
||||||
|
return result as IBudgetVsActualAggregateNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
private equationSchemaNodeParser = R.curry(
|
||||||
|
(accNodes: any[], node: any): IBudgetVsActualEquationNode => {
|
||||||
|
const equation: string = node.equation;
|
||||||
|
|
||||||
|
const budgetTable = this.getNodesTableForEvaluating(
|
||||||
|
'budget.amount',
|
||||||
|
accNodes,
|
||||||
|
) as Record<string, number>;
|
||||||
|
const budgetTotal = this.evaluateEquation(equation, budgetTable);
|
||||||
|
|
||||||
|
const actualTable = this.getNodesTableForEvaluating(
|
||||||
|
'actual.amount',
|
||||||
|
accNodes,
|
||||||
|
) as Record<string, number>;
|
||||||
|
const actualTotal = this.evaluateEquation(equation, actualTable);
|
||||||
|
|
||||||
|
const variance = this.getVariance(actualTotal, budgetTotal);
|
||||||
|
const variancePct = this.getVariancePercentage(actualTotal, budgetTotal);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
name: this.i18n.t(node.name),
|
||||||
|
nodeType: BUDGET_VS_ACTUAL_NODE_TYPE.EQUATION,
|
||||||
|
budget: this.getTotalAmountMeta(budgetTotal),
|
||||||
|
actual: this.getTotalAmountMeta(actualTotal),
|
||||||
|
variance: this.getTotalAmountMeta(variance),
|
||||||
|
variancePercentage: this.getPercentageTotalAmountMeta(variancePct),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
private equationSchemaNodeCompose = R.curry(
|
||||||
|
(accNodes: any[], node: any): IBudgetVsActualEquationNode => {
|
||||||
|
let result: any = node;
|
||||||
|
if (this.isEquationNode(node)) {
|
||||||
|
result = this.equationSchemaNodeParser(accNodes)(node);
|
||||||
|
}
|
||||||
|
if (this.isEquationNode(result) && this.query.isDatePeriodsColumnsType()) {
|
||||||
|
result = this.assocEquationNodeDatePeriod(accNodes, (node as any).equation, result);
|
||||||
|
}
|
||||||
|
return result as IBudgetVsActualEquationNode;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
private parseSchemaChildren = (
|
||||||
|
children: any[],
|
||||||
|
): IBudgetVsActualNode[] => {
|
||||||
|
return this.mapNodesDeep(children, this.schemaNodeMapper);
|
||||||
|
};
|
||||||
|
|
||||||
|
private schemaNodeMapper = (node: any): IBudgetVsActualNode => {
|
||||||
|
let result: any = node;
|
||||||
|
if (this.isAccountsNode(node)) {
|
||||||
|
result = this.accountsSchemaNodeCompose(node);
|
||||||
|
}
|
||||||
|
return result as IBudgetVsActualNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
private reportSchemaAccountsNodesCompose = (
|
||||||
|
schemaNodes: any[],
|
||||||
|
): IBudgetVsActualNode[] => {
|
||||||
|
return this.mapNodesDeep(schemaNodes, (node: any) => {
|
||||||
|
let result: any = node;
|
||||||
|
if (this.isAccountsNode(node)) {
|
||||||
|
result = this.accountsSchemaNodeCompose(node);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private reportSchemaEquationNodesCompose = (
|
||||||
|
nodes: any[],
|
||||||
|
): IBudgetVsActualNode[] => {
|
||||||
|
return this.mapAccNodesDeep(
|
||||||
|
nodes,
|
||||||
|
(node: any, key: number, parentValue: any, accNodes: any[], context: any) => {
|
||||||
|
let result: any = node;
|
||||||
|
if (this.isEquationNode(node)) {
|
||||||
|
result = this.equationSchemaNodeCompose(accNodes, node);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private reportFilterPlugin = (nodes: IBudgetVsActualNode[]): IBudgetVsActualNode[] => {
|
||||||
|
if (!this.query.query.noneZero && !this.query.query.noneTransactions) {
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
return this.filterNodesDeep(nodes, (node: IBudgetVsActualNode) => {
|
||||||
|
if (node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNT) {
|
||||||
|
const accountNode = node as IBudgetVsActualAccountNode;
|
||||||
|
if (this.query.query.noneZero) {
|
||||||
|
return (
|
||||||
|
accountNode.budget.amount !== 0 ||
|
||||||
|
accountNode.actual.amount !== 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.query.query.noneTransactions) {
|
||||||
|
return accountNode.actual.amount !== 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
public reportData = (): IBudgetVsActualNode[] => {
|
||||||
|
const schema = this.getSchema();
|
||||||
|
|
||||||
|
return R.compose(
|
||||||
|
this.reportFilterPlugin,
|
||||||
|
this.reportSchemaEquationNodesCompose,
|
||||||
|
this.reportSchemaAccountsNodesCompose,
|
||||||
|
)(schema);
|
||||||
|
};
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
IFinancialCommonHorizDatePeriodNode,
|
||||||
|
} from '../../types/Report.types';
|
||||||
|
|
||||||
|
export interface IBudgetVsActualTotal {
|
||||||
|
amount: number;
|
||||||
|
formattedAmount: string;
|
||||||
|
currencyCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetVsActualPercentage {
|
||||||
|
amount: number;
|
||||||
|
formattedAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetVsActualAccountNode {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
nodeType: 'ACCOUNT';
|
||||||
|
budget: IBudgetVsActualTotal;
|
||||||
|
actual: IBudgetVsActualTotal;
|
||||||
|
variance: IBudgetVsActualTotal;
|
||||||
|
variancePercentage: IBudgetVsActualPercentage;
|
||||||
|
horizontalBudgets?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalActuals?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalVariances?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalVariancePercentages?: IBudgetVsActualDatePeriodPercentage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetVsActualAggregateNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nodeType: 'ACCOUNTS' | 'AGGREGATE';
|
||||||
|
budget: IBudgetVsActualTotal;
|
||||||
|
actual: IBudgetVsActualTotal;
|
||||||
|
variance: IBudgetVsActualTotal;
|
||||||
|
variancePercentage: IBudgetVsActualPercentage;
|
||||||
|
children: IBudgetVsActualNode[];
|
||||||
|
horizontalBudgets?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalActuals?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalVariances?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalVariancePercentages?: IBudgetVsActualDatePeriodPercentage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetVsActualEquationNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nodeType: 'EQUATION';
|
||||||
|
budget: IBudgetVsActualTotal;
|
||||||
|
actual: IBudgetVsActualTotal;
|
||||||
|
variance: IBudgetVsActualTotal;
|
||||||
|
variancePercentage: IBudgetVsActualPercentage;
|
||||||
|
horizontalBudgets?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalActuals?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalVariances?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalVariancePercentages?: IBudgetVsActualDatePeriodPercentage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetVsActualNetIncomeNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nodeType: 'NET_INCOME';
|
||||||
|
budget: IBudgetVsActualTotal;
|
||||||
|
actual: IBudgetVsActualTotal;
|
||||||
|
variance: IBudgetVsActualTotal;
|
||||||
|
variancePercentage: IBudgetVsActualPercentage;
|
||||||
|
horizontalBudgets?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalActuals?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalVariances?: IBudgetVsActualDatePeriodTotal[];
|
||||||
|
horizontalVariancePercentages?: IBudgetVsActualDatePeriodPercentage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IBudgetVsActualNode =
|
||||||
|
| IBudgetVsActualAccountNode
|
||||||
|
| IBudgetVsActualAggregateNode
|
||||||
|
| IBudgetVsActualEquationNode
|
||||||
|
| IBudgetVsActualNetIncomeNode;
|
||||||
|
|
||||||
|
export interface IBudgetVsActualDatePeriodTotal
|
||||||
|
extends IFinancialCommonHorizDatePeriodNode {
|
||||||
|
budget: IBudgetVsActualTotal;
|
||||||
|
actual: IBudgetVsActualTotal;
|
||||||
|
variance: IBudgetVsActualTotal;
|
||||||
|
variancePercentage: IBudgetVsActualPercentage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetVsActualDatePeriodPercentage {
|
||||||
|
fromDate: { date: Date; formattedDate: string };
|
||||||
|
toDate: { date: Date; formattedDate: string };
|
||||||
|
amount: number;
|
||||||
|
formattedAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetVsActualQuery {
|
||||||
|
budgetId: number;
|
||||||
|
fromDate?: Date | string;
|
||||||
|
toDate?: Date | string;
|
||||||
|
displayColumnsType?: string;
|
||||||
|
displayColumnsBy?: string;
|
||||||
|
numberFormat?: any;
|
||||||
|
noneZero?: boolean;
|
||||||
|
noneTransactions?: boolean;
|
||||||
|
accountsIds?: number[];
|
||||||
|
branchesIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetVsActualMeta {
|
||||||
|
organizationName: string;
|
||||||
|
baseCurrency: string;
|
||||||
|
dateFormat: string;
|
||||||
|
sheetName: string;
|
||||||
|
budgetName: string;
|
||||||
|
budgetType: string;
|
||||||
|
periodType: string;
|
||||||
|
fromDate: { date: Date; formattedDate: string };
|
||||||
|
toDate: { date: Date; formattedDate: string };
|
||||||
|
isCostComputeRunning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBudgetVsActualSheetData {
|
||||||
|
data: IBudgetVsActualNode[];
|
||||||
|
query: IBudgetVsActualQuery;
|
||||||
|
meta: IBudgetVsActualMeta;
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { BudgetVsActualService } from './BudgetVsActualService';
|
||||||
|
import { IBudgetVsActualQuery } from './BudgetVsActual.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BudgetVsActualApplication {
|
||||||
|
constructor(private readonly budgetVsActualService: BudgetVsActualService) {}
|
||||||
|
|
||||||
|
public sheet = (query: IBudgetVsActualQuery) => {
|
||||||
|
return this.budgetVsActualService.budgetVsActual(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
public table = (query: IBudgetVsActualQuery) => {
|
||||||
|
return this.budgetVsActualService.budgetVsActual(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
public csv = (query: IBudgetVsActualQuery) => {
|
||||||
|
return this.budgetVsActualService.budgetVsActual(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
public xlsx = (query: IBudgetVsActualQuery) => {
|
||||||
|
return this.budgetVsActualService.budgetVsActual(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
public pdf = (query: IBudgetVsActualQuery) => {
|
||||||
|
return this.budgetVsActualService.budgetVsActual(query);
|
||||||
|
};
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
import * as R from 'ramda';
|
||||||
|
import { sumBy } from 'lodash';
|
||||||
|
import { GConstructor } from '@/common/types/Constructor';
|
||||||
|
import { BUDGET_VS_ACTUAL_NODE_TYPE } from './constants';
|
||||||
|
|
||||||
|
interface IBudgetVsActualBaseContext {
|
||||||
|
repository: { budget: { budgetType: string } };
|
||||||
|
query: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BudgetVsActualBase = <T extends GConstructor<any>>(
|
||||||
|
Base: T,
|
||||||
|
) =>
|
||||||
|
class extends Base {
|
||||||
|
public isNodeType = R.curry((type: string, node: { nodeType: string }): boolean => {
|
||||||
|
return node.nodeType === type;
|
||||||
|
});
|
||||||
|
|
||||||
|
public isSchemaNodeType = R.curry((type: string, node: { nodeType?: string; type?: string }): boolean => {
|
||||||
|
return node.nodeType === type || node.type === type;
|
||||||
|
});
|
||||||
|
|
||||||
|
public isAccountNode = (node: { nodeType: string }): boolean => {
|
||||||
|
return node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNT;
|
||||||
|
};
|
||||||
|
|
||||||
|
public isAccountsNode = (node: { nodeType?: string; type?: string }): boolean => {
|
||||||
|
return (
|
||||||
|
node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNTS ||
|
||||||
|
node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.AGGREGATE ||
|
||||||
|
node.type === BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNTS ||
|
||||||
|
node.type === BUDGET_VS_ACTUAL_NODE_TYPE.AGGREGATE
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
public isEquationNode = (node: { nodeType: string }): boolean => {
|
||||||
|
return node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.EQUATION;
|
||||||
|
};
|
||||||
|
|
||||||
|
public isNetIncomeNode = (node: { nodeType: string }): boolean => {
|
||||||
|
return node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.NET_INCOME;
|
||||||
|
};
|
||||||
|
|
||||||
|
public isProfitAndLoss = (): boolean => {
|
||||||
|
const ctx = this as unknown as IBudgetVsActualBaseContext;
|
||||||
|
return ctx.repository.budget.budgetType === 'profit_and_loss';
|
||||||
|
};
|
||||||
|
|
||||||
|
public isBalanceSheet = (): boolean => {
|
||||||
|
const ctx = this as unknown as IBudgetVsActualBaseContext;
|
||||||
|
return ctx.repository.budget.budgetType === 'balance_sheet';
|
||||||
|
};
|
||||||
|
|
||||||
|
protected getVariance = (actual: number, budget: number): number => {
|
||||||
|
return actual - budget;
|
||||||
|
};
|
||||||
|
|
||||||
|
protected getVariancePercentage = (
|
||||||
|
actual: number,
|
||||||
|
budget: number,
|
||||||
|
): number => {
|
||||||
|
if (budget === 0) return 0;
|
||||||
|
return (actual - budget) / Math.abs(budget);
|
||||||
|
};
|
||||||
|
|
||||||
|
protected getBudgetOfNodes = (nodes: Array<{ budget: { amount: number } }>): number => {
|
||||||
|
return sumBy(nodes, 'budget.amount');
|
||||||
|
};
|
||||||
|
|
||||||
|
protected getActualOfNodes = (nodes: Array<{ actual: { amount: number } }>): number => {
|
||||||
|
return sumBy(nodes, 'actual.amount');
|
||||||
|
};
|
||||||
|
};
|
||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
import * as R from 'ramda';
|
||||||
|
import * as moment from 'moment';
|
||||||
|
import { sumBy } from 'lodash';
|
||||||
|
import { GConstructor } from '@/common/types/Constructor';
|
||||||
|
import { FinancialSheet } from '../../common/FinancialSheet';
|
||||||
|
import { BudgetVsActualRepository } from './BudgetVsActualRepository';
|
||||||
|
import {
|
||||||
|
IBudgetVsActualAccountNode,
|
||||||
|
IBudgetVsActualAggregateNode,
|
||||||
|
IBudgetVsActualEquationNode,
|
||||||
|
IBudgetVsActualDatePeriodPercentage,
|
||||||
|
} from './BudgetVsActual.types';
|
||||||
|
|
||||||
|
type BudgetVsActualDatePeriodsContext = FinancialSheet & {
|
||||||
|
repository: BudgetVsActualRepository;
|
||||||
|
query: any;
|
||||||
|
isProfitAndLoss: () => boolean;
|
||||||
|
getVariance: (actual: number, budget: number) => number;
|
||||||
|
getVariancePercentage: (actual: number, budget: number) => number;
|
||||||
|
getDateRanges: (fromDate: Date | string, toDate: Date | string, unit: moment.unitOfTime.StartOf) => Array<{ fromDate: Date; toDate: Date }>;
|
||||||
|
getDatePeriodTotalMeta: (total: number, fromDate: Date, toDate: Date) => any;
|
||||||
|
getDateMeta: (date: moment.MomentInput, format?: string) => { date: Date; formattedDate: string };
|
||||||
|
formatPercentage: (amount: number) => string;
|
||||||
|
getNodesTableForEvaluating: (path: string, nodes: any[]) => Record<string, number>;
|
||||||
|
evaluateEquation: (equation: string, scope: Record<string, number>) => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BudgetVsActualDatePeriods = <T extends GConstructor<any>>(
|
||||||
|
Base: T,
|
||||||
|
) =>
|
||||||
|
class extends Base {
|
||||||
|
public assocAccountNodeDatePeriods = (node: IBudgetVsActualAccountNode): IBudgetVsActualAccountNode => {
|
||||||
|
const ctx = this as unknown as BudgetVsActualDatePeriodsContext;
|
||||||
|
const fromDate = ctx.query.fromDate || ctx.repository.budget.startDate;
|
||||||
|
const toDate = ctx.query.toDate || ctx.repository.budget.endDate;
|
||||||
|
const periodsUnit = ctx.query.getPeriodsUnit();
|
||||||
|
|
||||||
|
const dateRanges = ctx.getDateRanges(fromDate, toDate, periodsUnit);
|
||||||
|
|
||||||
|
const horizontalBudgets: any[] = [];
|
||||||
|
const horizontalActuals: any[] = [];
|
||||||
|
const horizontalVariances: any[] = [];
|
||||||
|
const horizontalVariancePercentages: IBudgetVsActualDatePeriodPercentage[] = [];
|
||||||
|
|
||||||
|
dateRanges.forEach((dateRange) => {
|
||||||
|
const periodKey = `${moment(dateRange.fromDate).format('YYYY-MM-DD')}_${moment(dateRange.toDate).format('YYYY-MM-DD')}`;
|
||||||
|
|
||||||
|
const childrenAccountIds: number[] = ctx.repository.accountsGraph.dependenciesOf(
|
||||||
|
node.id,
|
||||||
|
);
|
||||||
|
const accountIds = R.uniq(R.append(node.id, childrenAccountIds));
|
||||||
|
|
||||||
|
let budgetPeriodAmount = 0;
|
||||||
|
const periodBudgetMap = ctx.repository.budgetPeriodsByAccount?.get(periodKey);
|
||||||
|
if (periodBudgetMap) {
|
||||||
|
accountIds.forEach((id: number) => {
|
||||||
|
budgetPeriodAmount += periodBudgetMap.get(id) || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let actualPeriodAmount = 0;
|
||||||
|
if (ctx.isProfitAndLoss()) {
|
||||||
|
const periodLedger = ctx.repository.periodsAccountsLedger
|
||||||
|
?.whereAccountsIds(accountIds)
|
||||||
|
?.whereFromDate(dateRange.fromDate)
|
||||||
|
?.whereToDate(dateRange.toDate);
|
||||||
|
actualPeriodAmount = periodLedger?.getClosingBalance() || 0;
|
||||||
|
} else {
|
||||||
|
const closingToDate = ctx.repository.periodsAccountsLedger
|
||||||
|
?.whereAccountsIds(accountIds)
|
||||||
|
?.whereToDate(dateRange.toDate);
|
||||||
|
const openingToDate = ctx.repository.periodsOpeningAccountLedger
|
||||||
|
?.whereAccountsIds(accountIds)
|
||||||
|
?.whereToDate(dateRange.fromDate);
|
||||||
|
|
||||||
|
const closing = closingToDate?.getClosingBalance() || 0;
|
||||||
|
const opening = openingToDate?.getClosingBalance() || 0;
|
||||||
|
actualPeriodAmount = closing - opening;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variance = ctx.getVariance(actualPeriodAmount, budgetPeriodAmount);
|
||||||
|
const variancePct = ctx.getVariancePercentage(actualPeriodAmount, budgetPeriodAmount);
|
||||||
|
|
||||||
|
horizontalBudgets.push(
|
||||||
|
ctx.getDatePeriodTotalMeta(budgetPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||||
|
);
|
||||||
|
horizontalActuals.push(
|
||||||
|
ctx.getDatePeriodTotalMeta(actualPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||||
|
);
|
||||||
|
horizontalVariances.push(
|
||||||
|
ctx.getDatePeriodTotalMeta(variance, dateRange.fromDate, dateRange.toDate),
|
||||||
|
);
|
||||||
|
horizontalVariancePercentages.push({
|
||||||
|
fromDate: ctx.getDateMeta(dateRange.fromDate),
|
||||||
|
toDate: ctx.getDateMeta(dateRange.toDate),
|
||||||
|
amount: variancePct,
|
||||||
|
formattedAmount: ctx.formatPercentage(variancePct),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
horizontalBudgets,
|
||||||
|
horizontalActuals,
|
||||||
|
horizontalVariances,
|
||||||
|
horizontalVariancePercentages,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
public assocAggregateDatePeriod = (node: IBudgetVsActualAggregateNode): IBudgetVsActualAggregateNode => {
|
||||||
|
if (!node.children || node.children.length === 0) return node;
|
||||||
|
|
||||||
|
const ctx = this as unknown as BudgetVsActualDatePeriodsContext;
|
||||||
|
const fromDate = ctx.query.fromDate || ctx.repository.budget.startDate;
|
||||||
|
const toDate = ctx.query.toDate || ctx.repository.budget.endDate;
|
||||||
|
const periodsUnit = ctx.query.getPeriodsUnit();
|
||||||
|
const dateRanges = ctx.getDateRanges(fromDate, toDate, periodsUnit);
|
||||||
|
|
||||||
|
const horizontalBudgets: any[] = [];
|
||||||
|
const horizontalActuals: any[] = [];
|
||||||
|
const horizontalVariances: any[] = [];
|
||||||
|
const horizontalVariancePercentages: IBudgetVsActualDatePeriodPercentage[] = [];
|
||||||
|
|
||||||
|
dateRanges.forEach((dateRange, index) => {
|
||||||
|
const budgetPeriodAmount = sumBy(
|
||||||
|
node.children as any[],
|
||||||
|
`horizontalBudgets.${index}.total.amount`,
|
||||||
|
);
|
||||||
|
const actualPeriodAmount = sumBy(
|
||||||
|
node.children as any[],
|
||||||
|
`horizontalActuals.${index}.total.amount`,
|
||||||
|
);
|
||||||
|
const variance = ctx.getVariance(actualPeriodAmount, budgetPeriodAmount);
|
||||||
|
const variancePct = ctx.getVariancePercentage(actualPeriodAmount, budgetPeriodAmount);
|
||||||
|
|
||||||
|
horizontalBudgets.push(
|
||||||
|
ctx.getDatePeriodTotalMeta(budgetPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||||
|
);
|
||||||
|
horizontalActuals.push(
|
||||||
|
ctx.getDatePeriodTotalMeta(actualPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||||
|
);
|
||||||
|
horizontalVariances.push(
|
||||||
|
ctx.getDatePeriodTotalMeta(variance, dateRange.fromDate, dateRange.toDate),
|
||||||
|
);
|
||||||
|
horizontalVariancePercentages.push({
|
||||||
|
fromDate: ctx.getDateMeta(dateRange.fromDate),
|
||||||
|
toDate: ctx.getDateMeta(dateRange.toDate),
|
||||||
|
amount: variancePct,
|
||||||
|
formattedAmount: ctx.formatPercentage(variancePct),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
horizontalBudgets,
|
||||||
|
horizontalActuals,
|
||||||
|
horizontalVariances,
|
||||||
|
horizontalVariancePercentages,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
public assocEquationNodeDatePeriod = R.curry(
|
||||||
|
(
|
||||||
|
accNodes: any[],
|
||||||
|
equation: string,
|
||||||
|
node: IBudgetVsActualEquationNode,
|
||||||
|
): IBudgetVsActualEquationNode => {
|
||||||
|
const ctx = this as unknown as BudgetVsActualDatePeriodsContext;
|
||||||
|
const fromDate = ctx.query.fromDate || ctx.repository.budget.startDate;
|
||||||
|
const toDate = ctx.query.toDate || ctx.repository.budget.endDate;
|
||||||
|
const periodsUnit = ctx.query.getPeriodsUnit();
|
||||||
|
const dateRanges = ctx.getDateRanges(fromDate, toDate, periodsUnit);
|
||||||
|
|
||||||
|
const horizontalBudgets: any[] = [];
|
||||||
|
const horizontalActuals: any[] = [];
|
||||||
|
const horizontalVariances: any[] = [];
|
||||||
|
const horizontalVariancePercentages: IBudgetVsActualDatePeriodPercentage[] = [];
|
||||||
|
|
||||||
|
dateRanges.forEach((dateRange, index) => {
|
||||||
|
const budgetTable = ctx.getNodesTableForEvaluating(
|
||||||
|
`horizontalBudgets.${index}.total.amount`,
|
||||||
|
accNodes,
|
||||||
|
) as Record<string, number>;
|
||||||
|
const budgetPeriodAmount = ctx.evaluateEquation(equation, budgetTable);
|
||||||
|
|
||||||
|
const actualTable = ctx.getNodesTableForEvaluating(
|
||||||
|
`horizontalActuals.${index}.total.amount`,
|
||||||
|
accNodes,
|
||||||
|
) as Record<string, number>;
|
||||||
|
const actualPeriodAmount = ctx.evaluateEquation(equation, actualTable);
|
||||||
|
|
||||||
|
const variance = ctx.getVariance(actualPeriodAmount, budgetPeriodAmount);
|
||||||
|
const variancePct = ctx.getVariancePercentage(actualPeriodAmount, budgetPeriodAmount);
|
||||||
|
|
||||||
|
horizontalBudgets.push(
|
||||||
|
ctx.getDatePeriodTotalMeta(budgetPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||||
|
);
|
||||||
|
horizontalActuals.push(
|
||||||
|
ctx.getDatePeriodTotalMeta(actualPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||||
|
);
|
||||||
|
horizontalVariances.push(
|
||||||
|
ctx.getDatePeriodTotalMeta(variance, dateRange.fromDate, dateRange.toDate),
|
||||||
|
);
|
||||||
|
horizontalVariancePercentages.push({
|
||||||
|
fromDate: ctx.getDateMeta(dateRange.fromDate),
|
||||||
|
toDate: ctx.getDateMeta(dateRange.toDate),
|
||||||
|
amount: variancePct,
|
||||||
|
formattedAmount: ctx.formatPercentage(variancePct),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
horizontalBudgets,
|
||||||
|
horizontalActuals,
|
||||||
|
horizontalVariances,
|
||||||
|
horizontalVariancePercentages,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { FinancialSheetMeta } from '../../common/FinancialSheetMeta';
|
||||||
|
import { IBudgetVsActualQuery, IBudgetVsActualMeta } from './BudgetVsActual.types';
|
||||||
|
import * as moment from 'moment';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BudgetVsActualMeta {
|
||||||
|
constructor(private readonly financialSheetMeta: FinancialSheetMeta) {}
|
||||||
|
|
||||||
|
public meta = async (
|
||||||
|
query: IBudgetVsActualQuery,
|
||||||
|
budget: any,
|
||||||
|
): Promise<IBudgetVsActualMeta> => {
|
||||||
|
const meta = await this.financialSheetMeta.meta();
|
||||||
|
const dateFormat = meta.dateFormat || 'YYYY MMM DD';
|
||||||
|
|
||||||
|
const fromDateVal = query.fromDate || budget.startDate;
|
||||||
|
const toDateVal = query.toDate || budget.endDate;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
sheetName: 'Budget vs Actual',
|
||||||
|
budgetName: budget.name,
|
||||||
|
budgetType: budget.budgetType,
|
||||||
|
periodType: budget.periodType,
|
||||||
|
fromDate: {
|
||||||
|
date: moment(fromDateVal).toDate(),
|
||||||
|
formattedDate: moment(fromDateVal).format(dateFormat),
|
||||||
|
},
|
||||||
|
toDate: {
|
||||||
|
date: moment(toDateVal).toDate(),
|
||||||
|
formattedDate: moment(toDateVal).format(dateFormat),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ToNumber } from '@/common/decorators/Validators';
|
||||||
|
import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto';
|
||||||
|
import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class BudgetVsActualQueryDto {
|
||||||
|
@ApiProperty({ description: 'Budget ID', example: 1 })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@ToNumber()
|
||||||
|
@IsInt()
|
||||||
|
budgetId: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'From date (defaults to budget start date)',
|
||||||
|
example: '2026-01-01',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
fromDate: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'To date (defaults to budget end date)',
|
||||||
|
example: '2026-12-31',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
toDate: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Display columns type',
|
||||||
|
enum: ['total', 'date_periods'],
|
||||||
|
example: 'total',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['total', 'date_periods'])
|
||||||
|
displayColumnsType: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Display columns by period',
|
||||||
|
enum: ['month', 'quarter', 'year'],
|
||||||
|
example: 'month',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['month', 'quarter', 'year'])
|
||||||
|
displayColumnsBy: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Number format settings' })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => NumberFormatQueryDto)
|
||||||
|
numberFormat: NumberFormatQueryDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by specific account IDs',
|
||||||
|
type: [Number],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ToNumber()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
accountsIds: number[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by branch IDs',
|
||||||
|
type: [Number],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ToNumber()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
branchesIds: number[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Exclude zero rows' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
noneZero: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Exclude accounts with no transactions' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
noneTransactions: boolean;
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
import * as moment from 'moment';
|
||||||
|
import { IBudgetVsActualQuery } from './BudgetVsActual.types';
|
||||||
|
import { BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE } from './constants';
|
||||||
|
import { IAccountTransactionsGroupBy } from '../../types/Report.types';
|
||||||
|
|
||||||
|
export class BudgetVsActualQuery {
|
||||||
|
readonly query: IBudgetVsActualQuery;
|
||||||
|
|
||||||
|
constructor(query: IBudgetVsActualQuery) {
|
||||||
|
this.query = {
|
||||||
|
...query,
|
||||||
|
displayColumnsType:
|
||||||
|
query.displayColumnsType ||
|
||||||
|
BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE.TOTAL,
|
||||||
|
displayColumnsBy: query.displayColumnsBy || 'month',
|
||||||
|
numberFormat: query.numberFormat || {
|
||||||
|
precision: 2,
|
||||||
|
divideOn1000: false,
|
||||||
|
showZero: false,
|
||||||
|
formatMoney: 'total',
|
||||||
|
negativeFormat: 'mines',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get budgetId(): number {
|
||||||
|
return this.query.budgetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get fromDate(): Date | string {
|
||||||
|
return this.query.fromDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
get toDate(): Date | string {
|
||||||
|
return this.query.toDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
get displayColumnsType(): string {
|
||||||
|
return this.query.displayColumnsType;
|
||||||
|
}
|
||||||
|
|
||||||
|
get displayColumnsBy(): string {
|
||||||
|
return this.query.displayColumnsBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
get accountsIds(): number[] {
|
||||||
|
return this.query.accountsIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
get branchesIds(): number[] {
|
||||||
|
return this.query.branchesIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
get numberFormat() {
|
||||||
|
return this.query.numberFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isDatePeriodsColumnsType = (): boolean => {
|
||||||
|
return (
|
||||||
|
this.displayColumnsType ===
|
||||||
|
BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE.DATE_PERIODS
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
public getGroupByType = (): IAccountTransactionsGroupBy => {
|
||||||
|
const pairs = {
|
||||||
|
month: IAccountTransactionsGroupBy.Month,
|
||||||
|
quarter: IAccountTransactionsGroupBy.Quarter,
|
||||||
|
year: IAccountTransactionsGroupBy.Year,
|
||||||
|
day: IAccountTransactionsGroupBy.Day,
|
||||||
|
};
|
||||||
|
return pairs[this.displayColumnsBy] || IAccountTransactionsGroupBy.Month;
|
||||||
|
};
|
||||||
|
|
||||||
|
public getPeriodsUnit = (): moment.unitOfTime.StartOf => {
|
||||||
|
const pairs = {
|
||||||
|
month: 'month' as moment.unitOfTime.StartOf,
|
||||||
|
quarter: 'quarter' as moment.unitOfTime.StartOf,
|
||||||
|
year: 'year' as moment.unitOfTime.StartOf,
|
||||||
|
day: 'day' as moment.unitOfTime.StartOf,
|
||||||
|
};
|
||||||
|
return pairs[this.displayColumnsBy] || 'month';
|
||||||
|
};
|
||||||
|
}
|
||||||
+316
@@ -0,0 +1,316 @@
|
|||||||
|
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||||
|
import { ModelObject } from 'objection';
|
||||||
|
import * as R from 'ramda';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { isEmpty, castArray, sumBy } from 'lodash';
|
||||||
|
import * as moment from 'moment';
|
||||||
|
import { transformToMapBy } from '@/utils/transform-to-map-by';
|
||||||
|
import { BudgetVsActualQuery } from './BudgetVsActualQuery';
|
||||||
|
import { Ledger } from '@/modules/Ledger/Ledger';
|
||||||
|
import { IBudgetVsActualQuery } from './BudgetVsActual.types';
|
||||||
|
import { IAccountTransactionsGroupBy } from '../../types/Report.types';
|
||||||
|
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||||
|
import { FinancialDatePeriods } from '../../common/FinancialDatePeriods';
|
||||||
|
import { AccountTransaction } from '@/modules/Accounts/models/AccountTransaction.model';
|
||||||
|
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
|
||||||
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { Budget } from '@/modules/Budgeting/models/Budget.model';
|
||||||
|
import { BudgetEntry } from '@/modules/Budgeting/models/BudgetEntry.model';
|
||||||
|
import { BUDGET_TYPE } from './constants';
|
||||||
|
|
||||||
|
@Injectable({ scope: Scope.TRANSIENT })
|
||||||
|
export class BudgetVsActualRepository extends R.compose(
|
||||||
|
FinancialDatePeriods,
|
||||||
|
)(class {} as any) {
|
||||||
|
@Inject(Account.name)
|
||||||
|
public accountModel: TenantModelProxy<typeof Account>;
|
||||||
|
|
||||||
|
@Inject(AccountTransaction.name)
|
||||||
|
public accountTransactionModel: TenantModelProxy<typeof AccountTransaction>;
|
||||||
|
|
||||||
|
@Inject(Budget.name)
|
||||||
|
public budgetModel: TenantModelProxy<typeof Budget>;
|
||||||
|
|
||||||
|
@Inject(BudgetEntry.name)
|
||||||
|
public budgetEntryModel: TenantModelProxy<typeof BudgetEntry>;
|
||||||
|
|
||||||
|
@Inject(TenancyContext)
|
||||||
|
public tenancyContext: TenancyContext;
|
||||||
|
|
||||||
|
public baseCurrency: string;
|
||||||
|
public accounts: ModelObject<Account>[];
|
||||||
|
public accountsByType: Map<string, ModelObject<Account>[]>;
|
||||||
|
public accountsByParentType: Map<string, ModelObject<Account>[]>;
|
||||||
|
public accountsGraph: any;
|
||||||
|
|
||||||
|
public query: BudgetVsActualQuery;
|
||||||
|
public transactionsGroupType: IAccountTransactionsGroupBy =
|
||||||
|
IAccountTransactionsGroupBy.Month;
|
||||||
|
|
||||||
|
public budget: Budget;
|
||||||
|
|
||||||
|
public totalAccountsLedger: Ledger;
|
||||||
|
public periodsAccountsLedger: Ledger;
|
||||||
|
public periodsOpeningAccountLedger: Ledger;
|
||||||
|
|
||||||
|
public budgetTotalByAccount: Map<number, number>;
|
||||||
|
public budgetPeriodsByAccount: Map<string, Map<number, number>>;
|
||||||
|
|
||||||
|
setFilter(query: IBudgetVsActualQuery) {
|
||||||
|
this.query = new BudgetVsActualQuery(query);
|
||||||
|
|
||||||
|
this.transactionsGroupType = this.getGroupByFromDisplayColumnsBy(
|
||||||
|
this.query.displayColumnsBy as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public asyncInitialize = async () => {
|
||||||
|
await this.initBaseCurrency();
|
||||||
|
await this.initBudget();
|
||||||
|
await this.initAccounts();
|
||||||
|
await this.initAccountsGraph();
|
||||||
|
|
||||||
|
if (this.isProfitAndLoss()) {
|
||||||
|
await this.initAccountsTotalLedger();
|
||||||
|
} else {
|
||||||
|
await this.initClosingAccountsTotalLedger();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.initBudgetEntries();
|
||||||
|
|
||||||
|
if (this.query.isDatePeriodsColumnsType()) {
|
||||||
|
if (this.isProfitAndLoss()) {
|
||||||
|
await this.initTotalDatePeriods();
|
||||||
|
} else {
|
||||||
|
await this.initBSTotalDatePeriods();
|
||||||
|
}
|
||||||
|
await this.initBudgetDatePeriods();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private isProfitAndLoss = (): boolean => {
|
||||||
|
return this.budget.budgetType === BUDGET_TYPE.PROFIT_AND_LOSS;
|
||||||
|
};
|
||||||
|
|
||||||
|
private initBaseCurrency = async () => {
|
||||||
|
const metadata = await this.tenancyContext.getTenantMetadata();
|
||||||
|
this.baseCurrency = metadata.baseCurrency;
|
||||||
|
};
|
||||||
|
|
||||||
|
private initBudget = async () => {
|
||||||
|
this.budget = await this.budgetModel()
|
||||||
|
.query()
|
||||||
|
.findById(this.query.budgetId)
|
||||||
|
.withGraphFetched('entries');
|
||||||
|
};
|
||||||
|
|
||||||
|
private initAccounts = async () => {
|
||||||
|
const accounts = await this.getAccounts();
|
||||||
|
this.accounts = accounts;
|
||||||
|
this.accountsByType = transformToMapBy(accounts, 'accountType');
|
||||||
|
this.accountsByParentType = transformToMapBy(accounts, 'accountParentType');
|
||||||
|
};
|
||||||
|
|
||||||
|
private initAccountsGraph = async () => {
|
||||||
|
this.accountsGraph = this.accountModel().toDependencyGraph(this.accounts);
|
||||||
|
};
|
||||||
|
|
||||||
|
private initAccountsTotalLedger = async () => {
|
||||||
|
const totalByAccount = await this.accountsTotal(
|
||||||
|
this.query.fromDate,
|
||||||
|
this.query.toDate,
|
||||||
|
);
|
||||||
|
this.totalAccountsLedger = Ledger.fromTransactions(totalByAccount);
|
||||||
|
};
|
||||||
|
|
||||||
|
private initClosingAccountsTotalLedger = async () => {
|
||||||
|
const totalByAccount = await this.closingAccountsTotal(
|
||||||
|
this.query.toDate,
|
||||||
|
);
|
||||||
|
this.totalAccountsLedger = Ledger.fromTransactions(totalByAccount);
|
||||||
|
};
|
||||||
|
|
||||||
|
private initTotalDatePeriods = async () => {
|
||||||
|
const periodsByAccount = await this.accountsDatePeriods(
|
||||||
|
this.query.fromDate,
|
||||||
|
this.query.toDate,
|
||||||
|
this.transactionsGroupType,
|
||||||
|
);
|
||||||
|
this.periodsAccountsLedger = Ledger.fromTransactions(periodsByAccount);
|
||||||
|
};
|
||||||
|
|
||||||
|
private initBSTotalDatePeriods = async () => {
|
||||||
|
const periodsByAccount = await this.accountsDatePeriods(
|
||||||
|
this.query.fromDate,
|
||||||
|
this.query.toDate,
|
||||||
|
this.transactionsGroupType,
|
||||||
|
);
|
||||||
|
const periodsOpeningByAccount = await this.closingAccountsTotal(
|
||||||
|
this.query.fromDate,
|
||||||
|
);
|
||||||
|
this.periodsAccountsLedger = Ledger.fromTransactions(periodsByAccount);
|
||||||
|
this.periodsOpeningAccountLedger = Ledger.fromTransactions(
|
||||||
|
periodsOpeningByAccount,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private initBudgetEntries = async () => {
|
||||||
|
const q = this.budgetEntryModel()
|
||||||
|
.query()
|
||||||
|
.where('budget_id', this.budget.id);
|
||||||
|
|
||||||
|
if (this.query.fromDate) {
|
||||||
|
q.where('period_date', '>=', this.query.fromDate);
|
||||||
|
}
|
||||||
|
if (this.query.toDate) {
|
||||||
|
q.where('period_date', '<=', this.query.toDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: BudgetEntry[] = await q;
|
||||||
|
|
||||||
|
this.budgetTotalByAccount = entries.reduce((map, entry) => {
|
||||||
|
const existing = map.get(entry.accountId) || 0;
|
||||||
|
map.set(entry.accountId, existing + entry.amount);
|
||||||
|
return map;
|
||||||
|
}, new Map<number, number>());
|
||||||
|
};
|
||||||
|
|
||||||
|
private initBudgetDatePeriods = async () => {
|
||||||
|
const fromDate = this.query.fromDate || this.budget.startDate;
|
||||||
|
const toDate = this.query.toDate || this.budget.endDate;
|
||||||
|
|
||||||
|
const q = this.budgetEntryModel()
|
||||||
|
.query()
|
||||||
|
.where('budget_id', this.budget.id)
|
||||||
|
.where('period_date', '>=', fromDate)
|
||||||
|
.where('period_date', '<=', toDate);
|
||||||
|
|
||||||
|
const entries: BudgetEntry[] = await q;
|
||||||
|
|
||||||
|
const periodsMap = new Map<string, Map<number, number>>();
|
||||||
|
const dateRanges = this.getDateRanges(
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
this.query.getPeriodsUnit(),
|
||||||
|
);
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const entryDate = moment(entry.periodDate);
|
||||||
|
const periodKey = this.findPeriodKeyForDate(entryDate, dateRanges);
|
||||||
|
|
||||||
|
if (!periodsMap.has(periodKey)) {
|
||||||
|
periodsMap.set(periodKey, new Map());
|
||||||
|
}
|
||||||
|
const periodMap = periodsMap.get(periodKey);
|
||||||
|
periodMap.set(entry.accountId, (periodMap.get(entry.accountId) || 0) + entry.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.budgetPeriodsByAccount = periodsMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
private findPeriodKeyForDate = (
|
||||||
|
entryDate: moment.Moment,
|
||||||
|
dateRanges: Array<{ fromDate: Date; toDate: Date }>,
|
||||||
|
): string => {
|
||||||
|
for (const range of dateRanges) {
|
||||||
|
if (
|
||||||
|
(entryDate.isSameOrAfter(moment(range.fromDate)) &&
|
||||||
|
entryDate.isSameOrBefore(moment(range.toDate))) ||
|
||||||
|
entryDate.isSame(moment(range.fromDate), 'month')
|
||||||
|
) {
|
||||||
|
return `${moment(range.fromDate).format('YYYY-MM-DD')}_${moment(range.toDate).format('YYYY-MM-DD')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${moment(entryDate).startOf('month').format('YYYY-MM-DD')}_${moment(entryDate).endOf('month').format('YYYY-MM-DD')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
public accountsTotal = async (
|
||||||
|
fromDate: moment.MomentInput,
|
||||||
|
toDate: moment.MomentInput,
|
||||||
|
) => {
|
||||||
|
return this.accountTransactionModel()
|
||||||
|
.query()
|
||||||
|
.onBuild((query: any) => {
|
||||||
|
query.sum('credit as credit');
|
||||||
|
query.sum('debit as debit');
|
||||||
|
query.groupBy('accountId');
|
||||||
|
query.select(['accountId']);
|
||||||
|
|
||||||
|
query.modify('filterDateRange', fromDate, toDate);
|
||||||
|
query.withGraphFetched('account');
|
||||||
|
|
||||||
|
this.commonFilterBranchesQuery(query);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
public closingAccountsTotal = async (toDate: moment.MomentInput) => {
|
||||||
|
return this.accountTransactionModel()
|
||||||
|
.query()
|
||||||
|
.onBuild((query: any) => {
|
||||||
|
query.sum('credit as credit');
|
||||||
|
query.sum('debit as debit');
|
||||||
|
query.groupBy('accountId');
|
||||||
|
query.select(['accountId']);
|
||||||
|
|
||||||
|
query.modify('filterDateRange', null, toDate);
|
||||||
|
query.withGraphFetched('account');
|
||||||
|
|
||||||
|
this.commonFilterBranchesQuery(query);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
public accountsDatePeriods = async (
|
||||||
|
fromDate: moment.MomentInput,
|
||||||
|
toDate: moment.MomentInput,
|
||||||
|
datePeriodsType: IAccountTransactionsGroupBy,
|
||||||
|
) => {
|
||||||
|
return this.accountTransactionModel()
|
||||||
|
.query()
|
||||||
|
.onBuild((query: any) => {
|
||||||
|
query.sum('credit as credit');
|
||||||
|
query.sum('debit as debit');
|
||||||
|
query.groupBy('accountId');
|
||||||
|
query.select(['accountId']);
|
||||||
|
|
||||||
|
query.modify('groupByDateFormat', datePeriodsType);
|
||||||
|
query.modify('filterDateRange', fromDate, toDate);
|
||||||
|
query.withGraphFetched('account');
|
||||||
|
|
||||||
|
this.commonFilterBranchesQuery(query);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private commonFilterBranchesQuery = (query: any) => {
|
||||||
|
if (!isEmpty(this.query.branchesIds)) {
|
||||||
|
query.modify('filterByBranches', this.query.branchesIds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getAccounts = () => {
|
||||||
|
return this.accountModel().query();
|
||||||
|
};
|
||||||
|
|
||||||
|
public getAccountsByType = (type: string[] | string): ModelObject<Account>[] => {
|
||||||
|
const types = castArray(type) as string[];
|
||||||
|
const result: ModelObject<Account>[] = [];
|
||||||
|
types.forEach((accountType) => {
|
||||||
|
const accounts = this.accountsByType.get(accountType) || [];
|
||||||
|
result.push(...accounts);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
public getBudgetAmountForAccounts = (accountIds: number[]): number => {
|
||||||
|
let total = 0;
|
||||||
|
accountIds.forEach((id) => {
|
||||||
|
total += this.budgetTotalByAccount.get(id) || 0;
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
|
||||||
|
public getActualClosingBalance = (accountIds: number[]): number => {
|
||||||
|
return this.totalAccountsLedger
|
||||||
|
.whereAccountsIds(accountIds)
|
||||||
|
.getClosingBalance();
|
||||||
|
};
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
class BudgetVsActualTotalDto {
|
||||||
|
@ApiProperty({ description: 'Amount', example: 5000.0 })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Formatted amount', example: '$5,000.00' })
|
||||||
|
formattedAmount: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Currency code', example: 'USD' })
|
||||||
|
currencyCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BudgetVsActualPercentageDto {
|
||||||
|
@ApiProperty({ description: 'Percentage amount', example: 0.15 })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Formatted percentage', example: '15.00%' })
|
||||||
|
formattedAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BudgetVsActualDatePeriodDto {
|
||||||
|
fromDate: { date: Date; formattedDate: string };
|
||||||
|
toDate: { date: Date; formattedDate: string };
|
||||||
|
budget: BudgetVsActualTotalDto;
|
||||||
|
actual: BudgetVsActualTotalDto;
|
||||||
|
variance: BudgetVsActualTotalDto;
|
||||||
|
variancePercentage: BudgetVsActualPercentageDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BudgetVsActualDataNodeDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string | number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
nodeType: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
budget: BudgetVsActualTotalDto;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
actual: BudgetVsActualTotalDto;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
variance: BudgetVsActualTotalDto;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
variancePercentage: BudgetVsActualPercentageDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [BudgetVsActualDataNodeDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => BudgetVsActualDataNodeDto)
|
||||||
|
children?: BudgetVsActualDataNodeDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [BudgetVsActualDatePeriodDto] })
|
||||||
|
horizontalBudgets?: BudgetVsActualDatePeriodDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [BudgetVsActualDatePeriodDto] })
|
||||||
|
horizontalActuals?: BudgetVsActualDatePeriodDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [BudgetVsActualDatePeriodDto] })
|
||||||
|
horizontalVariances?: BudgetVsActualDatePeriodDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [BudgetVsActualDatePeriodDto] })
|
||||||
|
horizontalVariancePercentages?: BudgetVsActualDatePeriodDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BudgetVsActualMetaDto {
|
||||||
|
@ApiProperty()
|
||||||
|
organizationName: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
baseCurrency: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
dateFormat: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
sheetName: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
budgetName: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
budgetType: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
periodType: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
fromDate: { date: Date; formattedDate: string };
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
toDate: { date: Date; formattedDate: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BudgetVsActualResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
query: any;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [BudgetVsActualDataNodeDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => BudgetVsActualDataNodeDto)
|
||||||
|
data: BudgetVsActualDataNodeDto[];
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
meta: BudgetVsActualMetaDto;
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { GConstructor } from '@/common/types/Constructor';
|
||||||
|
import { FinancialSheet } from '../../common/FinancialSheet';
|
||||||
|
import { FinancialSchema } from '../../common/FinancialSchema';
|
||||||
|
import { BUDGET_TYPE } from './constants';
|
||||||
|
import { getProfitLossSheetSchema } from '../ProfitLossSheet/ProfitLossSchema';
|
||||||
|
import { getBalanceSheetSchema } from '../BalanceSheet/BalanceSheetSchema';
|
||||||
|
|
||||||
|
interface IBudgetVsActualSchemaContext {
|
||||||
|
repository: { budget: { budgetType: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BudgetVsActualSchema = <T extends GConstructor<any>>(
|
||||||
|
Base: T,
|
||||||
|
) =>
|
||||||
|
class extends FinancialSchema(Base) {
|
||||||
|
getSchema = () => {
|
||||||
|
const ctx = this as unknown as IBudgetVsActualSchemaContext;
|
||||||
|
if (ctx.repository.budget.budgetType === BUDGET_TYPE.PROFIT_AND_LOSS) {
|
||||||
|
return getProfitLossSheetSchema();
|
||||||
|
}
|
||||||
|
return getBalanceSheetSchema();
|
||||||
|
};
|
||||||
|
};
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
IBudgetVsActualQuery,
|
||||||
|
IBudgetVsActualMeta,
|
||||||
|
IBudgetVsActualNode,
|
||||||
|
} from './BudgetVsActual.types';
|
||||||
|
import { BudgetVsActualSheet } from './BudgetVsActual';
|
||||||
|
import { mergeQueryWithDefaults } from './utils';
|
||||||
|
import { BudgetVsActualRepository } from './BudgetVsActualRepository';
|
||||||
|
import { BudgetVsActualMeta } from './BudgetVsActualMeta';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { I18nService } from 'nestjs-i18n';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { ERRORS } from '@/modules/Budgeting/constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BudgetVsActualService {
|
||||||
|
constructor(
|
||||||
|
private readonly budgetVsActualMeta: BudgetVsActualMeta,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
private readonly i18nService: I18nService,
|
||||||
|
private readonly budgetVsActualRepository: BudgetVsActualRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public budgetVsActual = async (
|
||||||
|
query: IBudgetVsActualQuery,
|
||||||
|
): Promise<{
|
||||||
|
data: IBudgetVsActualNode[];
|
||||||
|
query: IBudgetVsActualQuery;
|
||||||
|
meta: IBudgetVsActualMeta;
|
||||||
|
}> => {
|
||||||
|
const filter = mergeQueryWithDefaults(query);
|
||||||
|
|
||||||
|
this.budgetVsActualRepository.setFilter(filter);
|
||||||
|
await this.budgetVsActualRepository.asyncInitialize();
|
||||||
|
|
||||||
|
if (!this.budgetVsActualRepository.budget) {
|
||||||
|
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await this.budgetVsActualMeta.meta(
|
||||||
|
filter,
|
||||||
|
this.budgetVsActualRepository.budget,
|
||||||
|
);
|
||||||
|
|
||||||
|
const budgetVsActualInstance = new BudgetVsActualSheet(
|
||||||
|
this.budgetVsActualRepository,
|
||||||
|
filter,
|
||||||
|
this.i18nService,
|
||||||
|
{ baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat },
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = budgetVsActualInstance.reportData();
|
||||||
|
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.reports.onBudgetVsActualViewed,
|
||||||
|
{ query: filter },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: filter,
|
||||||
|
data,
|
||||||
|
meta,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const BUDGET_VS_ACTUAL_NODE_TYPE = {
|
||||||
|
ACCOUNT: 'ACCOUNT',
|
||||||
|
ACCOUNTS: 'ACCOUNTS',
|
||||||
|
AGGREGATE: 'AGGREGATE',
|
||||||
|
EQUATION: 'EQUATION',
|
||||||
|
NET_INCOME: 'NET_INCOME',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE = {
|
||||||
|
TOTAL: 'total',
|
||||||
|
DATE_PERIODS: 'date_periods',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BUDGET_TYPE = {
|
||||||
|
PROFIT_AND_LOSS: 'profit_and_loss',
|
||||||
|
BALANCE_SHEET: 'balance_sheet',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { IBudgetVsActualQuery } from './BudgetVsActual.types';
|
||||||
|
import { BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE } from './constants';
|
||||||
|
|
||||||
|
export const defaultQuery: Partial<IBudgetVsActualQuery> = {
|
||||||
|
displayColumnsType: BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE.TOTAL,
|
||||||
|
displayColumnsBy: 'month',
|
||||||
|
numberFormat: {
|
||||||
|
precision: 2,
|
||||||
|
divideOn1000: false,
|
||||||
|
showZero: false,
|
||||||
|
formatMoney: 'total',
|
||||||
|
negativeFormat: 'mines',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeQueryWithDefaults = (
|
||||||
|
query: IBudgetVsActualQuery,
|
||||||
|
): IBudgetVsActualQuery => {
|
||||||
|
return {
|
||||||
|
...defaultQuery,
|
||||||
|
...query,
|
||||||
|
numberFormat: {
|
||||||
|
...defaultQuery.numberFormat,
|
||||||
|
...(query.numberFormat || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -38,6 +38,7 @@ export enum ReportsAction {
|
|||||||
READ_CASHFLOW_ACCOUNT_TRANSACTION = 'read-cashflow-account-transactions',
|
READ_CASHFLOW_ACCOUNT_TRANSACTION = 'read-cashflow-account-transactions',
|
||||||
READ_PROJECT_PROFITABILITY_SUMMARY = 'read-project-profitability-summary',
|
READ_PROJECT_PROFITABILITY_SUMMARY = 'read-project-profitability-summary',
|
||||||
READ_SALES_TAX_LIABILITY_SUMMARY = 'read-sales-tax-liability-summary',
|
READ_SALES_TAX_LIABILITY_SUMMARY = 'read-sales-tax-liability-summary',
|
||||||
|
READ_BUDGET_VS_ACTUAL = 'read-budget-vs-actual',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFinancialSheetBranchesQuery {
|
export interface IFinancialSheetBranchesQuery {
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ export enum AbilitySubject {
|
|||||||
CreditNote = 'CreditNode',
|
CreditNote = 'CreditNode',
|
||||||
VendorCredit = 'VendorCredit',
|
VendorCredit = 'VendorCredit',
|
||||||
Project = 'Project',
|
Project = 'Project',
|
||||||
TaxRate = 'TaxRate'
|
TaxRate = 'TaxRate',
|
||||||
|
Budget = 'Budget',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRoleCreatedPayload {
|
export interface IRoleCreatedPayload {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import { RefundCreditNote } from '@/modules/CreditNoteRefunds/models/RefundCredi
|
|||||||
import { VendorCredit } from '@/modules/VendorCredit/models/VendorCredit';
|
import { VendorCredit } from '@/modules/VendorCredit/models/VendorCredit';
|
||||||
import { RefundVendorCredit } from '@/modules/VendorCreditsRefund/models/RefundVendorCredit';
|
import { RefundVendorCredit } from '@/modules/VendorCreditsRefund/models/RefundVendorCredit';
|
||||||
import { PaymentReceived } from '@/modules/PaymentReceived/models/PaymentReceived';
|
import { PaymentReceived } from '@/modules/PaymentReceived/models/PaymentReceived';
|
||||||
|
import { Budget } from '@/modules/Budgeting/models/Budget.model';
|
||||||
|
import { BudgetEntry } from '@/modules/Budgeting/models/BudgetEntry.model';
|
||||||
import { Model } from 'objection';
|
import { Model } from 'objection';
|
||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
import { TenantUser } from './models/TenantUser.model';
|
import { TenantUser } from './models/TenantUser.model';
|
||||||
@@ -80,6 +82,8 @@ const models = [
|
|||||||
PaymentReceived,
|
PaymentReceived,
|
||||||
PaymentReceivedEntry,
|
PaymentReceivedEntry,
|
||||||
TenantUser,
|
TenantUser,
|
||||||
|
Budget,
|
||||||
|
BudgetEntry,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user