From 57f1b2352d75ae57a7e113df75f9113ccc19d49c Mon Sep 17 00:00:00 2001 From: Jonathan Cameron Date: Tue, 14 Nov 2023 10:41:40 +0100 Subject: [PATCH] Added budget module --- .semaphore/semaphore.yml | 1 + client/src/css/structure.css | 64 +- client/src/i18n/en/budget.json | 74 ++ client/src/i18n/en/form.json | 1 + client/src/i18n/en/general_ledger.json | 10 +- client/src/i18n/en/periods.json | 17 +- client/src/i18n/en/table.json | 1 + client/src/i18n/en/tree.json | 1 + client/src/i18n/fr/budget.json | 74 ++ client/src/i18n/fr/form.json | 1 + client/src/i18n/fr/general_ledger.json | 10 +- client/src/i18n/fr/periods.json | 17 +- client/src/i18n/fr/table.json | 1 + client/src/i18n/fr/tree.json | 1 + client/src/js/constants/bhConstants.js | 18 + client/src/js/services/PeriodService.js | 2 +- client/src/modules/accounts/accounts.js | 3 +- .../src/modules/accounts/accounts.service.js | 14 + client/src/modules/budget/budget.html | 135 ++ client/src/modules/budget/budget.js | 429 +++++++ client/src/modules/budget/budget.routes.js | 9 + client/src/modules/budget/budget.service.js | 324 +++++ .../budget/modal/editAccountBudget.html | 65 + .../modules/budget/modal/editAccountBudget.js | 134 ++ client/src/modules/budget/modal/import.html | 43 + client/src/modules/budget/modal/import.js | 70 ++ .../budget/templates/acct_label.cell.html | 11 + .../budget/templates/acct_number.cell.html | 10 + .../budget/templates/acct_type.cell.html | 10 + .../modules/budget/templates/action.tmpl.html | 18 + .../budget/templates/actuals.cell.html | 12 + .../modules/budget/templates/budget.cell.html | 11 + .../budget/templates/budgetYTD.cell.html | 11 + .../budget/templates/deviationPct.cell.html | 11 + .../templates/deviationYTDPct.cell.html | 11 + .../budget/templates/differenceYTD.cell.html | 10 + .../budget/templates/period_actuals.cell.html | 10 + .../templates/period_actuals_header.cell.html | 4 + .../budget/templates/period_budget.cell.html | 11 + .../templates/period_budget_header.cell.html | 4 + .../general-ledger/general-ledger.ctrl.js | 2 +- package.json | 10 +- server/config/constants.js | 24 + server/config/routes.js | 16 + server/controllers/finance/accounts/index.js | 42 +- server/controllers/finance/budget/index.js | 1110 +++++++++++++++++ server/controllers/finance/fiscal.js | 15 + .../finance/reports/budget/budget.handlebars | 110 ++ .../finance/reports/budget/index.js | 129 ++ server/models/bhima.sql | 3 +- server/models/migrations/next/migrate.sql | 18 +- server/models/procedures.sql | 50 + server/models/schema.sql | 13 +- .../templates/import-budget-template.csv | 5 + .../fixtures/budget-to-import-bad-headers.csv | 26 + ...import-bad-line-account-type-incorrect.csv | 26 + ...budget-to-import-bad-line-account-type.csv | 26 + .../budget-to-import-bad-line-account.csv | 27 + .../budget-to-import-bad-line-budget.csv | 26 + ...get-to-import-bad-line-negative-budget.csv | 27 + test/fixtures/budget-to-import.csv | 26 + test/integration/accountFYBalances.js | 33 + test/integration/budget/budget.js | 199 +++ test/integration/budget/import.js | 223 ++++ .../shipment/shipmentContainerTypes.js | 1 - yarn.lock | 180 +-- 66 files changed, 3872 insertions(+), 158 deletions(-) create mode 100644 client/src/i18n/en/budget.json create mode 100644 client/src/i18n/fr/budget.json create mode 100644 client/src/modules/budget/budget.html create mode 100644 client/src/modules/budget/budget.js create mode 100644 client/src/modules/budget/budget.routes.js create mode 100644 client/src/modules/budget/budget.service.js create mode 100644 client/src/modules/budget/modal/editAccountBudget.html create mode 100644 client/src/modules/budget/modal/editAccountBudget.js create mode 100644 client/src/modules/budget/modal/import.html create mode 100644 client/src/modules/budget/modal/import.js create mode 100644 client/src/modules/budget/templates/acct_label.cell.html create mode 100644 client/src/modules/budget/templates/acct_number.cell.html create mode 100644 client/src/modules/budget/templates/acct_type.cell.html create mode 100644 client/src/modules/budget/templates/action.tmpl.html create mode 100644 client/src/modules/budget/templates/actuals.cell.html create mode 100644 client/src/modules/budget/templates/budget.cell.html create mode 100644 client/src/modules/budget/templates/budgetYTD.cell.html create mode 100644 client/src/modules/budget/templates/deviationPct.cell.html create mode 100644 client/src/modules/budget/templates/deviationYTDPct.cell.html create mode 100644 client/src/modules/budget/templates/differenceYTD.cell.html create mode 100644 client/src/modules/budget/templates/period_actuals.cell.html create mode 100644 client/src/modules/budget/templates/period_actuals_header.cell.html create mode 100644 client/src/modules/budget/templates/period_budget.cell.html create mode 100644 client/src/modules/budget/templates/period_budget_header.cell.html create mode 100644 server/controllers/finance/budget/index.js create mode 100644 server/controllers/finance/reports/budget/budget.handlebars create mode 100644 server/controllers/finance/reports/budget/index.js create mode 100644 server/resources/templates/import-budget-template.csv create mode 100644 test/fixtures/budget-to-import-bad-headers.csv create mode 100644 test/fixtures/budget-to-import-bad-line-account-type-incorrect.csv create mode 100644 test/fixtures/budget-to-import-bad-line-account-type.csv create mode 100644 test/fixtures/budget-to-import-bad-line-account.csv create mode 100644 test/fixtures/budget-to-import-bad-line-budget.csv create mode 100644 test/fixtures/budget-to-import-bad-line-negative-budget.csv create mode 100644 test/fixtures/budget-to-import.csv create mode 100644 test/integration/accountFYBalances.js create mode 100644 test/integration/budget/budget.js create mode 100644 test/integration/budget/import.js diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index 81b960ce42..5e3601ee57 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -60,6 +60,7 @@ blocks: - npx playwright install - 'yarn build:db' - yarn build + - mkdir results - 'yarn test:server-unit' - 'yarn test:client-unit' - 'yarn test:integration' diff --git a/client/src/css/structure.css b/client/src/css/structure.css index 448144e887..192ab93e5f 100644 --- a/client/src/css/structure.css +++ b/client/src/css/structure.css @@ -435,6 +435,10 @@ li.selected { border-left-color : #2089CE; } +.marginTop1ex { + margin-top: 1ex; +} + /** * Temporary header breadcrumb bootstrap fork * @todo implement this in less using the bootstrap element @@ -1301,4 +1305,62 @@ form.NoSubmitButton div.modal-footer button.btn-primary { .lots-modal-grid { min-height: calc(320px); } -/* -------------------- end lots modal grid ---------------- */ \ No newline at end of file +/* -------------------- end lots modal grid ---------------- */ + +/* -------------------- budget -------------------- */ + +.budget-title { + font-weight : bold; +} + +.budget-income { + color: #007F00; +} + +.budget-expense { + color: #7F0000; +} + +.budget-total-expenses { + font-weight: bold; + background-color: #ffafaf; +} + +.budget-total-expenses-large { + font-weight: bold; + font-size: 120%; + text-transform: uppercase; + background-color: #ffafaf; +} + +.budget-total-income { + font-weight: bold; + background-color: #afffaf; +} + +.budget-total-income-large { + font-weight: bold; + font-size: 120%; + text-transform: uppercase; + background-color: #afffaf; +} + + +.budget-total-summary { + font-weight: bold; + background-color: #cfcfcf; +} + +.budget-total-summary-large { + font-weight: bold; + font-size: 120%; + text-transform: uppercase; + background-color: #cfcfcf; +} + +.budget-edit-td { + padding-bottom: 0 !important; + vertical-align: middle !important; +} + +/* -------------------- end budget -------------------- */ \ No newline at end of file diff --git a/client/src/i18n/en/budget.json b/client/src/i18n/en/budget.json new file mode 100644 index 0000000000..337408f920 --- /dev/null +++ b/client/src/i18n/en/budget.json @@ -0,0 +1,74 @@ +{ + "BUDGET": { + "TITLE": "Budget", + "ACTUALS_SUBTITLE": "(actuals)", + "BUDGET_SUBTITLE": "(budget)", + "BUDGET_YTD": "Budget (YTD)", + "BUDGET_YTD_COLUMN": "Budget", + "BUDGET_YTD_SUBTITLE": "(YTD)", + "BUDGET_YTD_TOOLTIP": "Budget totals for the year to date", + "EXPENSES_TOTAL": "Expenses Total", + "DEVIATION_YTD": "Deviation % (YTD)", + "DEVIATION_YTD_TOOLTIP": "Deviation % (YTD) = 100*(actuals_YTD/budget_YTD)", + "DIFFERENCE_YTD": "Difference (YTD)", + "DIFFERENCE_YTD_TOOLTIP": "Difference = Actuals - Budget", + "FY_ACTUALS": "FY Actuals (YTD)", + "FY_BUDGET": "FY Budget", + "FY_DEVIATION": "FY % Deviation", + "EXPORT_BUDGET": "Export Budget to CSV file", + "IMPORT_BUDGET": "Import Budget CSV file", + "IMPORT_BUDGET_BAD_HEADERS": "ERROR: Budget import failed! Check for missing or misspelled column header names. Expected: AcctNum, Label, Type, Budget, BudgetLastYear.", + "IMPORT_BUDGET_ERROR": "ERROR: Budget import failed! Check for missing or invalid data or incorrect Type values (should be 'title', 'income', or 'expense').", + "IMPORT_BUDGET_ERROR_ACCT_NUM": "ERROR: Budget import failed! Account number is not an integer.", + "IMPORT_BUDGET_ERROR_ACCT_TYPE": "ERROR: Budget import failed! Check for incorrect Type values (should be 'title', 'income', or 'expense').", + "IMPORT_BUDGET_ERROR_ACCT_TYPE_INCORRECT": "ERROR: Budget import failed! Account type does not match account in the database.", + "IMPORT_BUDGET_ERROR_BAD_BUDGET_VALUE": "ERROR: Budget import failed! Budget value is not a number.", + "IMPORT_BUDGET_ERROR_NEGATIVE_BUDGET_VALUE": "ERROR: Budget import failed! Budget value is negative.", + "IMPORT_BUDGET_WARN_TITLE_BUDGET_IGNORED": "WARNING: Budget values for title accounts are ignored", + "INCOME_TOTAL": "Income Total", + "TOTAL_SUMMARY": "Total Summary (Income - Expenses)", + "ACTUALS": { + "JANUARY": "January Actuals", + "FEBRUARY": "February Actuals", + "MARCH": "March Actuals", + "APRIL": "April Actuals", + "MAY": "May Actuals", + "JUNE": "June Actuals", + "JULY": "July Actuals", + "AUGUST": "August Actuals", + "SEPTEMBER": "September Actuals", + "OCTOBER": "October Actuals", + "NOVEMBER": "November Actuals", + "DECEMBER": "December Actuals" + }, + "EDIT_ACCOUNT_BUDGET": { + "ACTION": "Edit Monthly Budgets", + "BUDGET_COLUMN_LABEL": "Budget ({{currencySymbol}})", + "ERROR_NEW_BUDGET_TOO_HIGH": "ERROR: Tne locked budget amounts exceed the available budget for the account!", + "DESCRIPTION": "Edit the budget for each month for this account.", + "DESCRIPTION_NOTE": "NOTE: Locking the budget for a month will distribute the balance of the budget evenly among the unlocked months.", + "LOCK": "Lock", + "TITLE": "Edit account: {{acctNum}} - {{acctLabel}}" + }, + "EXPORT": { + "DATA_CSV": "Export YTD Budget Data (CSV)", + "DESCRIPTION": "Export account data to CSV file", + "EXCEL": "Export YTD Budget Data (Excel)", + "MODAL_BREADCRUMB": "Export Sample Budget Data", + "NO_ACCT_BALANCE_DATA_FOR_FY": "No accounts balance data found for {{fyName}}!", + "REPORT_FILENAME": "{{fyName}} Budget Report", + "REPORT_PDF": "Export YTD Report (PDF)", + "REPORT_TITLE": "FY Budget Report (Year to Date)", + "SELECT_FY": "Select fiscal year to export", + "SUCCESS": "Budget data CSV file for {{fyName}} succesfully downloaded!" + }, + "IMPORT": { + "DESCRIPTION": "You can upload a budget from a CSV file. Only lines for title, income, and expense accounts may be included. Budget totals for tile accounts will be ignored. Note that imported budget data will replace any existing budget data for the selected fiscal year.", + "DOWNLOAD": "Click here to download an example budget CSV template file", + "LOAD_FROM_FILE": "Import budget data from a CSV file", + "MODAL_BREADCRUMB": "Upload Budget Data for {{fiscalYear}}", + "NO_FILE_SELECTED": "No file is selected", + "SUCCESS" : "Budget data for {{fyName}} successfully uploaded and imported!" + } + } +} diff --git a/client/src/i18n/en/form.json b/client/src/i18n/en/form.json index dc578b557f..cfb6535b0d 100644 --- a/client/src/i18n/en/form.json +++ b/client/src/i18n/en/form.json @@ -357,6 +357,7 @@ "BEGINNING_VIEW_BALANCE" : "Beginning View Balance", "BILLING_DATE": "Billing Date", "BREAK_EVEN": "Break Even", + "BUDGET": "Budget", "BULK_QUANTITY": "Bulk quantity", "BY": "by", "BY_ID": "By Id", diff --git a/client/src/i18n/en/general_ledger.json b/client/src/i18n/en/general_ledger.json index 6249cc8b5e..cf6aa18f56 100644 --- a/client/src/i18n/en/general_ledger.json +++ b/client/src/i18n/en/general_ledger.json @@ -1,3 +1,7 @@ -{"GENERAL_LEDGER":{"TITLE":"General Ledger", -"ACCOUNT_SLIP":"Account Slip", -"SOLD":"Sold"}} \ No newline at end of file +{ + "GENERAL_LEDGER": { + "TITLE": "General Ledger", + "ACCOUNT_SLIP": "Account Slip", + "SOLD": "Sold" + } +} diff --git a/client/src/i18n/en/periods.json b/client/src/i18n/en/periods.json index c8bd37291c..48f7721ede 100644 --- a/client/src/i18n/en/periods.json +++ b/client/src/i18n/en/periods.json @@ -18,6 +18,21 @@ "CUSTOM_TO" : "Custom To", "SELECT_PERIOD" : "Select Period", "SELECT_CUSTOM_PERIOD" : "Select Custom Period", - "SUBMIT_CUSTOM_PERIOD" : "Submit Custom Period" + "SUBMIT_CUSTOM_PERIOD" : "Submit Custom Period", + "NAME" : { + "ALL": "All", + "JANUARY": "January", + "FEBRUARY": "February", + "MARCH": "March", + "APRIL": "April", + "MAY": "May", + "JUNE": "June", + "JULY": "July", + "AUGUST": "August", + "SEPTEMBER": "September", + "OCTOBER": "October", + "NOVEMBER": "November", + "DECEMBER": "December" + } } } diff --git a/client/src/i18n/en/table.json b/client/src/i18n/en/table.json index 1f776f8f68..dd7d8d2b03 100644 --- a/client/src/i18n/en/table.json +++ b/client/src/i18n/en/table.json @@ -44,6 +44,7 @@ "MARGIN_VARIABLE_LOADS": "Margin on variable loads", "TURNOVER" : "Sales Revenue" }, + "BUDGET": "Budget", "BY": "by", "CASHBOX": "Cashbox", "CASES": "Cases", diff --git a/client/src/i18n/en/tree.json b/client/src/i18n/en/tree.json index ef73acda6f..58a8e40a10 100644 --- a/client/src/i18n/en/tree.json +++ b/client/src/i18n/en/tree.json @@ -26,6 +26,7 @@ "BREAK_EVEN_PROJECT_REPORT" : "Break-even Report by Project", "BREAK_EVEN_REFERENCE":"Break-even references", "BREAK_EVEN_REPORT": "Break-even Report", + "BUDGET": "Budget Management", "CASHBOX_MANAGEMENT" : "Cashbox Management", "CASHFLOW_BY_SERVICE" : "Cashflow by Service", "CASHFLOW" : "Statement of Cash Flows", diff --git a/client/src/i18n/fr/budget.json b/client/src/i18n/fr/budget.json new file mode 100644 index 0000000000..9468524e2a --- /dev/null +++ b/client/src/i18n/fr/budget.json @@ -0,0 +1,74 @@ +{ + "BUDGET": { + "TITLE": "Budget", + "ACTUALS_SUBTITLE": "(réels)", + "BUDGET_SUBTITLE": "(budget)", + "BUDGET_YTD": "Budget (CDA)", + "BUDGET_YTD_COLUMN": "Budget", + "BUDGET_YTD_SUBTITLE": "(CDA)", + "BUDGET_YTD_TOOLTIP": "Budget cumul de l'année (CDA)", + "EXPENSES_TOTAL": "Dépenses totales", + "DEVIATION_YTD": "Déviation % (CDA)", + "DEVIATION_YTD_TOOLTIP": "Déviation % (CDA) = 100*(Réels_CDA/budget_CDA)", + "DIFFERENCE_YTD": "Différence (CDA)", + "DIFFERENCE_YTD_TOOLTIP": "Différence = Réels - Budget", + "FY_ACTUALS": "FY Réels (CDA)", + "FY_BUDGET": "FY Budget", + "FY_DEVIATION": "FY % Déviation", + "EXPORT_BUDGET": "Exporter le budget vers un fichier CSV", + "IMPORT_BUDGET": "Importer un fichier CSV de budget", + "IMPORT_BUDGET_BAD_HEADERS": "ERREUR : L'importation du budget a échoué ! Vérifier si des noms d'en-têtes de colonnes sont manquants ou mal orthographiés. Attendu : AcctNum, Label, Type, Budget, BudgetLastYear.", + "IMPORT_BUDGET_ERROR": "ERREUR : L'importation du budget a échoué ! Vérifiez si des données sont manquantes ou invalides ou si les valeurs de type sont incorrectes (elles devraient être 'title', 'income', ou 'expense').", + "IMPORT_BUDGET_ERROR_ACCT_NUM": "ERREUR : L'importation du budget a échoué ! Le numéro de compte n'est pas un integer.", + "IMPORT_BUDGET_ERROR_ACCT_TYPE": "ERREUR : L'importation du budget a échoué ! Vérifier si les valeurs de type sont incorrectes (elles devraient être 'title', 'income', ou 'expense').", + "IMPORT_BUDGET_ERROR_ACCT_TYPE_INCORRECT": "ERREUR : L'importation du budget a échoué ! Le type de compte ne correspond pas au compte dans la base de données.", + "IMPORT_BUDGET_ERROR_BAD_BUDGET_VALUE": "ERREUR : L'importation du budget a échoué ! La valeur du budget n'est pas un nombre.", + "IMPORT_BUDGET_ERROR_NEGATIVE_BUDGET_VALUE": "ERREUR : L'importation du budget a échoué ! La valeur du budget est négative.", + "IMPORT_BUDGET_WARN_TITLE_BUDGET_IGNORED": "ATTENTION : Les valeurs budgétaires des comptes titres sont ignorées.", + "INCOME_TOTAL": "Revenu totales", + "TOTAL_SUMMARY": "Résumé des totaux (réel - budget)", + "ACTUALS": { + "JANUARY": "Réalisations de janvier", + "FEBRUARY": "Réalisations de fevrier", + "MARCH": "Réalisations de mars", + "APRIL": "Réalisations de avril", + "MAY": "Réalisations de mai", + "JUNE": "Réalisations de juin", + "JULY": "Réalisations de juillet", + "AUGUST": "Réalisations de aout", + "SEPTEMBER": "Réalisations de septembre", + "OCTOBER": "Réalisations de octobre", + "NOVEMBER": "Réalisations de novembre", + "DECEMBER": "Réalisations de decembre" + }, + "EDIT_ACCOUNT_BUDGET": { + "ACTION": "Modifier les budgets mensuels", + "BUDGET_COLUMN_LABEL": "Budget ({{currencySymbol}})", + "ERROR_NEW_BUDGET_TOO_HIGH": "ERREUR : Les montants du budget bloqué dépassent le budget disponible pour le compte !", + "DESCRIPTION": "Modifiez le budget de ce compte pour chaque mois.", + "DESCRIPTION_NOTE": "NOTE : Le verrouillage du budget pour un mois répartira le solde du budget de manière égale entre les mois non verrouillés.", + "LOCK": "Verrouiller", + "TITLE": "Modifier le compte: {{acctNum}} - {{acctLabel}}" + }, + "EXPORT": { + "DATA_CSV": "Exporter les données budgétaires CDA (CSV)", + "DESCRIPTION": "Exporter les données du compte vers un fichier CSV", + "EXCEL": "Exporter les données budgétaires CDA (Excel)", + "MODAL_BREADCRUMB": "Exporter les données budgétaires", + "NO_ACCT_BALANCE_DATA_FOR_FY": "Aucune donnée sur le solde des comptes n'a été trouvée pour {{fyName}}!", + "REPORT_FILENAME": "{{fyName}} Rapport budgétaire", + "REPORT_PDF": "Exporter le rapport budgétaire (PDF)", + "REPORT_TITLE": "Rapport budgétaire (cummul de l'année)", + "SELECT_FY": "Sélectionner l'année fiscale à exporter", + "SUCCESS": "Le fichier CSV des données budgétaires de {{fyName}} a été téléchargé avec succès !" + }, + "IMPORT": { + "DESCRIPTION": "Vous pouvez télécharger un budget à partir d'un fichier CSV. Seules les lignes des comptes de titres, de revenus et de dépenses peuvent être incluses. Les totaux de budget pour les comptes de tuiles seront ignorés. Notez que les données budgétaires importées remplaceront toutes les données budgétaires existantes pour l'année fiscale sélectionnée.", + "DOWNLOAD": "Cliquez ici pour télécharger un exemple de modèle de budget CSV", + "LOAD_FROM_FILE": "Importer des données budgétaires à partir d'un fichier CSV", + "MODAL_BREADCRUMB": "Télécharger les données budgétaires pour {{fiscalYear}}", + "NO_FILE_SELECTED": "Aucun fichier n'est sélectionné", + "SUCCESS" : "Les données budgétaires pour {{fyName}} ont été téléchargées et importées avec succès !" + } + } +} \ No newline at end of file diff --git a/client/src/i18n/fr/form.json b/client/src/i18n/fr/form.json index ee9e60863d..6e554377bd 100644 --- a/client/src/i18n/fr/form.json +++ b/client/src/i18n/fr/form.json @@ -360,6 +360,7 @@ "BEGINNING_VIEW_BALANCE" : "Solde Début du Grid", "BILLING_DATE": "Date Facturation", "BREAK_EVEN": "Seuil de rentabilité", + "BUDGET": "Budget", "BULK_QUANTITY": "Quantité en vrac", "BY_ID": "Par id", "BY_NAME": "Par nom", diff --git a/client/src/i18n/fr/general_ledger.json b/client/src/i18n/fr/general_ledger.json index 5aefb8359f..15e45700c5 100644 --- a/client/src/i18n/fr/general_ledger.json +++ b/client/src/i18n/fr/general_ledger.json @@ -1,3 +1,7 @@ -{"GENERAL_LEDGER":{"TITLE":"Grand Livre", -"ACCOUNT_SLIP":"Extrait de compte", -"SOLD":"Solde"}} \ No newline at end of file +{ + "GENERAL_LEDGER": { + "TITLE": "Grand Livre", + "ACCOUNT_SLIP": "Extrait de compte", + "SOLD": "Solde" + } +} diff --git a/client/src/i18n/fr/periods.json b/client/src/i18n/fr/periods.json index d9785039f4..cfc180872d 100644 --- a/client/src/i18n/fr/periods.json +++ b/client/src/i18n/fr/periods.json @@ -18,6 +18,21 @@ "CUSTOM_TO" : "à", "SELECT_PERIOD" : "Choisir une Période", "SELECT_CUSTOM_PERIOD" : "Période Personnalisée", - "SUBMIT_CUSTOM_PERIOD" : "Valider la Période" + "SUBMIT_CUSTOM_PERIOD" : "Valider la Période", + "NAME" : { + "ALL": "Tous", + "JANUARY": "Janvier", + "FEBRUARY": "Fevrier", + "MARCH": "Mars", + "APRIL": "Avril", + "MAY": "Mai", + "JUNE": "Juin", + "JULY": "Juillet", + "AUGUST": "Août", + "SEPTEMBER": "Septembre", + "OCTOBER": "Octobre", + "NOVEMBER": "Novembre", + "DECEMBER": "Décembre" + } } } diff --git a/client/src/i18n/fr/table.json b/client/src/i18n/fr/table.json index f32dca7e0b..fea9eaaff1 100644 --- a/client/src/i18n/fr/table.json +++ b/client/src/i18n/fr/table.json @@ -44,6 +44,7 @@ "MARGIN_VARIABLE_LOADS": "Marge sur charges variables (Ms/CV)", "TURNOVER" : "Chiffre d'affaire" }, + "BUDGET": "Budget", "CANCEL_CASH": "Annuler le paiement", "BY": "Par", "CASES": "Cas", diff --git a/client/src/i18n/fr/tree.json b/client/src/i18n/fr/tree.json index 48b0a9ce0d..f7d1b0ab0c 100644 --- a/client/src/i18n/fr/tree.json +++ b/client/src/i18n/fr/tree.json @@ -26,6 +26,7 @@ "BREAK_EVEN_PROJECT_REPORT" : "Rapport du seuil de rentabilité par Projets", "BREAK_EVEN_REFERENCE":"Références du seuil de rentabilité", "BREAK_EVEN_REPORT": "Rapport du seuil de rentabilité", + "BUDGET": "Gestion budgétaire", "CASHBOX_MANAGEMENT":"Caisses et Banques", "CASHFLOW": "Flux de Trésorerie", "CASHFLOW_BY_SERVICE": "Journal de Ventilation", diff --git a/client/src/js/constants/bhConstants.js b/client/src/js/constants/bhConstants.js index 6d00cb6bbc..c47b713f5a 100644 --- a/client/src/js/constants/bhConstants.js +++ b/client/src/js/constants/bhConstants.js @@ -244,5 +244,23 @@ function constantConfig() { comment : 'PAYROLL_SETTINGS.GROUP_BY_COST_CENTERS_HELP_TEXT', }, ], + + /* MUST match budgetPeriods() in server constants.js */ + /* eslint-disable no-multi-spaces */ + periods : [ + { periodNum : 1, label : 'PERIODS.NAME.JANUARY', key : 'jan' }, + { periodNum : 2, label : 'PERIODS.NAME.FEBRUARY', key : 'feb' }, + { periodNum : 3, label : 'PERIODS.NAME.MARCH', key : 'mar' }, + { periodNum : 4, label : 'PERIODS.NAME.APRIL', key : 'apr' }, + { periodNum : 5, label : 'PERIODS.NAME.MAY', key : 'may' }, + { periodNum : 6, label : 'PERIODS.NAME.JUNE', key : 'jun' }, + { periodNum : 7, label : 'PERIODS.NAME.JULY', key : 'jul' }, + { periodNum : 8, label : 'PERIODS.NAME.AUGUST', key : 'aug' }, + { periodNum : 9, label : 'PERIODS.NAME.SEPTEMBER', key : 'sep' }, + { periodNum : 10, label : 'PERIODS.NAME.OCTOBER', key : 'oct' }, + { periodNum : 11, label : 'PERIODS.NAME.NOVEMBER', key : 'nov' }, + { periodNum : 12, label : 'PERIODS.NAME.DECEMBER', key : 'dec' }, + ], + /* eslint-enable */ }; } diff --git a/client/src/js/services/PeriodService.js b/client/src/js/services/PeriodService.js index f2fd745455..ea707765b4 100644 --- a/client/src/js/services/PeriodService.js +++ b/client/src/js/services/PeriodService.js @@ -3,7 +3,7 @@ angular.module('bhima.services') PeriodService.$inject = ['moment']; -/** @TODO rewrite this using AMD synatx so that the same file can be used across +/** @TODO rewrite this using AMD syntax so that the same file can be used across * the client and the server */ function PeriodService(Moment) { const service = this; diff --git a/client/src/modules/accounts/accounts.js b/client/src/modules/accounts/accounts.js index 0c449c94ca..8dee4ea7d2 100644 --- a/client/src/modules/accounts/accounts.js +++ b/client/src/modules/accounts/accounts.js @@ -80,7 +80,7 @@ function AccountsController( { field : 'number', displayName : '', - cellClass : 'text-right', + cellClass : 'text-left', width : 80, }, { @@ -88,6 +88,7 @@ function AccountsController( displayName : 'FORM.LABELS.ACCOUNT', cellTemplate : '/modules/accounts/templates/grid.indentCell.tmpl.html', headerCellFilter : 'translate', + cellClass : 'text-left', }, { field : 'type', diff --git a/client/src/modules/accounts/accounts.service.js b/client/src/modules/accounts/accounts.service.js index c23b568fdf..1156bc22b4 100644 --- a/client/src/modules/accounts/accounts.service.js +++ b/client/src/modules/accounts/accounts.service.js @@ -19,9 +19,11 @@ function AccountService(Api, bhConstants, HttpCache) { // debounce the read() method by 250 milliseconds to avoid needless GET requests service.read = read; service.read = read; service.label = label; + service.typeToken = typeToken; service.getBalance = getBalance; service.getAnnualBalance = getAnnualBalance; + service.getAllAnnualBalances = getAllAnnualBalances; service.getOpeningBalanceForPeriod = getOpeningBalanceForPeriod; service.filterTitleAccounts = filterTitleAccounts; service.filterAccountByType = filterAccountsByType; @@ -81,6 +83,12 @@ function AccountService(Api, bhConstants, HttpCache) { return String(account.number).concat(' - ', account.label); } + function typeToken(typeId) { + const typeName = Object.keys(bhConstants.accounts).find(key => bhConstants.accounts[key] === typeId); + const token = typeName ? `ACCOUNT.TYPES.${typeName}` : ''; + return token; + } + function getBalance(accountId, opt) { const url = baseUrl.concat(accountId, '/balance'); return service.$http.get(url, opt) @@ -93,6 +101,12 @@ function AccountService(Api, bhConstants, HttpCache) { .then(service.util.unwrapHttpResponse); } + function getAllAnnualBalances(fiscalYearId) { + const url = baseUrl.concat(fiscalYearId, '/all_balances'); + return service.$http.get(url) + .then(service.util.unwrapHttpResponse); + } + function filterTitleAccounts(accounts) { return filterAccountsByType(accounts, bhConstants.accounts.TITLE); } diff --git a/client/src/modules/budget/budget.html b/client/src/modules/budget/budget.html new file mode 100644 index 0000000000..1f7d109ecd --- /dev/null +++ b/client/src/modules/budget/budget.html @@ -0,0 +1,135 @@ +
+
+
    +
  1. TREE.FINANCE
  2. +
  3. BUDGET.TITLE
  4. +
  5. {{ BudgetCtrl.fiscalYearLabel }}
  6. +
+ + +
+
+ +
+
+ +
+
+ +
+ + + +
+ +
+ +
+ +
+
+
+
\ No newline at end of file diff --git a/client/src/modules/budget/budget.js b/client/src/modules/budget/budget.js new file mode 100644 index 0000000000..900aebbb52 --- /dev/null +++ b/client/src/modules/budget/budget.js @@ -0,0 +1,429 @@ +angular.module('bhima.controllers') + .controller('BudgetController', BudgetController); + +BudgetController.$inject = [ + 'BudgetService', 'SessionService', 'FiscalService', 'GridColumnService', 'GridStateService', + 'NotifyService', '$state', '$translate', '$uibModal', 'bhConstants', +]; + +/** + * BudgetController + * + * This controller is responsible for displaying accounts and their balances + */ /* eslint-disable-next-line */ +function BudgetController( + Budget, Session, Fiscal, Columns, GridState, + Notify, $state, $translate, $uibModal, bhConstants, +) { + const vm = this; + const cacheKey = 'budget-grid'; + + vm.loading = false; + + vm.enterprise = Session.enterprise; + + // vm.displayNames = []; + vm.year = {}; + + vm.toggleHideTitleAccount = toggleHideTitleAccount; + vm.historicPeriods = []; + + vm.hideTitleAccount = false; + vm.indentTitleSpace = 15; + const isNotTitleAccount = (account) => account.type_id !== bhConstants.accounts.TITLE; + + // Define exports + vm.downloadExcelQueryString = downloadExcelQueryString; + vm.editAccountBudget = editAccountBudget; + vm.exportToQueryString = exportToQueryString; + vm.importBudgetCSV = importBudgetCSV; + vm.onSelectFiscalYear = onSelectFiscalYear; + vm.openColumnConfiguration = openColumnConfiguration; + + vm.months = Budget.budgetPeriods(); + + // Add month abbreviations + vm.months.forEach(mo => { + mo.abbr = ($translate.instant(mo.label)).substring(0, 1); + }); + + const columns = [ + { + field : 'number', + displayName : 'TABLE.COLUMNS.ACCOUNT', + enableFiltering : false, + headerCellFilter : 'translate', + headerCellClass : 'wrappingColHeader', + cellTemplate : '/modules/budget/templates/acct_number.cell.html', + width : 80, + }, { + field : 'label', + displayName : 'TABLE.COLUMNS.LABEL', + enableFiltering : false, + headerCellFilter : 'translate', + cellTemplate : '/modules/budget/templates/acct_label.cell.html', + width : '25%', + }, { + field : 'type', + displayName : 'TABLE.COLUMNS.TYPE', + enableFiltering : false, + headerCellFilter : 'translate', + cellTemplate : '/modules/budget/templates/acct_type.cell.html', + }, { + field : 'budget', + displayName : 'BUDGET.FY_BUDGET', + headerCellFilter : 'translate', + headerCellClass : 'wrappingColHeader', + cellTemplate : '/modules/budget/templates/budget.cell.html', + type : 'number', + enableFiltering : false, + footerCellFilter : 'currency: grid.appScope.enterprise.currency_id:0', + footerCellClass : 'text-right', + }, { + name : 'budgetYTD', + displayName : 'BUDGET.BUDGET_YTD', + headerCellFilter : 'translate', + headerCellClass : 'wrappingColHeader', + cellTemplate : '/modules/budget/templates/budgetYTD.cell.html', + type : 'number', + enableFiltering : false, + }, { + name : 'actuals', + displayName : 'BUDGET.FY_ACTUALS', + headerCellFilter : 'translate', + headerCellClass : 'wrappingColHeader', + cellTemplate : '/modules/budget/templates/actuals.cell.html', + type : 'number', + enableFiltering : false, + footerCellFilter : 'currency: grid.appScope.enterprise.currency_id:0', + footerCellClass : 'text-right', + }, { + name : 'difference', + displayName : 'BUDGET.DIFFERENCE_YTD', + headerTooltip : 'BUDGET.DIFFERENCE_YTD_TOOLTIP', + headerCellFilter : 'translate', + headerCellClass : 'wrappingColHeader', + cellTemplate : '/modules/budget/templates/differenceYTD.cell.html', + type : 'number', + enableFiltering : false, + visible : true, + }, { + name : 'deviationPct', + displayName : 'BUDGET.FY_DEVIATION', + headerTooltip : 'BUDGET.DIFFERENCE_YTD_TOOLTIP', + headerCellFilter : 'translate', + headerCellClass : 'wrappingColHeader', + cellTemplate : '/modules/budget/templates/deviationPct.cell.html', + type : 'number', + enableFiltering : false, + visible : false, + }, { + name : 'deviationYTDPct', + displayName : 'BUDGET.DEVIATION_YTD', + headerCellFilter : 'translate', + headerCellClass : 'wrappingColHeader', + cellTemplate : '/modules/budget/templates/deviationYTDPct.cell.html', + type : 'number', + enableFiltering : false, + }, + ]; + + // Add columns for the months + vm.months.forEach(mon => { + // Add the budget column for the month + columns.push({ + name : mon.key, + displayName : mon.label, + headerCellTemplate : '/modules/budget/templates/period_budget_header.cell.html', + cellClass : 'text-right', + cellTemplate : `/modules/budget/templates/period_budget.cell.html`, + visible : false, + width : '10%', + }); + + // Add the actuals column for the month + const actualsLabel = `BUDGET.ACTUALS.${mon.label.replace('PERIODS.NAME.', '')}`; + columns.push({ + name : `${mon.key}_actuals`, + displayName : actualsLabel, + headerCellTemplate : '/modules/budget/templates/period_actuals_header.cell.html', + cellClass : 'text-right', + cellTemplate : `/modules/budget/templates/period_actuals.cell.html`, + visible : false, + width : '10%', + }); + }); + + // Add the action menu + columns.push({ + field : 'action', + displayName : '', + cellTemplate : '/modules/budget/templates/action.tmpl.html', + enableFiltering : false, + enableSorting : false, + enableColumnMenu : false, + }); + + // options for the UI grid + vm.gridOptions = { + appScopeProvider : vm, + enableColumnMenus : false, + fastWatch : true, + flatEntityAccess : true, + enableSorting : false, + onRegisterApi : onRegisterApiFn, + columnDefs : columns, + }; + + const gridColumns = new Columns(vm.gridOptions, cacheKey); + const state = new GridState(vm.gridOptions, cacheKey); + + vm.saveGridState = state.saveGridState; + vm.clearGridState = function clearGridState() { + state.clearGridState(); + $state.reload(); + }; + + vm.getPeriodBudgetSign = function getPeriodBudgetSign(month, entity) { + // First get the amount (if absent, return no sign) + const periods = entity.period; + if (!angular.isDefined(periods) || periods === null) { + return ''; + } + const info = periods.find(item => item.key === month); + if (!info || !angular.isDefined(info.budget) || info.budget === 0) { + return ''; + } + + if (entity.type_id === bhConstants.accounts.INCOME) { + return '+'; + } + if (entity.type_id === bhConstants.accounts.EXPENSE) { + return '-'; + } + return ''; + }; + + vm.getPeriodActualsSign = function getPeriodActualsSign(month, entity) { + // First get the amount (if absent, return no sign) + const periods = entity.period; + if (!angular.isDefined(periods) || periods === null) { + return ''; + } + const key = month.replace('_actuals', ''); + const info = periods.find(item => item.key === key); + if (!info || !angular.isDefined(info.actuals) || info.actuals === 0) { + return ''; + } + + if (entity.type_id === bhConstants.accounts.INCOME) { + return '+'; + } + if (entity.type_id === bhConstants.accounts.EXPENSE) { + return '-'; + } + return ''; + }; + + vm.getPeriodActuals = function getPeriodActuals(month, periods) { + if (!angular.isDefined(periods) || periods === null) { + return ''; + } + const key = month.replace('_actuals', ''); + const info = periods.find(item => item.key === key) || ''; + return info.actuals; + }; + + vm.getPeriodBudget = function getPeriodBudget(month, periods) { + if (!angular.isDefined(periods) || periods === null) { + return ''; + } + const info = periods.find(item => item.key === month) || ''; + return info.budget; + }; + + vm.getPeriodBudgetLocked = function getPeriodBudgetLocked(month, periods) { + if (!angular.isDefined(periods) || periods === null) { + return false; + } + const pinfo = periods.find(item => item.key === month); + if (!pinfo || !angular.isDefined(pinfo.locked)) { + return false; + } + return pinfo.locked; + }; + + vm.getMonthLabel = function getMonthLabel(name) { + const key = name.replace('_actuals', ''); + const month = vm.months.find(item => item.key === key); + if (month) { + return month.label; + } + return 'NOT FOUND'; + }; + + function onRegisterApiFn(gridApi) { + vm.gridApi = gridApi; + gridApi.grid.registerDataChangeCallback(expandOnSetData); + } + + function handleError(err) { + vm.hasError = true; + Notify.handleError(err); + } + + vm.toggleMonth = function toggleMonth(month) { + const cols = gridColumns.getColumnVisibilityMap(); + const monthActuals = `${month}_actuals`; + cols[month] = !cols[month]; + cols[monthActuals] = !cols[monthActuals]; + gridColumns.setVisibleColumns(cols); + }; + + function hideTitles() { + if (vm.hideTitleAccount) { + const dataview = vm.data.filter(isNotTitleAccount); + + // squash the tree level so that no grouping occurs + dataview.forEach(account => { + account.$$treeLevel = 0; + }); + + // Update grid + vm.gridOptions.data = dataview; + + } else { + const dataview = vm.data; + + // restore the tree level to restore grouping + dataview.forEach(account => { + account.$$treeLevel = account._$$treeLevel; + }); + + // Update grid + vm.gridOptions.data = dataview; + } + } + + function openColumnConfiguration() { + gridColumns.openConfigurationModal(); + } + + // specify if titles accounts should be hidden + function toggleHideTitleAccount() { + vm.hideTitleAccount = !vm.hideTitleAccount; + hideTitles(); + } + + function expandOnSetData(grid) { + if (grid.options.data.length > 0) { + grid.api.treeBase.expandAllRows(); + } + } + + function importBudgetCSV() { + const budget = { year : vm.year }; + return $uibModal.open({ + templateUrl : 'modules/budget/modal/import.html', + controller : 'ImportBudgetModalController as ModalCtrl', + keyboard : false, + backdrop : 'static', + size : 'md', + resolve : { + data : () => budget, + }, + }).result + .then(() => { + load({ fiscal_year_id : vm.year.id }); + }); + } + + function exportToQueryString(renderer) { + const filename = $translate.instant('BUDGET.EXPORT.REPORT_FILENAME', { fyName : vm.fiscalYearLabel }); + const params = { + fiscal_year_id : vm.year.id, + filename : filename.replaceAll(' ', '-'), + }; + return Budget.exportToQueryString(renderer, params); + } + + function downloadExcelQueryString() { + const filename = $translate.instant('BUDGET.EXPORT.REPORT_FILENAME', { fyName : vm.fiscalYearLabel }); + const params = { + fiscal_year_id : vm.year.id, + filename : filename.replaceAll(' ', '-'), + }; + return Budget.downloadExcelQueryString(params); + } + + function editAccountBudget(account) { + const params = { year : vm.year, account }; + return $uibModal.open({ + templateUrl : 'modules/budget/modal/editAccountBudget.html', + controller : 'EditAccountBudgetModalController as ModalCtrl', + keyboard : false, + backdrop : 'static', + size : 'md', + resolve : { + data : () => params, + }, + }).result + .then(() => { + load({ fiscal_year_id : vm.year.id }); + }); + } + + /** + * Load the budget data + * @param {object} options - options + */ + function load(options) { + vm.loading = true; + const fiscalYearId = options.fiscal_year_id; + + Budget.loadData(fiscalYearId) + .then(budgetData => { + vm.data = budgetData; + vm.gridOptions.data = vm.data; + }) + .then(() => { + hideTitles(); + }) + .catch(handleError) + .finally(() => { + vm.loading = false; + }); + } + + /** + * fired when the footer changes and on startup. + * + * @param {object} year - new fiscal year object + */ + async function onSelectFiscalYear(year) { + vm.year = year; + vm.fiscalYearLabel = vm.year.label; + vm.historicPeriods = await Budget.getPeriodsWithActuals(vm.year.id); + vm.filters = { + fiscal_year_id : vm.year.id, + }; + + load(vm.filters); + } + + /** + * Runs on startup + */ + function startup() { + Fiscal.read(null, { detailed : 1, includePeriods : 1 }) + .then((years) => { + vm.fiscalYears = years; + + // get the last year + onSelectFiscalYear(years[0]); + }) + .catch(Notify.handleError); + } + + startup(); +} diff --git a/client/src/modules/budget/budget.routes.js b/client/src/modules/budget/budget.routes.js new file mode 100644 index 0000000000..e2e9237c80 --- /dev/null +++ b/client/src/modules/budget/budget.routes.js @@ -0,0 +1,9 @@ +angular.module('bhima.routes') + .config(['$stateProvider', $stateProvider => { + $stateProvider + .state('budget', { + url : '/budget', + controller : 'BudgetController as BudgetCtrl', + templateUrl : 'modules/budget/budget.html', + }); + }]); diff --git a/client/src/modules/budget/budget.service.js b/client/src/modules/budget/budget.service.js new file mode 100644 index 0000000000..e65ee0b1bb --- /dev/null +++ b/client/src/modules/budget/budget.service.js @@ -0,0 +1,324 @@ +angular.module('bhima.services') + .service('BudgetService', BudgetService); + +BudgetService.$inject = [ + 'PrototypeApiService', + 'moment', + 'AccountService', + 'NotifyService', + 'LanguageService', + 'FormatTreeDataService', + '$httpParamSerializer', + 'bhConstants', + '$translate', +]; + +/** + * Provide budget services + * + * @returns {object} the budget service object + */ /* eslint-disable-next-line */ +function BudgetService(Api, moment, Accounts, Notify, Languages, + FormatTreeData, $httpParamSerializer, bhConstants, $translate) { + + const service = new Api('/budget'); + + const { TITLE, EXPENSE, INCOME } = bhConstants.accounts; + const allowedTypes = [TITLE, EXPENSE, INCOME]; + + // Expose services + service.budgetPeriods = budgetPeriods; + service.downloadBudgetTemplate = downloadBudgetTemplate; + service.exportCSV = exportCSV; + service.fillBudget = fillBudget; + service.getBudgetData = getBudgetData; + service.getPeriods = getPeriods; + service.getPeriodsWithActuals = getPeriodsWithActuals; + service.loadData = loadData; + service.populateBudget = populateBudget; + service.updateBudgetPeriods = updateBudgetPeriods; + service.exportToQueryString = exportToQueryString; + service.downloadExcelQueryString = downloadExcelQueryString; + + /** + * Download the budget template file + */ + function downloadBudgetTemplate() { + service.$http.get('/budget/import_template_file') + .then(response => { + return service.util.download(response, 'Import Budget Template', 'csv'); + }); + } + + /** + * Populate the budget for the fiscal year. + * + * ASSUMES: the budget items for the fiscal year have been creted (period num == 0) + * + * @param {number} fiscalYearId - the ID for the fiscal year + * @returns {Promise} for the result + */ + function populateBudget(fiscalYearId) { + const url = `/budget/populate/${fiscalYearId}`; + return service.$http.post(url) + .then(service.util.unwrapHttpResponse); + } + + /** + * Update budget period(s) + * + * @param {Array} changes - array of {periodId, newBudget, newLocked} updates + * @returns {Promise} of number of changes done + */ + function updateBudgetPeriods(changes) { + const url = '/budget/updatePeriodBudgets'; + return service.$http.put(url, { params : changes }) + .then(service.util.unwrapHttpResponse); + } + + /** + * Fill/distribute the budget for the fiscal year to each budget period for each account + * + * @param {number} fiscalYearId - the ID for the fiscal year + * @returns {Promise} for the result + */ + function fillBudget(fiscalYearId) { + const url = `/budget/fill/${fiscalYearId}`; + return service.$http.put(url) + .then(service.util.unwrapHttpResponse); + } + + /** + * Get the budget and actuals data for the fiscal year + * + * @param {number} fiscalYearId - the ID for the fiscal year + * @returns {Promise} for the result + */ + function getBudgetData(fiscalYearId) { + const url = `/budget/data/${fiscalYearId}`; + return service.$http.get(url) + .then(service.util.unwrapHttpResponse); + } + + /** + * Get the periods for a fiscal year + * + * An additional pseudo period (number = 0) is appended + * for the bounds of the FY + * + * @param {number} fiscalYearId - ID for the fiscal year + * @returns {Promise} of the list of periods + */ + function getPeriods(fiscalYearId) { + let periods; + return service.$http.get(`/fiscal/${fiscalYearId}/periods`) + .then(service.util.unwrapHttpResponse) + .then(allPeriods => { + periods = allPeriods; + return service.$http.get(`/fiscal/${fiscalYearId}`); + }) + .then(service.util.unwrapHttpResponse) + .then(fy => { + // Push in period zero to provide FY dates + periods.push({ + number : 0, + end_date : fy.end_date, + start_date : fy.start_date, + }); + return periods; + }); + } + + /** + * Get the periods for the fiscal year that potentially have actuals. + * This does not include periods in the future (for the current FY) + * but will include all periods for any FY in the past. + * + * @param {number} fiscalYearId - ID of the desired fiscal year + * @returns {Array} array of period IDs + */ + function getPeriodsWithActuals(fiscalYearId) { + const today = moment().toDate(); + const monthEnd = moment().endOf('month').toDate(); + let currentFY; + return service.$http.get(`/fiscal/${fiscalYearId}`) + .then(service.util.unwrapHttpResponse) + .then(fy => { + currentFY = fy; + return service.$http.get(`/fiscal/${fiscalYearId}/periods`); + }) + .then(service.util.unwrapHttpResponse) + .then(allPeriods => { + const endDateFY = new Date(currentFY.end_date); + if (today > endDateFY) { + // If the current date is after the end of the fiscal year, all periods could have actuals + return allPeriods.map(item => item.id); + } + + // The current date is inside this fiscal year, so do not return periods in the future + // (since they cannot have any actuals) + const periods = []; + allPeriods.forEach(p => { + const endDate = new Date(p.end_date); + if (endDate < monthEnd) { + periods.push(p.id); + } + }); + return periods; + }); + } + + /** + * Load the accounts and budget data + * @param {number} fiscalYearId - Fiscal year ID + * @returns {object} results { accounts, budgetData, historicPeriods } + */ + function loadData(fiscalYearId) { + + return getBudgetData(fiscalYearId) + .then(data => { + + // Set up the tree hierarchy info + FormatTreeData.order(data); + + data.forEach(acct => { + + // Make a feeble attempt to fix $$treelevel, if it is missing + if (!angular.isDefined(acct.$$treeLevel)) { + acct.$$treeLevel = acct.parent ? 1 : 0; + } + + // cache the $$treeLevel + acct._$$treeLevel = acct.$$treeLevel; + }); + + return data; + }); + } + + /** + * Export/download the budget data for a fiscal year + * + * Note that only expense, income, and their title accounts are exported. + * + * @param {number} fiscalYear - fiscal year ID + * @returns {boolean} when completed + */ + async function exportCSV(fiscalYear) { + + /** + * Sleep (Used to get the user time to see a transient warning message) + * + * @param {number} ms - milliseconds to sleep + * @returns {Promise} of completion of the timeout + */ + function sleep(ms) { + // eslint-disable-next-line no-promise-executor-return + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // First, get all account balance data + const fyAcctData = await Accounts.getAllAnnualBalances(fiscalYear.id); + if (fyAcctData.length === 0) { + Notify.warn($translate.instant('BUDGET.EXPORT.NO_ACCT_BALANCE_DATA_FOR_FY', { fyName : fiscalYear.label })); + // Give the user a couple seconds to see the message + await sleep(2500); + } + + // Get all the account data + const accounts = await Accounts.read(); + + // Sort the accounts lexically + accounts.sort((a, b) => a.number.toString().localeCompare(b.number.toString())); + + // Process the accounts to construct the data to export + let exportData = 'AcctNum, Label, Type, Budget\n'; // The headers + accounts.forEach(acct => { + if (allowedTypes.includes(acct.type_id)) { + // Find the matching fy account balance for the account + const fyData = fyAcctData.find(item => item.account_id === acct.id); + + // Prepare and construct the CSV data + const label = JSON.stringify(acct.label); + let amount = 0; + switch (acct.type_id) { + case TITLE: + exportData += `${acct.number},${label},title,\n`; + break; + case EXPENSE: + amount = fyData ? fyData.debit - fyData.credit : 0; + exportData += `${acct.number},${label},expense,${amount}\n`; + break; + case INCOME: + amount = fyData ? fyData.credit - fyData.debit : 0; + exportData += `${acct.number},${label},income,${amount}\n`; + break; + default: + } + } + }); + + // Download the data as a CSV file + await service.util.download({ data : exportData }, `Budget data ${fiscalYear.label}`, 'csv'); + return true; + } + + /** + * Construct the http query stgring for the GET URL + * @param {string} renderer - name of report renderer (pdf, csv, xlsx) + * @param {Array} params - parameters for rendering (must include: fiscal_year_id, filename) + * @returns {string} - http query string for http GET call + */ + function exportToQueryString(renderer, params) { + const defaultOpts = { + renderer, + lang : Languages.key, + }; + const options = angular.merge(defaultOpts, params); + return $httpParamSerializer(options); + } + + /** + * Construct the http parameter string for the GET URL + * + * @param {Array} params - parameters for report rendering + * @returns {string} - http query string for http GET call + */ + function downloadExcelQueryString(params) { + const defaultOpts = { + renderer : 'xlsx', + lang : Languages.key, + renameKeys : true, + }; + const options = angular.merge(defaultOpts, params); + return $httpParamSerializer(options); + } + + /** + * Return the stucture for the data periods (months) + * + * NOTE: Must match 'periods' in server/config/constants.js + * + * @returns {Array} of data for the periods + */ + function budgetPeriods() { + /* eslint-disable no-multi-spaces */ + return [ + { periodNum : 1, label : 'PERIODS.NAME.JANUARY', key : 'jan' }, + { periodNum : 2, label : 'PERIODS.NAME.FEBRUARY', key : 'feb' }, + { periodNum : 3, label : 'PERIODS.NAME.MARCH', key : 'mar' }, + { periodNum : 4, label : 'PERIODS.NAME.APRIL', key : 'apr' }, + { periodNum : 5, label : 'PERIODS.NAME.MAY', key : 'may' }, + { periodNum : 6, label : 'PERIODS.NAME.JUNE', key : 'jun' }, + { periodNum : 7, label : 'PERIODS.NAME.JULY', key : 'jul' }, + { periodNum : 8, label : 'PERIODS.NAME.AUGUST', key : 'aug' }, + { periodNum : 9, label : 'PERIODS.NAME.SEPTEMBER', key : 'sep' }, + { periodNum : 10, label : 'PERIODS.NAME.OCTOBER', key : 'oct' }, + { periodNum : 11, label : 'PERIODS.NAME.NOVEMBER', key : 'nov' }, + { periodNum : 12, label : 'PERIODS.NAME.DECEMBER', key : 'dec' }, + ]; + /* eslint-enable */ + } + + return service; +} diff --git a/client/src/modules/budget/modal/editAccountBudget.html b/client/src/modules/budget/modal/editAccountBudget.html new file mode 100644 index 0000000000..00aaf3dc82 --- /dev/null +++ b/client/src/modules/budget/modal/editAccountBudget.html @@ -0,0 +1,65 @@ +
+ + + + + +
diff --git a/client/src/modules/budget/modal/editAccountBudget.js b/client/src/modules/budget/modal/editAccountBudget.js new file mode 100644 index 0000000000..dc741854fe --- /dev/null +++ b/client/src/modules/budget/modal/editAccountBudget.js @@ -0,0 +1,134 @@ +angular.module('bhima.controllers') + .controller('EditAccountBudgetModalController', EditAccountBudgetModalController); + +EditAccountBudgetModalController.$inject = [ + 'BudgetService', 'CurrencyService', '$uibModalInstance', 'SessionService', + 'NotifyService', 'data', '$translate', +]; + +function EditAccountBudgetModalController( + Budget, Currency, Instance, Session, Notify, data, $translate) { + + const vm = this; + + vm.loading = false; + vm.cancel = Instance.close; + + const { account } = data; + vm.account = account; + vm.year = data.year; + vm.currency = Currency; + + // Save the previous budget numbers + vm.account.period.forEach(p => { + p.old_budget = p.budget; + p.old_locked = p.locked; + p.budget_invalid = false; + }); + + vm.breadcrumb = $translate.instant('BUDGET.EDIT_ACCOUNT_BUDGET.TITLE', + { acctNum : account.number, acctLabel : account.label }); + + vm.budget_column_label = $translate.instant('BUDGET.EDIT_ACCOUNT_BUDGET.BUDGET_COLUMN_LABEL', + { currencySymbol : (Currency.symbol(Session.enterprise.currency_id)).toUpperCase() }); + + function computeTrialAdjustment(periods) { + // Compute the total locked + let totalLocked = 0.0; + let numLocked = 0; + periods.forEach(p => { + if (p.locked) { + totalLocked += p.budget; + numLocked += 1; + } + }); + + // Make sure the total locked is not greater than the total budget for the account! + if (totalLocked > vm.account.budget) { + return false; + } + + // Do the adjustment + const newBudget = (account.budget - totalLocked) / (12 - numLocked); + periods.forEach(p => { + if (!p.locked) { + p.budget = newBudget; + } + }); + + return true; + } + + vm.updateBudgetPeriod = function updateBudgetPeriod(period) { + period.old_locked = period.locked ? 0 : 1; + vm.updateBudgets(period, period.locked); + }; + + vm.updateBudgets = function updateBudgets(period, flag) { + period.locked = flag; + + // Construct trial data + const trialPeriods = []; + account.period.forEach(p => { + trialPeriods.push({ + key : p.key, + budget : p.budget, + locked : p.locked, + }); + }); + + if (computeTrialAdjustment(trialPeriods)) { + // The trial was okay so apply the adjustments + account.period.forEach(p => { + if (!p.locked) { + const tp = trialPeriods.find(item => item.key === p.key); + p.budget = tp.budget; + } + // No need to adjust the locked budget values + }); + + } else { + // Revert the changes and complain that the new total is greater than the available budget + period.locked = period.old_locked; + period.budget = period.old_budget; + Instance.close(false); + Notify.warn($translate.instant('BUDGET.EDIT_ACCOUNT_BUDGET.ERROR_NEW_BUDGET_TOO_HIGH')); + } + + }; + + vm.submit = async () => { + vm.loading = true; + + // Save the results! + const changes = []; + + // Note that this will update all periods if one is changed (rebalanced) + account.period.forEach(p => { + if ((p.budget !== p.old_budget) || (p.locked !== p.old_locked)) { + changes.push({ + budgetId : p.budgetId, + newBudget : p.budget, + newLocked : p.locked, + }); + } + }); + + // let the user know if there were no changes + if (changes.length === 0) { + Notify.success($translate.instant('BUDGET.EDIT_ACCOUNT_BUDGET.NO_CHANGES')); + return Instance.close(true); + } + + try { + await Budget.updateBudgetPeriods(changes); + } catch (err) { + Notify.handleError(err); + } finally { + vm.loading = false; + } + + return Instance.close(true); + }; + +} diff --git a/client/src/modules/budget/modal/import.html b/client/src/modules/budget/modal/import.html new file mode 100644 index 0000000000..1555e37d9a --- /dev/null +++ b/client/src/modules/budget/modal/import.html @@ -0,0 +1,43 @@ +
+ + + + + +
diff --git a/client/src/modules/budget/modal/import.js b/client/src/modules/budget/modal/import.js new file mode 100644 index 0000000000..3a79724d30 --- /dev/null +++ b/client/src/modules/budget/modal/import.js @@ -0,0 +1,70 @@ +angular.module('bhima.controllers') + .controller('ImportBudgetModalController', ImportBudgetModalController); + +ImportBudgetModalController.$inject = [ + 'data', 'BudgetService', '$uibModalInstance', + 'Upload', 'NotifyService', '$translate', +]; + +function ImportBudgetModalController(data, Budget, Instance, Upload, Notify, $translate) { + const vm = this; + + vm.cancel = Instance.close; + vm.budget = data; + vm.select = (file) => { + vm.noSelectedFile = !file; + }; + + vm.breadcrumb = $translate.instant('BUDGET.IMPORT.MODAL_BREADCRUMB', { fiscalYear : vm.budget.year.label }); + + vm.downloadTemplate = Budget.downloadTemplate; + + vm.submit = () => { + // send data only when a file is selected + if (!vm.file) { + vm.noSelectedFile = true; + return; + } + uploadFile(vm.file); + }; + + /** + * upload the file to server + * @param {string} file - name of file to upload + */ + function uploadFile(file) { + vm.uploadState = 'uploading'; + const params = { + url : `/budget/import/${vm.budget.year.id}`, + data : { file }, + }; + + // upload the file to the server + Upload.upload(params) + .then(handleSuccess, Notify.handleError, handleProgress); + + // success upload handler + function handleSuccess() { + // Populate the rest of the budget items + // (after the base budget data is entered eg period = 0) + Budget.populateBudget(vm.budget.year.id) + .then(() => { + return Budget.fillBudget(vm.budget.year.id); + }) + .then(() => { + // Finally alert the user of the success + vm.uploadState = 'uploaded'; + Notify.success($translate.instant('BUDGET.IMPORT.SUCCESS', { fyName : vm.budget.year.label })); + Instance.close(true); + }); + } + + // progress handler + // @TODO : does this work ??? Is it necessary? + function handleProgress(evt) { + file.progress = Math.min(100, parseInt((100.0 * evt.loaded) / evt.total, 10)); + vm.progressStyle = { width : String(file.progress).concat('%') }; + } + + } +} diff --git a/client/src/modules/budget/templates/acct_label.cell.html b/client/src/modules/budget/templates/acct_label.cell.html new file mode 100644 index 0000000000..1365cd94a7 --- /dev/null +++ b/client/src/modules/budget/templates/acct_label.cell.html @@ -0,0 +1,11 @@ +
+ + {{ row.entity.label }} +
diff --git a/client/src/modules/budget/templates/acct_number.cell.html b/client/src/modules/budget/templates/acct_number.cell.html new file mode 100644 index 0000000000..867ac4b006 --- /dev/null +++ b/client/src/modules/budget/templates/acct_number.cell.html @@ -0,0 +1,10 @@ +
+ {{ row.entity.number }} +
\ No newline at end of file diff --git a/client/src/modules/budget/templates/acct_type.cell.html b/client/src/modules/budget/templates/acct_type.cell.html new file mode 100644 index 0000000000..a4e22f71de --- /dev/null +++ b/client/src/modules/budget/templates/acct_type.cell.html @@ -0,0 +1,10 @@ +
+ {{ row.entity.type }} +
diff --git a/client/src/modules/budget/templates/action.tmpl.html b/client/src/modules/budget/templates/action.tmpl.html new file mode 100644 index 0000000000..4630a0489c --- /dev/null +++ b/client/src/modules/budget/templates/action.tmpl.html @@ -0,0 +1,18 @@ +
+ + FORM.BUTTONS.ACTIONS + + + + +
+ diff --git a/client/src/modules/budget/templates/actuals.cell.html b/client/src/modules/budget/templates/actuals.cell.html new file mode 100644 index 0000000000..bc90ee0592 --- /dev/null +++ b/client/src/modules/budget/templates/actuals.cell.html @@ -0,0 +1,12 @@ +
+ + +- + {{ row.entity.actuals | currency: grid.appScope.enterprise.currency_id:0 }} + +
diff --git a/client/src/modules/budget/templates/budget.cell.html b/client/src/modules/budget/templates/budget.cell.html new file mode 100644 index 0000000000..49416fc90c --- /dev/null +++ b/client/src/modules/budget/templates/budget.cell.html @@ -0,0 +1,11 @@ +
+ + +- + {{row.entity.budget | currency: grid.appScope.enterprise.currency_id:0}} + +
\ No newline at end of file diff --git a/client/src/modules/budget/templates/budgetYTD.cell.html b/client/src/modules/budget/templates/budgetYTD.cell.html new file mode 100644 index 0000000000..64a0854fdc --- /dev/null +++ b/client/src/modules/budget/templates/budgetYTD.cell.html @@ -0,0 +1,11 @@ +
+ + +- + {{ row.entity.budgetYTD | currency: grid.appScope.enterprise.currency_id:0 }} + +
\ No newline at end of file diff --git a/client/src/modules/budget/templates/deviationPct.cell.html b/client/src/modules/budget/templates/deviationPct.cell.html new file mode 100644 index 0000000000..8046c23c0e --- /dev/null +++ b/client/src/modules/budget/templates/deviationPct.cell.html @@ -0,0 +1,11 @@ +
+ + {{ row.entity.deviationPct }} % + +
diff --git a/client/src/modules/budget/templates/deviationYTDPct.cell.html b/client/src/modules/budget/templates/deviationYTDPct.cell.html new file mode 100644 index 0000000000..5062b8fc04 --- /dev/null +++ b/client/src/modules/budget/templates/deviationYTDPct.cell.html @@ -0,0 +1,11 @@ +
+ + {{ row.entity.deviationYTDPct }} % + +
diff --git a/client/src/modules/budget/templates/differenceYTD.cell.html b/client/src/modules/budget/templates/differenceYTD.cell.html new file mode 100644 index 0000000000..c7792714ad --- /dev/null +++ b/client/src/modules/budget/templates/differenceYTD.cell.html @@ -0,0 +1,10 @@ +
+ + {{ row.entity.differenceYTD | currency: grid.appScope.enterprise.currency_id:0 }} + +
\ No newline at end of file diff --git a/client/src/modules/budget/templates/period_actuals.cell.html b/client/src/modules/budget/templates/period_actuals.cell.html new file mode 100644 index 0000000000..e4d2e54481 --- /dev/null +++ b/client/src/modules/budget/templates/period_actuals.cell.html @@ -0,0 +1,10 @@ +
+ {{ grid.appScope.getPeriodActualsSign(col.name, row.entity) }} + {{ grid.appScope.getPeriodActuals(col.name, row.entity.period) | currency: grid.appScope.enterprise.currency_id:0 }} +
\ No newline at end of file diff --git a/client/src/modules/budget/templates/period_actuals_header.cell.html b/client/src/modules/budget/templates/period_actuals_header.cell.html new file mode 100644 index 0000000000..dc367204b0 --- /dev/null +++ b/client/src/modules/budget/templates/period_actuals_header.cell.html @@ -0,0 +1,4 @@ +
+{{ grid.appScope.getMonthLabel(col.name) }}
+BUDGET.ACTUALS_SUBTITLE +
diff --git a/client/src/modules/budget/templates/period_budget.cell.html b/client/src/modules/budget/templates/period_budget.cell.html new file mode 100644 index 0000000000..8865fbd806 --- /dev/null +++ b/client/src/modules/budget/templates/period_budget.cell.html @@ -0,0 +1,11 @@ +
+ + {{ grid.appScope.getPeriodBudgetSign(col.name, row.entity) }} + {{ grid.appScope.getPeriodBudget(col.name, row.entity.period) | currency: grid.appScope.enterprise.currency_id:0 }} +
\ No newline at end of file diff --git a/client/src/modules/budget/templates/period_budget_header.cell.html b/client/src/modules/budget/templates/period_budget_header.cell.html new file mode 100644 index 0000000000..a0c46baf3a --- /dev/null +++ b/client/src/modules/budget/templates/period_budget_header.cell.html @@ -0,0 +1,4 @@ +
+{{ grid.appScope.getMonthLabel(col.name) }}
+BUDGET.BUDGET_SUBTITLE +
diff --git a/client/src/modules/general-ledger/general-ledger.ctrl.js b/client/src/modules/general-ledger/general-ledger.ctrl.js index e5baf70255..91a0d1cec4 100644 --- a/client/src/modules/general-ledger/general-ledger.ctrl.js +++ b/client/src/modules/general-ledger/general-ledger.ctrl.js @@ -68,7 +68,7 @@ function GeneralLedgerController( enableFiltering : true, headerCellFilter : 'translate', width : 80, - cellClass : 'text-right', + cellClass : 'text-left', }, { field : 'label', displayName : 'TABLE.COLUMNS.LABEL', diff --git a/package.json b/package.json index a88c497e14..e1c914e1ed 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "dependencies": { "@ima-worldhealth/coral": "^2.13.0", "@ima-worldhealth/tree": "^2.6.0", - "@types/angular": "^1.8.8", + "@types/angular": "^1.8.9", "@uirouter/angularjs": "^1.0.29", "accounting-js": "^1.1.1", "adm-zip": "^0.5.10", @@ -122,11 +122,11 @@ "font-awesome": "^4.7.0", "handlebars": "^4.7.8", "helmet": "^7.1.0", - "inline-source": "^8.0.2", + "inline-source": "^8.0.3", "ioredis": "^5.3.2", "jaro-winkler": "^0.2.8", "jquery": "^3.7.1", - "jsbarcode": "^3.11.5", + "jsbarcode": "^3.11.6", "json-2-csv": "^5.0.1", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", @@ -164,7 +164,7 @@ "chai-spies-next": "^0.9.3", "cssnano": "^6.0.0", "del": "^6.1.1", - "eslint": "^8.53.0", + "eslint": "^8.54.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.29.0", "gulp": "^4.0.0", @@ -193,7 +193,7 @@ "release-it": "^17.0.0", "sinon": "^17.0.1", "standard-version": "^9.5.0", - "typescript": "^5.2.2" + "typescript": "^5.3.2" }, "homepage": "https://docs.bhi.ma", "engines": { diff --git a/server/config/constants.js b/server/config/constants.js index b126deba18..888826a0cc 100644 --- a/server/config/constants.js +++ b/server/config/constants.js @@ -1,4 +1,5 @@ // shared constants identical to `bhConstants` on the client side + // TODO(@jniles) unify this with `bhConstants` and build the same // constant configuration across the client/server. @@ -11,6 +12,8 @@ module.exports = { DELETE_PURCHASE_ORDER : 5, DELETE_STOCK_MOVEMENT : 6, DELETE_VOUCHER : 7, + EDIT_LOT : 8, + VALIDATE_REQUISITION : 9, }, accounts : { ROOT : 0, @@ -94,6 +97,7 @@ module.exports = { HAS_MINIMUM_WARNING : 'minimum_reached', HAS_OVERAGE_WARNING : 'over_maximum', UNUSED_STOCK : 'unused_stock', + AVAILABLE_NOT_USABLE : 'available_not_usable', }, reports : { AGED_DEBTOR : 'AGED_DEBTOR', @@ -136,4 +140,24 @@ module.exports = { MID : 2, HIGH : 3, }, + + /* MUST match budgetPeriods() in client bhConstants.js */ + /* eslint-disable no-multi-spaces */ + periods : [ + { periodNum : 0, label : 'PERIODS.NAME.ALL', key : 'all' }, + { periodNum : 1, label : 'PERIODS.NAME.JANUARY', key : 'jan' }, + { periodNum : 2, label : 'PERIODS.NAME.FEBRUARY', key : 'feb' }, + { periodNum : 3, label : 'PERIODS.NAME.MARCH', key : 'mar' }, + { periodNum : 4, label : 'PERIODS.NAME.APRIL', key : 'apr' }, + { periodNum : 5, label : 'PERIODS.NAME.MAY', key : 'may' }, + { periodNum : 6, label : 'PERIODS.NAME.JUNE', key : 'jun' }, + { periodNum : 7, label : 'PERIODS.NAME.JULY', key : 'jul' }, + { periodNum : 8, label : 'PERIODS.NAME.AUGUST', key : 'aug' }, + { periodNum : 9, label : 'PERIODS.NAME.SEPTEMBER', key : 'sep' }, + { periodNum : 10, label : 'PERIODS.NAME.OCTOBER', key : 'oct' }, + { periodNum : 11, label : 'PERIODS.NAME.NOVEMBER', key : 'nov' }, + { periodNum : 12, label : 'PERIODS.NAME.DECEMBER', key : 'dec' }, + ], + /* eslint-enable */ + }; diff --git a/server/config/routes.js b/server/config/routes.js index 2a999603b0..d09f30327e 100644 --- a/server/config/routes.js +++ b/server/config/routes.js @@ -92,6 +92,7 @@ const invoicingFees = require('../controllers/finance/invoicingFees'); const unpaidInvoicePayments = require('../controllers/finance/reports/unpaid_invoice_payments'); const accounts = require('../controllers/finance/accounts'); const subsidies = require('../controllers/finance/subsidies'); +const budget = require('../controllers/finance/budget'); const patientInvoice = require('../controllers/finance/patientInvoice'); const financeReports = require('../controllers/finance/reports'); const discounts = require('../controllers/finance/discounts'); @@ -255,6 +256,7 @@ exports.configure = function configure(app) { app.get('/accounts/:id', accounts.detail); app.get('/accounts/:id/balance', accounts.getBalance); app.get('/accounts/:id/balance/:fiscalYearId', accounts.getAnnualBalance); + app.get('/accounts/:fiscalYearId/all_balances', accounts.getAllAnnualBalances); app.get('/accounts/:id/openingBalance', accounts.getOpeningBalanceForPeriod); app.get('/accounts/:id/cost-center', accounts.lookupCostCenter); app.post('/accounts', accounts.create); @@ -316,6 +318,20 @@ exports.configure = function configure(app) { app.get('/fiscal/:id/closing_balance', fiscal.getClosingBalanceRoute); app.get('/fiscal/:id/periods', fiscal.getPeriods); + app.get('/fiscal/:id/periodZero', fiscal.getPeriodZero); + + // Budget routes + app.get('/budget', budget.list); + app.get('/budget/data/:fiscal_year', budget.getBudgetData); + app.get('/budget/download_template_file', budget.downloadTemplate); + app.post('/budget', budget.insertBudgetItem); + app.put('/budget/update/:id', budget.updateBudgetItem); + app.put('/budget/updatePeriodBudgets', budget.updateBudgetPeriods); + app.post('/budget/import/:fiscal_year', upload.middleware('csv', 'file'), budget.importBudget); + app.delete('/budget/:fiscal_year', budget.deleteBudget); + app.post('/budget/populate/:fiscal_year', budget.populateBudgetPeriods); + app.put('/budget/fill/:fiscal_year', budget.fillBudget); + app.get('/reports/budget', budget.getReport); // periods API app.get('/periods', period.list); diff --git a/server/controllers/finance/accounts/index.js b/server/controllers/finance/accounts/index.js index 6ca4839734..54e5f62f7d 100644 --- a/server/controllers/finance/accounts/index.js +++ b/server/controllers/finance/accounts/index.js @@ -161,7 +161,7 @@ function list(req, res, next) { SELECT a.id, a.enterprise_id, a.locked, a.created, a.reference_id, a.number, a.label, a.parent, a.type_id, at.type, at.translation_key, a.hidden, a.cost_center_id, cc.label AS cost_center_label - FROM account AS a + FROM account AS a JOIN account_type AS at ON a.type_id = at.id LEFT JOIN cost_center AS cc ON a.cost_center_id = cc.id `; @@ -170,6 +170,7 @@ function list(req, res, next) { filters.equals('type_id', 'type_id', 'a', true); filters.equals('locked'); filters.equals('hidden'); + filters.equals('number'); filters.setOrder('ORDER BY a.number'); @@ -290,6 +291,44 @@ function getAnnualBalance(req, res, next) { .done(); } +/** + * Get the account balances of all accounts for a fiscal year + * + * GET /accounts/:fiscalYearId/all_balances + * + * WARNING: This API uses the period_total table. It is necessary + * to update the period_total table before calling this function by + * using the zRecalculatePeriodTotals() SQL procedure. + * + * @param {object} req - the request object + * @param {object} res - the response object + * @param {object} next - next middleware object to pass control to + * @returns {Array} an array of the account balances for all accounts + */ +function getAllAnnualBalances(req, res, next) { + const { fiscalYearId } = req.params; + + const query = ` + SELECT + pt.account_id, a.number, a.label, a.type_id, at.type, + IFNULL(SUM(pt.debit), 0) AS debit, + IFNULL(SUM(pt.credit), 0) AS credit, + IFNULL(SUM(pt.debit - pt.credit), 0) AS balance + FROM period_total pt + JOIN account AS a ON a.id = pt.account_id + JOIN account_type AS at ON at.id = a.type_id + WHERE pt.fiscal_year_id = ? + GROUP BY pt.account_id + ORDER BY pt.account_id; + `; + return db.exec(query, [fiscalYearId]) + .then(rows => { + res.status(200).json(rows); + }) + .catch(next) + .done(); +} + /** * @function getOpeningBalanceForPeriod * @@ -420,3 +459,4 @@ exports.processAccountDepth = processAccountDepth; exports.importing = importing; exports.lookupCostCenter = lookupCostCenter; exports.getAnnualBalance = getAnnualBalance; +exports.getAllAnnualBalances = getAllAnnualBalances; diff --git a/server/controllers/finance/budget/index.js b/server/controllers/finance/budget/index.js new file mode 100644 index 0000000000..04e74db27a --- /dev/null +++ b/server/controllers/finance/budget/index.js @@ -0,0 +1,1110 @@ +/** + * The /budget HTTP API endpoint + * + * This module is responsible for budget storing and retrieving as well as other budget-related actions + */ + +const _ = require('lodash'); +const path = require('path'); + +const db = require('../../../lib/db'); +const util = require('../../../lib/util'); +const constants = require('../../../config/constants'); +const BadRequest = require('../../../lib/errors/BadRequest'); + +const Fiscal = require('../fiscal'); +const budgetReport = require('../reports/budget'); + +const legalAccountTypes = ['title', 'income', 'expense']; + +const { TITLE, EXPENSE, INCOME } = constants.accounts; +const allowedTypes = [TITLE, EXPENSE, INCOME]; + +// expose the API +exports.deleteBudget = deleteBudget; +exports.downloadTemplate = downloadTemplate; +exports.fillBudget = fillBudget; +exports.getBudgetData = getBudgetData; +exports.buildBudgetData = buildBudgetData; +exports.importBudget = importBudget; +exports.insertBudgetItem = insertBudgetItem; +exports.list = list; +exports.populateBudgetPeriods = populateBudgetPeriods; +exports.updateBudgetItem = updateBudgetItem; +exports.updateBudgetPeriods = updateBudgetPeriods; +exports.getReport = budgetReport.getReport; + +const periodsSql = 'SELECT * FROM period p WHERE fiscal_year_id = ?'; + +const budgetSql = ` + SELECT + b.id AS budgetId, b.period_id, b.budget, b.locked, + a.id, a.number AS acctNum, a.label AS acctLabel, + a.type_id AS acctTypeId, at.type AS acctType, p.number AS periodNum + FROM budget AS b + JOIN period AS p ON p.id = b.period_id + JOIN account AS a ON a.id = b.account_id + JOIN account_type AS at ON at.id = a.type_id + WHERE p.fiscal_year_id = ? + ORDER BY acctNum, periodNum +`; + +/** + * List budget data + * + * GET /budget (include 'fiscal_year_id' in the query) + * + * @param {object} req - the request object + * @param {object} res - the response object + * @param {object} next - next middleware object to pass control to + * @returns {object} HTTP/JSON response object + */ +function list(req, res, next) { + const fiscalYearId = req.query.fiscal_year_id; + return db.exec(budgetSql, [fiscalYearId]) + .then(rows => { + res.status(200).json(rows); + }) + .catch(next) + .done(); +} + +/** + * Builds the budget data for display + * + * @param {number} fiscalYearId - id for the fiscal year + * @returns {Promise} of all the rows of account and budget data + */ +function buildBudgetData(fiscalYearId) { + let allAccounts; + let accounts; + let periodActuals; + + // Get basic info on all relevant accounts + const accountsSql = ` + SELECT + a.id, a.number, a.label, a.locked, a.type_id, + a.parent, a.locked, a.hidden + FROM account AS a + WHERE a.type_id in (${allowedTypes}); + `; + + // First get the basic account and FY budget data (if available) + const sql = ` + SELECT + a.id, a.number, a.type_id, at.type AS acctType, + a.label, a.parent, a.locked AS acctLocked, a.hidden, + bdata.budget + FROM account AS a + JOIN account_type AS at ON at.id = a.type_id + LEFT JOIN ( + SELECT b.budget, b.account_id + FROM budget AS b + JOIN period AS p ON p.id = b.period_id + WHERE p.number = 0 and p.fiscal_year_id = ? + ) AS bdata ON bdata.account_id = a.id + WHERE a.type_id in (${INCOME}, ${EXPENSE}); + `; + + const actualsSql = ` + SELECT + a.id, + IFNULL(SUM(pt.debit), 0) AS debit, + IFNULL(SUM(pt.credit), 0) AS credit + FROM period_total pt + JOIN account AS a ON a.id = pt.account_id + JOIN account_type AS at ON at.id = a.type_id + WHERE pt.fiscal_year_id = ? AND a.type_id in (${INCOME}, ${EXPENSE}) + GROUP BY a.id; + `; + + const periodActualsSql = ` + SELECT a.id, pt.debit, pt.credit, p.number AS periodNum + FROM period_total pt + JOIN period AS p ON p.id = pt.period_id + JOIN account AS a ON a.id = pt.account_id + JOIN account_type AS at ON at.id = a.type_id + WHERE pt.fiscal_year_id = ? AND a.type_id in (${INCOME}, ${EXPENSE}) + `; + + const months = constants.periods.filter(elt => elt.periodNum !== 0); + + return db.exec(accountsSql) + .then(acctData => { + allAccounts = acctData; + return db.exec(sql, [fiscalYearId]); + }) + .then(data => { + // This is the basic budget/accounts data + accounts = data; + + // Get the FY debit/credit balances for all accounts + return db.exec(actualsSql, [fiscalYearId]); + }) + .then(actuals => { + // Save the FY debit/credit balances for each account + accounts.forEach(acct => { + const adata = actuals.find(item => item.id === acct.id); + acct.debit = adata ? adata.debit : null; + acct.credit = adata ? adata.credit : null; + }); + // Get the actuals for each account and period in the FY + return db.exec(periodActualsSql, [fiscalYearId]); + }) + .then(pActuals => { + periodActuals = pActuals; + // Get the budget information for each period in the FY + return db.exec(budgetSql, [fiscalYearId]); + }) + .then(budget => { + // Add data to for the months for items with budget details + accounts.forEach(acct => { + acct.period = []; + + months.forEach(mon => { + // Find the budget record for the period for this account + const bdata = budget.find(bd => bd.id === acct.id && bd.periodNum === mon.periodNum); + const adata = periodActuals.find(item => item.id === acct.id && item.periodNum === mon.periodNum); + const record = { + key : mon.key, + label : mon.label, + }; + if (bdata) { + record.budget = bdata.budget; + record.budgetId = bdata.budgetId; + record.periodNum = bdata.periodNum; + record.periodId = bdata.period_id; + record.locked = bdata.locked; + } + if (adata) { + record.credit = adata.credit || 0; + record.debit = adata.debit || 0; + record.actuals = (acct.type_id === 4) ? adata.credit - adata.debit : adata.debit - adata.credit; + } + acct.period.push(record); + }); + }); + return accounts; + }) + .then(data => { + + data.forEach(acct => { + // Add the code/csv/translation friendly account types + switch (acct.type_id) { + case TITLE: + acct.acctType = 'title'; + acct.type = 'ACCOUNT.TYPES.TITLE'; + acct.isTitle = true; + break; + case EXPENSE: + acct.acctType = 'expense'; + acct.type = 'ACCOUNT.TYPES.EXPENSE'; + break; + case INCOME: + acct.acctType = 'income'; + acct.type = 'ACCOUNT.TYPES.INCOME'; + break; + default: + } + }); + return sortAccounts(data, allAccounts); + }) + .then(data => { + computeSubTotals(data); + return computeBudgetTotalsYTD(data, fiscalYearId); + }) + .then(data => { + return data; + }); +} + +/** + * Get the account and budget data for the fiscal year + * + * GET /budget/data/:fiscal_year + * + * @param {object} req - the request object + * @param {object} res - the response object + * @param {object} next - next middleware object to pass control to + * @returns {object} HTTP/JSON response object + */ +function getBudgetData(req, res, next) { + const fiscalYearId = req.params.fiscal_year; + + return buildBudgetData(fiscalYearId) + .then(data => { + res.status(200).json(data); + }) + .catch(next) + .done(); +} + +/** + * Sort the accounts into income and expenses sections + * + * (1) divide the accounts into income and expense accounts + * (2) put the income accounts first + * (3) add an income totals line after the incomes (and a blank row afterwards) + * (4) add expenses accounts + * (5) add an expenses total line + * + * Note that only income and expense accounts along with their title accounts are preserved. + * All other account types (such as balance accounts) are excluded. + * + * @param {Array} origAccounts - list of accounts (modified in place) + * @param {Array} allAccounts - list of all accounts (including title accounts) + * @returns {Array} updated accounts list + */ +function sortAccounts(origAccounts, allAccounts) { + + // first separate the types of accounts + const expenses = origAccounts.filter(item => item.type_id === EXPENSE).sort((a, b) => a.number - b.number); + const incomes = origAccounts.filter(item => item.type_id === INCOME).sort((a, b) => a.number - b.number); + + // Construct the list of periods (leave out the FY total period) + const periods = constants.periods.filter(elt => elt.periodNum !== 0); + + // Reassemble the accounts array in the desired order + const accounts = []; + + incomes.forEach(acct => { + // Insert the title accounts first (if not already added) + const parentIds = getTitleAccounts(acct.id, allAccounts); + parentIds.forEach(pid => { + if (!accounts.find(item => item.id === pid)) { + const title = allAccounts.find(a => a.id === pid); + title.type = typeToken(title.type_id); + accounts.push(title); + } + }); + + accounts.push(acct); + }); + + // Add a fake account for the income totals row + accounts.push({ + $$treeLevel : 0, + _$$treeLevel : 0, + id : null, + number : null, + acctType : 'total-income', + isTitle : true, + type : 'ACCOUNT.TYPES.INCOME', + label : 'BUDGET.INCOME_TOTAL', + parent : 0, + budget : null, + debit : null, + credit : null, + period : [...periods], // Make a copy + }); + + // Add in a blank row to separate income accounts from expense accounts + accounts.push({ + $$treeLevel : 0, + _$$treeLevel : 0, + id : null, + number : null, + type_id : TITLE, + isTitle : true, + label : '', + parent : 0, + budget : null, + debit : null, + credit : null, + }); + + // Add the expense accounts next + expenses.forEach(acct => { + // Insert the title accounts first (if not already added) + const parentIds = getTitleAccounts(acct.id, allAccounts); + parentIds.forEach(pid => { + if (!accounts.find(item => item.id === pid)) { + const title = allAccounts.find(a => a.id === pid); + title.type = typeToken(title.type_id); + accounts.push(allAccounts.find(a => a.id === pid)); + } + }); + + accounts.push(acct); + }); + + // Add a fake account for the expense totals row + accounts.push({ + $$treeLevel : 0, + _$$treeLevel : 0, + id : null, + number : null, + acctType : 'total-expenses', + isTitle : true, + type : 'ACCOUNT.TYPES.EXPENSE', + label : 'BUDGET.EXPENSES_TOTAL', + parent : 0, + budget : null, + debit : null, + credit : null, + period : [...periods], // Make a copy + }); + + // Add in a blank row to separate income accounts from expense accounts + accounts.push({ + $$treeLevel : 0, + _$$treeLevel : 0, + id : null, + number : null, + type_id : TITLE, + isTitle : true, + label : '', + parent : 0, + budget : null, + debit : null, + credit : null, + }); + + // Finally, add a fake account for the totals summary row + accounts.push({ + $$treeLevel : 0, + _$$treeLevel : 0, + id : null, + number : null, + acctType : 'total-summary', + isTitle : true, + type : '', + label : 'BUDGET.TOTAL_SUMMARY', + parent : 0, + budget : null, + debit : null, + credit : null, + period : [...periods], // Make a copy + }); + + return accounts; +} + +/** + * Get the IDs of the parent (title) accounts for an account + * + * This is a recursive function used by getTitleAccounts + * + * @param {number} id - id of account to get parents of + * @param {object} accounts - list of all accounts + * @param {Array} parents - current list of parents + * @returns {Array} of parent account IDs + */ +function getParentAccounts(id, accounts, parents) { + const acct = accounts.find(item => item.id === id); + if (acct.parent === id) { + // In some cases, some accounts erroneously point to themselves as parents. + // Assume that there are no more parents. + return parents; + } + const parentAcct = accounts.find(a => (a.id === acct.parent) && (a.type_id === 6)); + // const parentAcct = accounts.find(a => a.id === acct.parent); + if (parentAcct) { + parents.push(parentAcct.id); + return getParentAccounts(parentAcct.id, accounts, parents); + } + return parents; +} + +/** + * Get a list of the parent title accounts for an account + * + * @param {number} id - id of account to get title accounts for + * @param {Array} accounts - list of all acounts + * @returns {Array} of title (parent) accounts for this account (in hierarchical order) + */ +function getTitleAccounts(id, accounts) { + const parents = getParentAccounts(id, accounts, []); + return parents.sort((a, b) => a - b); +} + +/** + * Get the periods for a fiscal year + * + * An additional pseudo period (number = 0) is appended + * for the bounds of the FY + * + * @param {number} fiscalYearId - ID for the fiscal year + * @returns {Promise} of the list of periods + */ +function getPeriods(fiscalYearId) { + let fiscalYear; + return Fiscal.lookupFiscalYear(fiscalYearId) + .then(fy => { + fiscalYear = fy; + return Fiscal.getPeriodByFiscal(fiscalYearId); + }) + .then(periods => { + // Append period zero to provide FY dates + periods.push({ + number : 0, + end_date : fiscalYear.end_date, + start_date : fiscalYear.start_date, + }); + + // Set up compatibility with other period-related code for budgets + periods.forEach(p => { + p.periodNum = p.number; + }); + + return periods; + }); +} + +/** + * Compute the subtotals for expenses and incomes + * + * NOTE: The sortAccounts must have be called first in order to add the subtotal rows. + * + * @param {object} accounts - the list of accounts (processed in place) + */ +function computeSubTotals(accounts) { + + const incomeTotal = accounts.find(acct => acct.acctType === 'total-income'); + const expensesTotal = accounts.find(acct => acct.acctType === 'total-expenses'); + const summaryTotal = accounts.find(acct => acct.acctType === 'total-summary'); + + // Make sure the rows for account totals have been added. + if (!incomeTotal || !expensesTotal) { + throw new Error('sortAccounts must be called before computeSubTotals'); + } + + let budgetTotal = 0; + let debitTotal = 0; + let creditTotal = 0; + let actualsTotal = 0; + + // Construct the list of periods (leave out the FY total period) + const periods = constants.periods.filter(elt => elt.periodNum !== 0); + const periodSums = [...periods]; // Make a copy + + // compute income totals + accounts.forEach(acct => { + if (acct.type_id === INCOME) { + + // Compute the actuals + acct.actuals = acct.credit ? acct.credit : 0; + if (acct.debit) { + acct.actuals -= acct.debit; + } + + // Compute the percent deviation + acct.deviationPct = ((acct.credit || acct.debit) && acct.budget && acct.budget !== 0) + ? Math.round(100.0 * (acct.actuals / acct.budget)) : null; + + // Sum the totals + budgetTotal += acct.budget ? acct.budget : 0; + creditTotal += acct.credit ? acct.credit : 0; + debitTotal += acct.debit ? acct.debit : 0; + actualsTotal += acct.credit ? acct.credit : 0; + if (acct.debit) { + actualsTotal -= acct.debit; + } + + // Sum the totals for each period + periodSums.forEach(pt => { + pt.budget = 0; + pt.credit = 0; + pt.debit = 0; + pt.actuals = 0; + }); + periodSums.forEach(pt => { + const acctPeriod = acct.period.find(item => item.key === pt.key); + if (acctPeriod) { + pt.budget += acctPeriod.budget ? acctPeriod.budget : 0; + pt.credit += acctPeriod.credit ? acctPeriod.credit : 0; + pt.debit += acctPeriod.debit ? acctPeriod.debit : 0; + pt.actuals += acctPeriod.credit ? acctPeriod.credit : 0; + if (acctPeriod.debit) { + pt.actuals -= acctPeriod.debit; + } + } + }); + } + }); + + // Save the income totals + incomeTotal.budget = budgetTotal; + incomeTotal.debit = debitTotal; + incomeTotal.credit = creditTotal; + incomeTotal.actuals = actualsTotal; + incomeTotal.deviationPct = (budgetTotal !== 0) + ? Math.round(100.0 * (actualsTotal / budgetTotal)) : null; + + // save the income totals for the periods + incomeTotal.period.forEach(pit => { + const pdata = periodSums.find(item => item.key === pit.key); + pit.budget = pdata.budget && pdata.budget !== 0 ? pdata.budget : null; + pit.credit = pdata.credit && pdata.credit !== 0 ? pdata.credit : null; + pit.debit = pdata.debit && pdata.debit !== 0 ? pdata.debit : null; + pit.actuals = pdata.actuals && pdata.actuals !== 0 ? pdata.actuals : null; + }); + + // compute expense totals + budgetTotal = 0; + debitTotal = 0; + creditTotal = 0; + actualsTotal = 0; + + accounts.forEach(acct => { + if (acct.type_id === EXPENSE) { + + // Compute the actuals + acct.actuals = acct.debit ? acct.debit : 0; + if (acct.credit) { + acct.actuals -= acct.credit; + } + + // Compute the percent deviation + acct.deviationPct = ((acct.credit || acct.debit) && acct.budget && acct.budget !== 0) + ? Math.round(100.0 * (acct.actuals / acct.budget)) : null; + + // Sum the totals + budgetTotal += acct.budget ? acct.budget : 0; + creditTotal += acct.credit ? acct.credit : 0; + debitTotal += acct.debit ? acct.debit : 0; + actualsTotal += acct.debit ? acct.debit : 0; + if (acct.credit) { + actualsTotal -= acct.credit; + } + + // Sum the totals for each period + periodSums.forEach(pt => { + pt.budget = 0; + pt.credit = 0; + pt.debit = 0; + pt.actuals = 0; + }); + + periodSums.forEach(pt => { + const acctPeriod = acct.period.find(item => item.key === pt.key); + if (acctPeriod) { + pt.budget += acctPeriod.budget ? acctPeriod.budget : 0; + pt.credit += acctPeriod.credit ? acctPeriod.credit : 0; + pt.debit += acctPeriod.debit ? acctPeriod.debit : 0; + pt.actuals += acctPeriod.debit ? acctPeriod.debit : 0; + if (acctPeriod.credit) { + pt.actuals -= acctPeriod.credit; + } + } + }); + } + }); + + // Save the expense totals + expensesTotal.budget = budgetTotal; + expensesTotal.debit = debitTotal; + expensesTotal.credit = creditTotal; + expensesTotal.actuals = actualsTotal; + expensesTotal.deviationPct = (budgetTotal !== 0) + ? Math.round(100.0 * (actualsTotal / budgetTotal)) : null; + + // save the expense totals for the periods + expensesTotal.period.forEach(pet => { + const pdata = periodSums.find(item => item.key === pet.key); + pet.budget = pdata.budget && pdata.budget !== 0 ? pdata.budget : null; + pet.credit = pdata.credit && pdata.credit !== 0 ? pdata.credit : null; + pet.debit = pdata.debit && pdata.debit !== 0 ? pdata.debit : null; + pet.actuals = pdata.actuals && pdata.actuals !== 0 ? pdata.actuals : null; + }); + + summaryTotal.budget = incomeTotal.budget - expensesTotal.budget; + summaryTotal.debit = incomeTotal.debit - expensesTotal.debit; + summaryTotal.credit = incomeTotal.credit - expensesTotal.credit; + summaryTotal.actuals = incomeTotal.actuals - expensesTotal.actuals; + summaryTotal.deviationPct = (summaryTotal.budget !== 0) + ? Math.round(100.0 * (summaryTotal.actuals / summaryTotal.budget)) : null; + + // save the expense totals for the periods + summaryTotal.period.forEach(pst => { + const pdata = periodSums.find(item => item.key === pst.key); + pst.budget = pdata.budget && pdata.budget !== 0 ? pdata.budget : null; + pst.credit = pdata.credit && pdata.credit !== 0 ? pdata.credit : null; + pst.debit = pdata.debit && pdata.debit !== 0 ? pdata.debit : null; + pst.actuals = pdata.actuals && pdata.actuals !== 0 ? pdata.actuals : null; + }); + +} + +/** + * Update the accounts with YTD calculations + * + * @param {Array} accounts - list of accounts to process for YTD data + * @param {number} fiscalYearId - Id of the fiscal year + * @returns {Promise} of the accounts with updated YTD data + */ +async function computeBudgetTotalsYTD(accounts, fiscalYearId) { + const today = new Date(); + + const periods = (await getPeriods(fiscalYearId)).filter(elt => elt.periodNum !== 0); + + let incomeTotalYTD = 0; + let expensesTotalYTD = 0; + + accounts.forEach(acct => { + if ((acct.type_id === INCOME) || (acct.type_id === EXPENSE)) { + let budgetYTD = 0; + + acct.period.forEach(p => { + const pdata = periods.find(item => item.number === p.periodNum); + + if (pdata) { + const pStart = new Date(pdata.start_date); + const pEnd = new Date(pdata.end_date); + + if (pEnd < today) { + // this period is entirely in the past, count the full period budget + budgetYTD += p.budget ? p.budget : 0; + } else if (pStart > today) { + // NOP - This is period is entirely in the future, do not count it + } else { + // today must be inside the period, compute the budget portion + const monthDays = Math.round((pEnd.getTime() - pStart.getTime()) / (1000 * 60 * 60 * 24)); + const numDays = Math.round((today.getTime() - pStart.getTime()) / (1000 * 60 * 60 * 24)); + budgetYTD += p.budget ? (p.budget * (numDays / monthDays)) : 0; + } + } + acct.budgetYTD = budgetYTD; + acct.differenceYTD = acct.actuals - budgetYTD; + + // Compute the deviation percent YTD + acct.deviationYTDPct = (budgetYTD !== 0) + ? Math.round(100.0 * (acct.actuals / budgetYTD)) : null; + }); + + if (acct.type_id === INCOME) { + incomeTotalYTD += budgetYTD; + } else { + expensesTotalYTD += budgetYTD; + } + } + }); + + // Add the YTD totals to the income summary row + const incomeTotalRow = accounts.find(acct => acct.acctType === 'total-income'); + if (incomeTotalRow) { + incomeTotalRow.budgetYTD = incomeTotalYTD; + incomeTotalRow.differenceYTD = incomeTotalRow.actuals - incomeTotalRow.budgetYTD; + incomeTotalRow.deviationYTDPct = incomeTotalYTD !== 0 + ? Math.round(100.0 * (incomeTotalRow.actuals / incomeTotalRow.budgetYTD)) : null; + } + + // Add the YTD totals to the expenses summary row + const expensesTotalRow = accounts.find(acct => acct.acctType === 'total-expenses'); + if (expensesTotalRow) { + expensesTotalRow.budgetYTD = expensesTotalYTD; + expensesTotalRow.differenceYTD = expensesTotalRow.actuals - expensesTotalRow.budgetYTD; + expensesTotalRow.deviationYTDPct = expensesTotalYTD !== 0 + ? Math.round(100.0 * (expensesTotalRow.actuals / expensesTotalRow.budgetYTD)) : null; + } + + const summaryTotalRow = accounts.find(acct => acct.acctType === 'total-summary'); + if (summaryTotalRow) { + summaryTotalRow.budgetYTD = incomeTotalYTD - expensesTotalYTD; + summaryTotalRow.differenceYTD = summaryTotalRow.actuals - summaryTotalRow.budgetYTD; + summaryTotalRow.deviationYTDPct = expensesTotalYTD !== 0 + ? Math.round(100.0 * (summaryTotalRow.actuals / summaryTotalRow.budgetYTD)) : null; + } + return accounts; +} + +/** + * Send the client the template file for budget import + * + * GET /budget/download_template_file + * + * @param {object} req - the request object + * @param {object} res - the response object + * @param {object} next - next middleware object to pass control to + */ +function downloadTemplate(req, res, next) { + try { + const file = path.join(__dirname, '../../../resources/templates/import-budget-template.csv'); + res.download(file); + } catch (error) { + next(error); + } +} + +/** + * Return the translation token for this account + * + * @param {number} typeId - the account type id (number) + * @returns {string} the translation token for the specified account type + */ +function typeToken(typeId) { + const typeName = Object.keys(constants.accounts).find(key => constants.accounts[key] === typeId); + const token = typeName ? `ACCOUNT.TYPES.${typeName}` : ''; + return token; +} + +/** + * Delete all budget entries for a specific fiscal year + * + * DELETE /budget/:fiscal_year + * + * @param {object} req - the request object + * @param {object} res - the response object + * @param {object} next - next middleware object to pass control to + */ +function deleteBudget(req, res, next) { + try { + if (!req.params.fiscal_year) { + throw new BadRequest(`ERROR: Missing 'fiscal_year' ID parameter in DELETE /budget/:fiscal_year`); + } + const fiscalYearId = Number(req.params.fiscal_year); + db.exec('CALL DeleteBudget(?)', [fiscalYearId]) + .then(() => { + res.sendStatus(200); + }); + } catch (error) { + next(error); + } +} + +/** + * Import budget data for a fiscal year + * + * POST /budget/import/:fiscal_year + * + * @param {object} req - the request object + * @param {object} res - the response object + * @param {object} next - next middleware object to pass control to + */ +async function importBudget(req, res, next) { + try { + if (!req.params.fiscal_year) { + throw new BadRequest(`ERROR: Missing 'fiscal_year' ID parameter in POST /budget/import`); + } + const fiscalYearId = Number(req.params.fiscal_year); + + if (!req.files || req.files.length === 0) { + throw new BadRequest('Expected at least one file upload but did not receive any files.', + 'ERRORS.MISSING_UPLOAD_FILES'); + } + const filePath = req.files[0].path; + + const data = await util.formatCsvToJson(filePath); + + if (!hasValidHeaders(data)) { + throw new BadRequest('The given budget file has a bad column headers', + 'BUDGET.IMPORT_BUDGET_BAD_HEADERS'); + } + + // Make sure the data in the file is valid + const validData = await hasValidData(data); + if (validData !== true) { + const [errCode, lineNum] = validData; + throw new BadRequest(`The given budget file has missing or invalid data on line ${lineNum + 1}`, + errCode); + } + + // Clear any previously uploaded budget data + db.exec('CALL DeleteBudget(?)', [fiscalYearId]) + .then(() => { + // Get the period ID for the totals for the fiscal year + return db.one('SELECT id FROM period WHERE period.number = 0 AND period.fiscal_year_id = ?', [fiscalYearId]); + }) + .then((fyPeriod) => { + const periodId = fyPeriod.id; + // Then create new budget entries for the totals for the fiscal year + const sql = 'CALL InsertBudgetItem(?, ?, ?, ?)'; + const transaction = db.transaction(); + data.forEach(line => { + // Do not insert budgets for title accounts or accounts with zero budget + if (line.Type !== 'title' && Number(line.Budget) > 0) { + transaction.addQuery(sql, [line.AcctNum, periodId, line.Budget, 1]); + // NOTE: Always lock budget lines for the entire year (period.number == 0) + } + }); + return transaction.execute(); + }) + .then(() => { + res.sendStatus(200); + }); + } catch (e) { + next(e); + } + +} + +/** + * Insert a new budget item + * + * POST /budget (with query) + * + * Query must include: acctNumber, periodId, budget, locked + * + * NOTE: This will fail if the item already exists + * + * @param {object} req - the request object + * @param {object} res - the response object + * @param {object} next - next middleware object to pass control to + * @returns {object} HTTP/JSON response object + */ +function insertBudgetItem(req, res, next) { + const q = req.query; + const sql = 'CALL InsertBudgetItem(?, ?, ?, ?)'; + return db.exec(sql, [q.acctNumber, q.periodId, q.budget, q.locked]) + .then(() => { + res.sendStatus(200); + }) + .catch(next) + .done(); +} + +/** + * Update a budget item or create it if it does not exist + * + * PUT /budget/update/:id (put data to change in query) + * + * Query should include: budget and/or locked + * + * @param {object} req - the request object + * @param {object} res - the response object + * @param {object} next - next middleware object to pass control to + * @returns {object} HTTP/JSON response object + */ +function updateBudgetItem(req, res, next) { + const budgetId = req.params.id; + const q = req.query; + const params = []; + + // Construct the SQL statement + let sql = 'UPDATE `budget` SET '; + if (q.budget) { + sql += `\`budget\` = ${q.budget}`; + params.push(q.budget); + } + if (q.budget && q.locked) { + sql += ', '; + } + if (q.locked) { + sql += `\`locked\` = ${q.locked}`; + params.push(q.locked); + } + sql += ' WHERE `budget`.`id` = ?'; + + return db.exec(sql, [budgetId]) + .then(() => { + res.sendStatus(200); + }) + .catch(next) + .done(); +} + +/** + * Update periods with new budget and locked values + * + * /PUT /budget/updatePeriodBudgets + * + * Provide a list of (periodId, newBudget, and newLocked) values + * as { params : [{change},...] } as the second argument of the put. + * + * @param {object} req - the request object + * @param {object} res - the response object + * @param {object} next - next middleware object to pass control to + * @returns {object} HTTP/JSON response object + */ +function updateBudgetPeriods(req, res, next) { + const { params } = req.body; + + // Update the budget records + const transaction = db.transaction(); + params.forEach(p => { + transaction.addQuery('UPDATE budget SET budget = ?, locked = ? WHERE id = ?', + [p.newBudget, p.newLocked, p.budgetId]); + }); + return transaction.execute() + .then(() => { + res.sendStatus(200); + }) + .catch(next) + .done(); +} + +/** + * Populate the budget periods for the fiscal year + * + * PUT /budget/populate/:fiscal_year + * + * ASSUMES budget has already been added for the fiscal year (period.number=0) + * + * WARNING: Does not insert budget amounts and sets locked=0 for each new period + * + * @param {object} req - the request object + * @param {object} res - the response object + * @param {object} next - next middleware object to pass control to + * @returns {object} HTTP/JSON response object + */ +function populateBudgetPeriods(req, res, next) { + const fiscalYearId = Number(req.params.fiscal_year); + let periods; + let budgetData; + + // Get the periods for this fiscal year + return db.exec(periodsSql, [fiscalYearId]) + .then(pres => { + periods = pres; + + // Now get the budget data for the year + return db.exec(budgetSql, [fiscalYearId]); + }) + .then(bres => { + budgetData = bres; + const transaction = db.transaction(); + budgetData.forEach(b => { + periods.forEach(p => { + if (p.number > 0 && p.number < 13) { + transaction.addQuery('CALL InsertBudgetItem(?, ?, ?, ?)', + [b.acctNum, p.id, 0, 0]); + } + }); + }); + return transaction.execute(); + }) + .then(() => { + res.sendStatus(200); + }) + .catch(next) + .done(); +} + +/** + * Fill/update the budget data for the fiscal year + * + * PUT /budget/fill/:fiscal_year + * + * ASSUMES All budget periods have been created for all accounts for the fiscal year + * + * @param {object} req - the request object + * @param {object} res - the response object + * @param {object} next - next middleware object to pass control to + * @returns {object} HTTP/JSON response object + */ +async function fillBudget(req, res, next) { + const fiscalYearId = Number(req.params.fiscal_year); + + let budgetData; + + // Now get the budget data for the year (just for period.number = 0) + const sql = budgetSql.replace('ORDER BY', 'AND p.number = 0 ORDER BY'); + return db.exec(sql, [fiscalYearId]) + .then(bRes => { + budgetData = bRes; + + // Rebalance the budget for each account + const promises = []; + budgetData.forEach(b => { + const acct = b.id; + const totalBudget = b.budget; + promises.push(rebalanceBudget(fiscalYearId, acct, totalBudget, false)); + }); + return Promise.all(promises); + }) + .then(() => { + res.sendStatus(200); + }) + .catch(next) + .done(); +} + +/* -------------------------------------------------------------------------------- */ + +/** + * Rebalance the budget values for the account for the FY. + * (Compute total for locked periods and redristribute the rest to the unlocked periods) + * + * @param {number} fiscalYearId - the ID for the fiscal year + * @param {number} accountId - the ID for the account to update + * @param {number} budget - total budget for this account for the fiscal year + * @returns {Promise} of result status + */ +async function rebalanceBudget(fiscalYearId, accountId, budget) { + // Get the total budget for unlocked budget periods for this account + const sql = ` + SELECT SUM(b.budget) AS lockedTotal, COUNT(b.locked) AS numUnlocked + FROM budget AS b + JOIN period AS p ON p.id = b.period_id + WHERE p.fiscal_year_id = ? AND p.number > 0 AND b.account_id = ? AND b.locked = 0 + `; + const { lockedTotal, numUnlocked } = await db.one(sql, [fiscalYearId, accountId]); + const monthly = (budget - lockedTotal) / numUnlocked; + + // Set all the unlocked budget values for this account for the fiscal year + const setSql = ` + UPDATE budget AS b + JOIN period AS p ON p.id = b.period_id + SET b.budget = ? + WHERE p.fiscal_year_id = ? AND p.number > 0 AND b.account_id = ? AND b.locked = 0 + `; + return db.exec(setSql, [monthly, fiscalYearId, accountId]); +} + +/** + * Check if data has a valid format for inventories + * + * @param {Array} data - array of objects to check for valid properties + * @returns {boolean} - true if data is valid + */ +function hasValidHeaders(data) { + const [headers] = data; + return 'AcctNum' in headers + && 'Label' in headers + && 'Type' in headers + && 'Budget' in headers; +} + +/** + * Check if import data has a valid format + * + * @param {Array} data - array of objects to check for valid properties + * @returns {boolean} - true if data is valid + */ +async function hasValidData(data) { + + // eslint-disable-next-line no-restricted-syntax + for (let i = 0; i < data.length; i++) { + const line = data[i]; + + // Make sure the account number is a valid integer number + try { + const acctNum = Number(line.AcctNum); + if (!_.isInteger(acctNum)) { + return ['BUDGET.IMPORT_BUDGET_ERROR_ACCT_NUM', i]; + } + } catch { + return ['BUDGET.IMPORT_BUDGET_ERROR_ACCT_NUM', i]; + } + + // Make sure budget data is valid floating point number + // NOTE: We ignore budget values on title accounts + if (line.Type !== 'title') { + const budget = Number(line.Budget); + if (Number.isNaN(budget)) { + return ['BUDGET.IMPORT_BUDGET_ERROR_BAD_BUDGET_VALUE', i]; + } + if (budget < 0) { + return ['BUDGET.IMPORT_BUDGET_ERROR_NEGATIVE_BUDGET_VALUE', i]; + } + } + + // Make sure the account type is valid + if (!legalAccountTypes.includes(line.Type)) { + return ['BUDGET.IMPORT_BUDGET_ERROR_ACCT_TYPE', i]; + } + + // Make sure the account type matches the actual account info + const acctSql = 'SELECT at.type FROM account AS a JOIN account_type AS at ON at.id = a.type_id WHERE a.number = ?'; + if (i > 0) { + // skip the headers line + const account = await db.one(acctSql, [line.AcctNum]); // eslint-disable-line no-await-in-loop + if (line.Type !== account.type) { + return ['BUDGET.IMPORT_BUDGET_ERROR_ACCT_TYPE_INCORRECT', i]; + } + } + + } + + return true; +} diff --git a/server/controllers/finance/fiscal.js b/server/controllers/finance/fiscal.js index b9fcfe203b..a1037fc472 100644 --- a/server/controllers/finance/fiscal.js +++ b/server/controllers/finance/fiscal.js @@ -36,6 +36,7 @@ exports.detail = detail; exports.update = update; exports.remove = remove; exports.getPeriods = getPeriods; +exports.getPeriodZero = getPeriodZero; exports.lookupFiscalYear = lookupFiscalYear; @@ -737,6 +738,20 @@ function getPeriods(req, res, next) { .done(); } +/** + * Get the "zero" period for the fiscal year (where period.number = 0) + */ +function getPeriodZero(req, res, next) { + const fiscalYearId = req.params.id; + const sql = 'SELECT id FROM period WHERE period.number = 0 AND period.fiscal_year_id = ?'; + return db.one(sql, [fiscalYearId]) + .then(resPeriodZero => { + res.status(200).json(resPeriodZero); + }) + .catch(next) + .done(); +} + /** * return a query for retrieving account'balance by type_id and periods * In general in accounting the balance is obtained by making debit - credit diff --git a/server/controllers/finance/reports/budget/budget.handlebars b/server/controllers/finance/reports/budget/budget.handlebars new file mode 100644 index 0000000000..2dbc1aa99e --- /dev/null +++ b/server/controllers/finance/reports/budget/budget.handlebars @@ -0,0 +1,110 @@ +{{> head}} + + + + {{#> header}} +

+ {{fiscalYear.label}}: + {{date fiscalYear.start_date "MMMM YYYY"}} - {{date fiscalYear.end_date "MMMM YYYY"}} +

+ {{/header}} + + +
+
+ + + + + + + + + + + + + + + + + {{#each incomeRows}} + + + + + {{#if isTitle}} + + {{else}} + + + + + + {{/if}} + + {{/each}} + + {{#with incomeSummaryRow}} + + + + + + + + + + + {{/with}} + + + {{#each expenseRows}} + + + + + {{#if isTitle}} + + {{else}} + + + + + + {{/if}} + + {{/each}} + + + {{#with expensesSummaryRow}} + + + + + + + + + {{/with}} + + + + + + {{#with totalSummaryRow}} + + + + + + + + + {{/with}} + + +
{{translate 'TABLE.COLUMNS.ACCOUNT'}}{{translate 'TABLE.COLUMNS.LABEL'}}{{translate 'TABLE.COLUMNS.TYPE'}}{{translate 'BUDGET.FY_BUDGET'}}{{translate 'BUDGET.BUDGET_YTD'}}{{translate 'BUDGET.FY_ACTUALS'}}{{translate 'BUDGET.DIFFERENCE_YTD'}}{{translate 'BUDGET.DEVIATION_YTD'}}
{{number}}{{translate label}}{{translate type}}{{#if budget}}{{currency budget ../currencyId}}{{/if}}{{#if budgetYTD}}{{currency budgetYTD ../currencyId}}{{/if}}{{#if actuals}}{{currency actuals ../currencyId}}{{/if}}{{#if differenceYTD}}{{currency differenceYTD ../currencyId}}{{/if}}{{#if deviationYTDPct}}{{deviationYTDPct}} %{{/if}}
{{number}}{{translate label}}{{translate type}}{{currency budget ../currencyId}}{{currency budgetYTD ../currencyId}}{{currency actuals ../currencyId}}{{currency differenceYTD ../currencyId}}{{#if deviationYTDPct}}{{deviationYTDPct}} %{{/if}}
 
{{number}}{{translate label}}{{translate type}}{{#if budget}}{{currency budget ../currencyId}}{{/if}}{{#if budgetYTD}}{{currency budgetYTD ../currencyId}}{{/if}}{{#if actuals}}{{currency actuals ../currencyId}}{{/if}}{{#if differenceYTD}}{{currency differenceYTD ../currencyId}}{{/if}}{{#if deviationYTDPct}}{{deviationYTDPct}} %{{/if}}
{{number}}{{translate label}}{{translate type}}{{currency budget ../currencyId}}{{currency budgetYTD ../currencyId}}{{currency actuals ../currencyId}}{{currency differenceYTD ../currencyId}}{{#if deviationYTDPct}}{{deviationYTDPct}} %{{/if}}
 
{{number}}{{translate label}}{{translate type}}{{currency budget ../currencyId}}{{currency budgetYTD ../currencyId}}{{currency actuals ../currencyId}}{{currency differenceYTD ../currencyId}}{{#if deviationYTDPct}}{{deviationYTDPct}} %{{/if}}
+
+
+ diff --git a/server/controllers/finance/reports/budget/index.js b/server/controllers/finance/reports/budget/index.js new file mode 100644 index 0000000000..80e5c3c410 --- /dev/null +++ b/server/controllers/finance/reports/budget/index.js @@ -0,0 +1,129 @@ +const _ = require('lodash'); +const moment = require('moment'); + +const Budget = require('../../budget'); +const Fiscal = require('../../fiscal'); +const constants = require('../../../../config/constants'); + +const ReportManager = require('../../../../lib/ReportManager'); +const { formatFilters } = require('../shared'); + +const { TITLE, EXPENSE, INCOME } = constants.accounts; + +const BUDGET_REPORT_TEMPLATE = './server/controllers/finance/reports/budget/budget.handlebars'; + +function typeName(id) { + let name = null; + switch (id) { + case TITLE: + name = 'title'; + break; + case EXPENSE: + name = 'expense'; + break; + case INCOME: + name = 'income'; + break; + default: + } + return name; +} + +async function getReport(req, res, next) { + const params = req.query; + const { renderer } = params; + const fiscalYearId = params.fiscal_year_id; + + const optionReport = _.extend(params, { + csvKey : 'rows', + renameKeys : false, + orientation : 'landscape', + }); + + try { + let data; + + const fiscalYear = await Fiscal.lookupFiscalYear(fiscalYearId); + + const report = new ReportManager(BUDGET_REPORT_TEMPLATE, req.session, optionReport); + + const rows = await Budget.buildBudgetData(fiscalYearId); + + if (renderer === 'pdf') { + rows.forEach(row => { + row.isTitle = row.type_id === TITLE; + row.isIncome = row.type_id === INCOME; + row.isExpense = row.type_id === EXPENSE; + }); + + // Split the income and expense related rows + const incomeRows = []; + const expenseRows = []; + let income = true; + rows.forEach(row => { + if (row.acctType === 'total-income') { + income = false; + } + if ((row.label === '') + || (row.acctType === 'total-income') + || (row.acctType === 'total-expenses') + || (row.acctType === 'total-summary')) { + // skip blank row and summary rows + } else if (income) { + incomeRows.push(row); + } else { + expenseRows.push(row); + } + }); + + const incomeSummaryRow = rows.find(elt => elt.acctType === 'total-income'); + const expensesSummaryRow = rows.find(elt => elt.acctType === 'total-expenses'); + const totalSummaryRow = rows.find(acct => acct.acctType === 'total-summary'); + + data = { + incomeRows, + expenseRows, + incomeSummaryRow, + expensesSummaryRow, + totalSummaryRow, + fiscalYear, + title : 'BUDGET.EXPORT.REPORT_TITLE', + dateTo : moment().format('YYYY-MM-DD'), + filters : formatFilters(params), + currencyId : Number(req.session.enterprise.currency_id), + }; + + } else { + // For CSV and Excel, construct a simplied array of data with correct column header names + const csvData = []; + rows.forEach(row => { + if ((row.label === '') + || (row.acctType === 'total-income') + || (row.acctType === 'total-expenses') + || (row.acctType === 'total-summary')) { + // skip blank row and summary rows + } else { + csvData.push({ + AcctNum : row.number, + Label : row.label, + Type : typeName(row.type_id), + Budget : row.budget || 0, + Actuals : row.actuals || 0, + }); + } + + }); + + data = { + rows : csvData, + }; + } + + const result = await report.render(data); + res.set(result.headers).send(result.report); + } catch (e) { + next(e); + } +} + +exports.getReport = getReport; diff --git a/server/models/bhima.sql b/server/models/bhima.sql index 5b68eadbd5..a9fc1e4936 100644 --- a/server/models/bhima.sql +++ b/server/models/bhima.sql @@ -177,7 +177,8 @@ INSERT INTO unit VALUES (315, 'Needed Inventory Scans', 'TREE.INVENTORY_SCANS_NEEDED', 'report for needed inventory scans', 314, '/reports/needed_inventory_scans'), (316, 'Detailed record of purchases','TREE.PURCHASE_REGISTRY_DETAILED','The purchase registry detailed',154,'/purchases/detailed'), (317, 'Satisfaction Rate Report','TREE.SATISFACTION_RATE_REPORT','Satisfaction Rate Report',282,'/reports/satisfaction_rate_report'), - (318, 'Job Titles Management','TREE.TITLE','',57, '/titles'); + (318, 'Job Titles Management','TREE.TITLE','',57, '/titles'), + (319, 'Budget Management', 'TREE.BUDGET', '', 5, '/budget'); -- Reserved system account type INSERT IGNORE INTO `account_category` VALUES diff --git a/server/models/migrations/next/migrate.sql b/server/models/migrations/next/migrate.sql index be9887c300..a211da3aec 100644 --- a/server/models/migrations/next/migrate.sql +++ b/server/models/migrations/next/migrate.sql @@ -1 +1,17 @@ -/* v1.29.0 */ +/* v1.29.0 */ + +/** + * @author: jmcameron + * @description: Updates for budget module + * @date: 2023-11-02 + */ + +-- Update the budget table +ALTER TABLE `budget` MODIFY COLUMN `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT; +ALTER TABLE `budget` MODIFY COLUMN `budget` DECIMAL(19,4) UNSIGNED NOT NULL DEFAULT 0; +CALL add_column_if_missing('budget', 'locked', 'BOOLEAN NOT NULL DEFAULT 0'); +CALL add_column_if_missing('budget', 'updated_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + +-- Add the Finance > Budget page +INSERT INTO unit VALUES + (319, 'Budget Management', 'TREE.BUDGET', '', 5, '/budget'); diff --git a/server/models/procedures.sql b/server/models/procedures.sql index b164e99fcb..ec8600d0c0 100644 --- a/server/models/procedures.sql +++ b/server/models/procedures.sql @@ -4221,4 +4221,54 @@ BEGIN END$$ +/* ---------------------------------------------------------------------------- */ + +/* This section contains procedures for dealing with budgets */ + +-- Delete all budget items for each period of the given fiscal year +DROP PROCEDURE IF EXISTS DeleteBudget$$ +CREATE PROCEDURE DeleteBudget( + IN fiscalYearId MEDIUMINT(8) UNSIGNED +) +BEGIN + DECLARE _periodId mediumint(8) unsigned; + + DECLARE done BOOLEAN; + DECLARE periodCursor CURSOR FOR + SELECT id FROM period + WHERE period.fiscal_year_id = fiscalYearId; + + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; + + OPEN periodCursor; + ploop: LOOP + FETCH periodCursor INTO _periodId; + IF done THEN + LEAVE ploop; + END IF; + DELETE FROM budget WHERE budget.period_id = _periodId; + END LOOP; + CLOSE periodCursor; +END$$ + +-- Insert a budget line for a given period +-- NOTE: This procedure will error if the record already exists +DROP PROCEDURE IF EXISTS InsertBudgetItem$$ +CREATE PROCEDURE InsertBudgetItem( + IN acctNumber INT UNSIGNED, + IN periodId MEDIUMINT(8) UNSIGNED, + IN budget DECIMAL(19,4) UNSIGNED, + IN locked BOOLEAN +) +BEGIN + INSERT INTO budget (`account_id`, `period_id`, `budget`, `locked`) + SELECT act.id, periodId, budget, locked + FROM account AS act + WHERE act.number = acctNumber; +END$$ + +-- EXAMPLE ABORT CODE +-- DECLARE MyError CONDITION FOR SQLSTATE '45500'; +-- SIGNAL MyError SET MESSAGE_TEXT = 'message'; + DELIMITER ; diff --git a/server/models/schema.sql b/server/models/schema.sql index 19d418b7f5..3a2dd2935e 100644 --- a/server/models/schema.sql +++ b/server/models/schema.sql @@ -110,10 +110,12 @@ CREATE TABLE invoicing_fee ( DROP TABLE IF EXISTS `budget`; CREATE TABLE `budget` ( - `id` INT(11) NOT NULL AUTO_INCREMENT, + `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, `account_id` INT UNSIGNED NOT NULL, - `period_id` MEDIUMINT(8) UNSIGNED NOT NULL, - `budget` DECIMAL(10,4) UNSIGNED DEFAULT NULL, + `period_id` MEDIUMINT(8) UNSIGNED NOT NULL, -- FY period.number=0 => FY budget total + `budget` DECIMAL(19,4) UNSIGNED NOT NULL DEFAULT 0, + `locked` BOOLEAN NOT NULL DEFAULT 0, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY `account_id` (`account_id`), KEY `period_id` (`period_id`), CONSTRAINT `budget__account` FOREIGN KEY (`account_id`) REFERENCES `account` (`id`), @@ -433,7 +435,6 @@ CREATE TABLE `debtor_group_history` ( DROP TABLE IF EXISTS debtor_group_subsidy; - CREATE TABLE debtor_group_subsidy ( `id` SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT, `debtor_group_uuid` BINARY(16) NOT NULL, @@ -1318,7 +1319,6 @@ CREATE TABLE `cron_email_report` ( ) ENGINE=InnoDB DEFAULT CHARACTER SET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; DROP TABLE IF EXISTS `posting_journal`; - CREATE TABLE `posting_journal` ( `uuid` BINARY(16) NOT NULL, `project_id` SMALLINT(5) UNSIGNED NOT NULL, @@ -1398,7 +1398,6 @@ CREATE TABLE `project_permission` ( DROP TABLE IF EXISTS `province`; - CREATE TABLE `province` ( `uuid` BINARY(16) NOT NULL, `name` VARCHAR(100) NOT NULL, @@ -1513,7 +1512,6 @@ CREATE TABLE `reference_group` ( DROP TABLE IF EXISTS `report`; - CREATE TABLE `report` ( `id` TINYINT(3) UNSIGNED NOT NULL AUTO_INCREMENT, `report_key` TEXT NOT NULL, @@ -1539,7 +1537,6 @@ CREATE TABLE `saved_report` ( ) ENGINE= InnoDB DEFAULT CHARACTER SET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; DROP TABLE IF EXISTS `invoice`; - CREATE TABLE `invoice` ( `project_id` SMALLINT(5) UNSIGNED NOT NULL, `reference` INT(10) UNSIGNED NOT NULL DEFAULT 0, diff --git a/server/resources/templates/import-budget-template.csv b/server/resources/templates/import-budget-template.csv new file mode 100644 index 0000000000..492f87d24b --- /dev/null +++ b/server/resources/templates/import-budget-template.csv @@ -0,0 +1,5 @@ +AcctNum, Label, Type, Budget, BudgetLastYear +70, VENTS, title, +70111010, Vente Médicaments en comprimes, income,30000 +60, ACHATS ET VARIATIONS DE STOCKS, title, +60111010, Achat Médicaments en comprimés, expense,50000 diff --git a/test/fixtures/budget-to-import-bad-headers.csv b/test/fixtures/budget-to-import-bad-headers.csv new file mode 100644 index 0000000000..f174cc7ae8 --- /dev/null +++ b/test/fixtures/budget-to-import-bad-headers.csv @@ -0,0 +1,26 @@ +AcctNum, Label, BadType, Budget +70, VENTS, title, +70111010, Vente Médicaments en comprimes, income,30000 +70111011, Vente Médicaments en Sirop, income,10000 +70112010, Vente d'actifs, income,20000 +70611010, Consultations, income,15000 +70611011, Optique, income,10000 +70611012, Hospitalisation, income,6000 +70611036, URGENCES, income,10000 +75, AUTRES PRODUITS, title, +75811011, Autres remunerations d administrateurs, income,2000 +75821010, Indemnites d'assurances recues, income,5000 +75881010, Autres revenus, income,7000 +77, REVENUS FINANCIERS ET PRODUITS ASSIMILÉS, title, +77111010, Interets de Prets, income,10000 +60, ACHATS ET VARIATIONS DE STOCKS, title, +60111010, Achat Médicaments en comprimés, expense,50000 +60111011, Achat Médicaments en Sirop, expense,20000 +60111014, Achat Injectables, expense,15000 +60111015, Achat Produit de Perfusion, expense,5000 +60111016, Achat Produits Ophtamologiques, expense,10000 +60112010, Achat d'actifs, expense,30000 +61, TRANSPORTS, title, +61411010, Transport personnel, expense,5000 +66, CHARGES DE PERSONNEL, title, +66131010, Indemnité de congé, expense,20000 diff --git a/test/fixtures/budget-to-import-bad-line-account-type-incorrect.csv b/test/fixtures/budget-to-import-bad-line-account-type-incorrect.csv new file mode 100644 index 0000000000..c4ae513b34 --- /dev/null +++ b/test/fixtures/budget-to-import-bad-line-account-type-incorrect.csv @@ -0,0 +1,26 @@ +AcctNum, Label, Type, Budget +70, VENTS, title, +70111010, Vente Médicaments en comprimes, income,30000 +70111011, Vente Médicaments en Sirop, income,10000 +70112010, Vente d'actifs, income,20000 +70611010, Consultations, income,15000 +70611011, Optique, income,10000 +70611012, Hospitalisation, income,6000 +70611036, URGENCES, income,10000 +75, AUTRES PRODUITS, title, +75811011, Autres remunerations d administrateurs, income,2000 +75821010, Indemnites d'assurances recues, income,5000 +75881010, Autres revenus, income,7000 +77, REVENUS FINANCIERS ET PRODUITS ASSIMILÉS, title, +77111010, Interets de Prets, income,10000 +60, ACHATS ET VARIATIONS DE STOCKS, title, +60111010, Achat Médicaments en comprimés, income,50000 +60111011, Achat Médicaments en Sirop, expense,20000 +60111014, Achat Injectables, expense,15000 +60111015, Achat Produit de Perfusion, expense,5000 +60111016, Achat Produits Ophtamologiques, expense,10000 +60112010, Achat d'actifs, expense,30000 +61, TRANSPORTS, title, +61411010, Transport personnel, expense,5000 +66, CHARGES DE PERSONNEL, title, +66131010, Indemnité de congé, expense,20000 diff --git a/test/fixtures/budget-to-import-bad-line-account-type.csv b/test/fixtures/budget-to-import-bad-line-account-type.csv new file mode 100644 index 0000000000..84830f894e --- /dev/null +++ b/test/fixtures/budget-to-import-bad-line-account-type.csv @@ -0,0 +1,26 @@ +AcctNum, Label, Type, Budget +70, VENTS, title, +70111010, Vente Médicaments en comprimes, income,30000 +70111011, Vente Médicaments en Sirop, income,10000 +70112010, Vente d'actifs, income,20000 +70611010, Consultations, income,15000 +70611011, Optique, income,10000 +70611012, Hospitalisation, income,6000 +70611036, URGENCES, income,10000 +75, AUTRES PRODUITS, title, +75811011, Autres remunerations d administrateurs, income,2000 +75821010, Indemnites d'assurances recues, income,5000 +75881010, Autres revenus, BadType,7000 +77, REVENUS FINANCIERS ET PRODUITS ASSIMILÉS, title, +77111010, Interets de Prets, income,10000 +60, ACHATS ET VARIATIONS DE STOCKS, title, +60111010, Achat Médicaments en comprimés, expense,50000 +60111011, Achat Médicaments en Sirop, expense,20000 +60111014, Achat Injectables, expense,15000 +60111015, Achat Produit de Perfusion, expense,5000 +60111016, Achat Produits Ophtamologiques, expense,10000 +60112010, Achat d'actifs, expense,30000 +61, TRANSPORTS, title, +61411010, Transport personnel, expense,5000 +66, CHARGES DE PERSONNEL, title, +66131010, Indemnité de congé, expense,20000 diff --git a/test/fixtures/budget-to-import-bad-line-account.csv b/test/fixtures/budget-to-import-bad-line-account.csv new file mode 100644 index 0000000000..08c52517a9 --- /dev/null +++ b/test/fixtures/budget-to-import-bad-line-account.csv @@ -0,0 +1,27 @@ +AcctNum, Label, Type, Budget +70, VENTS, title, +70111010, Vente Médicaments en comprimes, income,30000 +70111011, Vente Médicaments en Sirop, income,10000 +70112010, Vente d'actifs, income,20000 +70611010, Consultations, income,15000 +70611011, Optique, income,10000 +BADNUM, Hospitalisation, income,6000 +70611036, URGENCES, income,10000 +75, AUTRES PRODUITS, title, +75811011, Autres remunerations d administrateurs, income,2000 +75821010, Indemnites d'assurances recues, income,5000 +75881010, Autres revenus, income,7000 +77, REVENUS FINANCIERS ET PRODUITS ASSIMILÉS, title, +77111010, Interets de Prets, income,10000 +60, ACHATS ET VARIATIONS DE STOCKS, title, +60111010, Achat Médicaments en comprimés, expense,50000 +60111011, Achat Médicaments en Sirop, expense,20000 +60111014, Achat Injectables, expense,15000 +60111015, Achat Produit de Perfusion, expense,5000 +60111016, Achat Produits Ophtamologiques, expense,10000 +60112010, Achat d'actifs, expense,30000 +61, TRANSPORTS, title, +61411010, Transport personnel, expense,5000 +66, CHARGES DE PERSONNEL, title, +66131010, Indemnité de congé, expense,20000 + diff --git a/test/fixtures/budget-to-import-bad-line-budget.csv b/test/fixtures/budget-to-import-bad-line-budget.csv new file mode 100644 index 0000000000..f049127584 --- /dev/null +++ b/test/fixtures/budget-to-import-bad-line-budget.csv @@ -0,0 +1,26 @@ +AcctNum, Label, Type, Budget +70, VENTS, title, +70111010, Vente Médicaments en comprimes, income,30000 +70111011, Vente Médicaments en Sirop, income,10000 +70112010, Vente d'actifs, income,20000 +70611010, Consultations, income,15000 +70611011, Optique, income,10000 +70611012, Hospitalisation, income,6000 +70611036, URGENCES, income,10000 +75, AUTRES PRODUITS, title, +75811011, Autres remunerations d administrateurs, income,2000 +75821010, Indemnites d'assurances recues, income,5000 +75881010, Autres revenus, income,fruit +77, REVENUS FINANCIERS ET PRODUITS ASSIMILÉS, title, +77111010, Interets de Prets, income,10000 +60, ACHATS ET VARIATIONS DE STOCKS, title, +60111010, Achat Médicaments en comprimés, expense,50000 +60111011, Achat Médicaments en Sirop, expense,20000 +60111014, Achat Injectables, expense,15000 +60111015, Achat Produit de Perfusion, expense,5000 +60111016, Achat Produits Ophtamologiques, expense,10000 +60112010, Achat d'actifs, expense,30000 +61, TRANSPORTS, title, +61411010, Transport personnel, expense,5000 +66, CHARGES DE PERSONNEL, title, +66131010, Indemnité de congé, expense,20000 diff --git a/test/fixtures/budget-to-import-bad-line-negative-budget.csv b/test/fixtures/budget-to-import-bad-line-negative-budget.csv new file mode 100644 index 0000000000..4c92c6c609 --- /dev/null +++ b/test/fixtures/budget-to-import-bad-line-negative-budget.csv @@ -0,0 +1,27 @@ +AcctNum, Label, Type, Budget +70, VENTS, title, +70111010, Vente Médicaments en comprimes, income,30000 +70111011, Vente Médicaments en Sirop, income,10000 +70112010, Vente d'actifs, income,20000 +70611010, Consultations, income,15000 +70611011, Optique, income,10000 +70611012, Hospitalisation, income,6000 +70611036, URGENCES, income,10000 +75, AUTRES PRODUITS, title, +75811011, Autres remunerations d administrateurs, income,2000 +75821010, Indemnites d'assurances recues, income,5000 +75881010, Autres revenus, income,7000 +77, REVENUS FINANCIERS ET PRODUITS ASSIMILÉS, title, +77111010, Interets de Prets, income,10000 +60, ACHATS ET VARIATIONS DE STOCKS, title, +60111010, Achat Médicaments en comprimés, expense,50000 +60111011, Achat Médicaments en Sirop, expense,-20000 +60111014, Achat Injectables, expense,15000 +60111015, Achat Produit de Perfusion, expense,5000 +60111016, Achat Produits Ophtamologiques, expense,10000 +60112010, Achat d'actifs, expense,30000 +61, TRANSPORTS, title, +61411010, Transport personnel, expense,5000 +66, CHARGES DE PERSONNEL, title, +66131010, Indemnité de congé, expense,20000 + diff --git a/test/fixtures/budget-to-import.csv b/test/fixtures/budget-to-import.csv new file mode 100644 index 0000000000..c87f24e20a --- /dev/null +++ b/test/fixtures/budget-to-import.csv @@ -0,0 +1,26 @@ +AcctNum, Label, Type, Budget +70, VENTS, title, +70111010, Vente Médicaments en comprimes, income, 30000 +70111011, Vente Médicaments en Sirop, income, 10000 +70112010, Vente d'actifs, income, 20000 +70611010, Consultations, income, 15000 +70611011, Optique, income, 10000 +70611012, Hospitalisation, income, 6000 +70611036, URGENCES, income, 10000 +75, AUTRES PRODUITS, title, +75811011, Autres remunerations d administrateurs, income, 2000 +75821010, Indemnites d'assurances recues, income, 5000 +75881010, Autres revenus, income, 7000 +77, REVENUS FINANCIERS ET PRODUITS ASSIMILÉS, title, +77111010, Interets de Prets, income, 10000 +60, ACHATS ET VARIATIONS DE STOCKS, title, +60111010, Achat Médicaments en comprimés, expense, 50000 +60111011, Achat Médicaments en Sirop, expense, 20000 +60111014, Achat Injectables, expense, 15000 +60111015, Achat Produit de Perfusion, expense, 5000 +60111016, Achat Produits Ophtamologiques, expense, 10000 +60112010, Achat d'actifs, expense, 30000 +61, TRANSPORTS, title, +61411010, Transport personnel, expense, 5000 +66, CHARGES DE PERSONNEL, title, +66131010, Indemnité de congé, expense, 20000 diff --git a/test/integration/accountFYBalances.js b/test/integration/accountFYBalances.js new file mode 100644 index 0000000000..8aac3d3eb4 --- /dev/null +++ b/test/integration/accountFYBalances.js @@ -0,0 +1,33 @@ +/* global expect, agent */ + +describe('Test getting all accounts balances for an FY http API', () => { + + const fiscalYearId = 4; // 2018 + + it('GET /accounts/:fiscalYearId/all_balances for FY 2018', () => { + return agent.get(`/accounts/${fiscalYearId}/all_balances`) + .then(results => { + const data = results.body; + expect(data.length).to.equal(7); + + const acct220 = data[4]; + expect(acct220.account_id).to.equal(220); + expect(acct220.number).to.equal(66110011); + expect(acct220.label).to.equal('Remunération Personnel'); + expect(acct220.type).to.equal('expense'); + expect(Number(acct220.credit)).to.equal(0); + expect(Number(acct220.debit)).to.equal(256.62); + expect(Number(acct220.balance)).to.equal(256.62); + + const acct243 = data[5]; + expect(acct243.account_id).to.equal(243); + expect(acct243.number).to.equal(70111011); + expect(acct243.label).to.equal('Vente Médicaments en Sirop'); + expect(acct243.type).to.equal('income'); + expect(Number(acct243.debit)).to.equal(0); + expect(Number(acct243.credit)).to.equal(394.8); + expect(Number(acct243.balance)).to.equal(-394.8); + }); + }); + +}); diff --git a/test/integration/budget/budget.js b/test/integration/budget/budget.js new file mode 100644 index 0000000000..c4c6030b70 --- /dev/null +++ b/test/integration/budget/budget.js @@ -0,0 +1,199 @@ +/* global expect, agent */ + +const helpers = require('../helpers'); + +describe('(/budget) basic budget operations http API', () => { + + const accountTotal = 100000; + const accountNum = 70611012; // Hospitalisation (income) + const period1Budget = 20000; + const newPeriod1Budget = 17000; + let accountId; + let fiscalYearId; + let periods; + let budgetId1; + + it('Get the latest fiscal year', () => { + return agent.get('/fiscal') + .then(res => { + // The /fiscal query sorts by start date (DESC) by default, + // so the first entry is always that last defined fiscal year + const [year] = JSON.parse(res.text); + fiscalYearId = year.id; + expect(Number.isInteger(fiscalYearId)); + }); + }); + + it('Get test account ID', () => { + return agent.get('/accounts') + .query({ number : accountNum }) + .then(res => { + expect(res).to.have.status(200); + accountId = res.body[0].id; + expect(Number.isInteger(accountId)); + }); + }); + + it('Delete the budget data for this fiscal year', () => { + return agent.delete(`/budget/${fiscalYearId}`) + .then(res => { + expect(res).to.have.status(200); + }) + .catch(helpers.handler); + }); + + it('Verify deletion of budget items for the fiscal year', () => { + return agent.get('/budget') + .query({ fiscal_year_id : fiscalYearId }) + .then(res => { + expect(res).to.have.status(200); + expect(res.body).to.have.length(0); + }) + .catch(helpers.handler); + }); + + it('Add some budget data', () => { + // First, get the period IDs for this fiscal year + return agent.get('/periods') + .query({ fiscal_year_id : fiscalYearId }) + .then(res1 => { + expect(res1).to.have.status(200); + periods = res1.body; + expect(periods[0].number, 'The first period has number zero').to.be.equal(0); + + // Add the total for the year (in period 0) + return agent.post('/budget') + .query({ + acctNumber : accountNum, + periodId : periods[0].id, + budget : accountTotal, + locked : 1, + }); + }) + .then(res2 => { + expect(res2).to.have.status(200); + + // Now add some data for this budget item for period 1 + return agent.post('/budget') + .query({ + acctNumber : accountNum, + periodId : periods[1].id, + budget : period1Budget, + locked : 0, + }); + }) + .then(res3 => { + expect(res3).to.have.status(200); + }) + .catch(helpers.handler); + }); + + it('Verify the just-added test budget data', () => { + return agent.get('/budget') + .query({ fiscal_year_id : fiscalYearId }) + .then(res => { + expect(res).to.have.status(200); + + // Verify that we have loaded all the data + expect(res.body).to.have.length(2); + const data = res.body; + budgetId1 = data[1].budgetId; + + // Check period 0 + expect(data[0].acctNum).to.be.equal(accountNum); + expect(data[0].periodNum).to.be.equal(0); + expect(data[0].budget).to.be.equal(accountTotal); + expect(data[0].locked).to.be.equal(1); + + // Check period 1 + expect(data[1].acctNum).to.be.equal(accountNum); + expect(data[1].periodNum).to.be.equal(1); + expect(data[1].budget).to.be.equal(period1Budget); + expect(data[1].locked).to.be.equal(0); + }) + .catch(helpers.handler); + }); + + it('Try to update the budget data for period 1', () => { + // First, change only the budget + return agent.put(`/budget/update/${budgetId1}`) + .query({ budget : newPeriod1Budget }) + .then(res => { + expect(res).to.have.status(200); + return agent.get('/budget').query({ fiscal_year_id : fiscalYearId }); + }) + .then(res2 => { + // Verify that we have only changed the budget + expect(res2).to.have.status(200); + const data = res2.body; + + expect(data[0].acctNum).to.be.equal(accountNum); + expect(data[0].periodNum).to.be.equal(0); + expect(data[0].budget).to.be.equal(accountTotal); + expect(data[0].locked).to.be.equal(1); + + expect(data[1].acctNum).to.be.equal(accountNum); + expect(data[1].periodNum).to.be.equal(1); + expect(data[1].budget).to.be.equal(newPeriod1Budget); + expect(data[1].locked).to.be.equal(0); + }) + .catch(helpers.handler); + }); + + it('Try to update the budget lock for period 1', () => { + // First, change only the budget + return agent.put(`/budget/update/${budgetId1}`) + .query({ locked : 1 }) + .then(res => { + expect(res).to.have.status(200); + return agent.get('/budget').query({ fiscal_year_id : fiscalYearId }); + }) + .then(res2 => { + // Verify that we have only changed the budget + expect(res2).to.have.status(200); + const data = res2.body; + + expect(data[0].acctNum).to.be.equal(accountNum); + expect(data[0].periodNum).to.be.equal(0); + expect(data[0].budget).to.be.equal(accountTotal); + expect(data[0].locked).to.be.equal(1); + + expect(data[1].acctNum).to.be.equal(accountNum); + expect(data[1].periodNum).to.be.equal(1); + expect(data[1].budget).to.be.equal(newPeriod1Budget); + expect(data[1].locked).to.be.equal(1); + }) + .catch(helpers.handler); + }); + + it('Try to update the budget amount and lock for period 1', () => { + // First, change only the budget + return agent.put(`/budget/update/${budgetId1}`) + .query({ budget : period1Budget, locked : 0 }) + .then(res => { + expect(res).to.have.status(200); + return agent.get('/budget').query({ fiscal_year_id : fiscalYearId }); + }) + .then(res2 => { + // Verify that we have only changed the budget + expect(res2).to.have.status(200); + const data = res2.body; + + expect(data[0].acctNum).to.be.equal(accountNum); + expect(data[0].periodNum).to.be.equal(0); + expect(data[0].budget).to.be.equal(accountTotal); + expect(data[0].locked).to.be.equal(1); + + expect(data[1].acctNum).to.be.equal(accountNum); + expect(data[1].periodNum).to.be.equal(1); + expect(data[1].budget).to.be.equal(period1Budget); + expect(data[1].locked).to.be.equal(0); + }) + .catch(helpers.handler); + }); + + // it('ABORT', () => { + // expect(true).to.be.equal(false); + // }); + +}); diff --git a/test/integration/budget/import.js b/test/integration/budget/import.js new file mode 100644 index 0000000000..fa0f793896 --- /dev/null +++ b/test/integration/budget/import.js @@ -0,0 +1,223 @@ +/* global expect, agent */ + +const fs = require('fs'); +const helpers = require('../helpers'); + +describe('(/budget/import) The budget import http API', () => { + const file = './test/fixtures/budget-to-import.csv'; + const filename = 'budget-to-import.csv'; + + const invalidHeadersFile = './test/fixtures/budget-to-import-bad-headers.csv'; + const invalidAcctFile = './test/fixtures/budget-to-import-bad-line-account.csv'; + const invalidAcctTypeFile = './test/fixtures/budget-to-import-bad-line-account-type.csv'; + const invalidAcctTypeIncorrectFile = './test/fixtures/budget-to-import-bad-line-account-type-incorrect.csv'; + const invalidBudgetFile = './test/fixtures/budget-to-import-bad-line-budget.csv'; + const invalidNegativeBudgetFile = './test/fixtures/budget-to-import-bad-line-negative-budget.csv'; + + let fiscalYearId; + + /** + * Get the last defined fiscal year + */ + it('Get the latest fiscal year', () => { + return agent.get('/fiscal') + .then(res => { + // The /fiscal query sorts by start date (DESC) by default, + // so the first entry is always that last defined fiscal year + const [year] = JSON.parse(res.text); + fiscalYearId = year.id; + expect(Number.isInteger(fiscalYearId)); + }); + }); + + /** + * test the /budget/import API for downloading + * the budget template file + */ + it('GET /budget/download_template_file downloads the budget template file', () => { + const templateCsvHeaders = 'AcctNum, Label, Type, Budget, BudgetLastYear'; + return agent.get('/budget/download_template_file') + .then(res => { + expect(res).to.have.status(200); + const header = res.text.split('\n', 1)[0]; + expect(String(header).trim()).to.be.equal(templateCsvHeaders); + }) + .catch(helpers.handler); + }); + + /** + * Just in case, delete any old budget data + */ + it('Delete any old budget data for this fiscal year', () => { + return agent.delete(`/budget/${fiscalYearId}`) + .then(res => { + expect(res).to.have.status(200); + }) + .catch(helpers.handler); + }); + + /** + * Verify that there is no budget data loaded + */ + it('GET /budget gets no budget data for the specified fiscal year', () => { + return agent.get('/budget') + .query({ fiscal_year_id : fiscalYearId }) + .then(res => { + expect(res).to.have.status(200); + expect(res.body).to.have.length(0); + }) + .catch(helpers.handler); + }); + + /** + * test the /budget/import API for importing a budget from a csv file + */ + it(`POST /budget/import upload and import a sample budget template file`, () => { + return agent.post(`/budget/import/${fiscalYearId}`) + .attach('file', fs.createReadStream(file), filename) + .then(res => { + expect(res).to.have.status(200); + }) + .catch(helpers.handler); + }); + + /** + * Verify that there is now budget data + */ + it('GET /budget gets budget all the FY data that was just loaded', () => { + return agent.get('/budget') + .query({ fiscal_year_id : fiscalYearId }) + .then(res => { + expect(res).to.have.status(200); + + // Verify the right number of entries were created + expect(res.body).to.have.length(19); + + // Do a spot-check + const testLine = res.body[1]; + expect(testLine.acctNum).to.be.equal(60111011); + expect(testLine.acctLabel).to.be.equal('Achat Médicaments en Sirop'); + expect(testLine.acctType).to.be.equal('expense'); + expect(testLine.budget).to.be.equal(20000); + }) + .catch(helpers.handler); + }); + + /** + * Create the rest of the budget entries + */ + it('POST /budget/populate/:fiscal_year creates the remaining budget items for the FY', () => { + return agent.post(`/budget/populate/${fiscalYearId}`) + .then(res1 => { + expect(res1).to.have.status(200); + + // Load the budget data to make sure all periods were added + return agent.get('/budget') + .query({ fiscal_year_id : fiscalYearId }); + }) + .then(res2 => { + expect(res2).to.have.status(200); + + // Verify that we have added all the necessary periods + expect(res2.body).to.have.length(19 * 13); + }) + .catch(helpers.handler); + }); + + /** + * Fill in the budget data for the fiscal year + */ + it('PUT /budget/fill/:fiscal_year fills the budget data for the year FY', () => { + return agent.put(`/budget/fill/${fiscalYearId}`) + .then(res1 => { + expect(res1).to.have.status(200); + + // Load the budget data to make sure all periods were added + return agent.get('/budget') + .query({ fiscal_year_id : fiscalYearId }); + }) + .then(res2 => { + expect(res2).to.have.status(200); + const data = res2.body; + + // Verify that all accounts add up properly (to the integer) + const accounts = Array.from(data.reduce((accts, item) => accts.add(item.id), new Set())).sort(); + accounts.forEach(acct => { + // Get the total budgeted for this account for the year + const basePeriod = data.find(item => (item.id === acct) && (item.periodNum === 0)); + const total = Math.round(basePeriod.budget); + + // Now sum up the total budget of the periods + const budgeted = Math.round(data.reduce( + (sum, item) => sum + ((item.id === acct && item.periodNum > 0) ? item.budget : 0), 0)); + + expect(budgeted, + `FY Budget of ${total} for account ${acct} did not match the totals for budget periods ${budgeted}`) + .to.equal(total); + }); + }) + .catch(helpers.handler); + }); + + // it('ABORT', () => { + // expect(true).to.be.equal(false); + // }); + + /** + * test uploads of a bad csv files + */ + it('POST /budget/import blocks an upload of a bad csv file for budget import (bad header)', () => { + return agent.post(`/budget/import/${fiscalYearId}`) + .attach('file', fs.createReadStream(invalidHeadersFile)) + .then(res => { + expect(res).to.have.status(400); + }) + .catch(helpers.handler); + }); + + it('POST /budget/import blocks an upload of a bad csv file for budget import (bad account number)', () => { + return agent.post(`/budget/import/${fiscalYearId}`) + .attach('file', fs.createReadStream(invalidAcctFile)) + .then(res => { + expect(res).to.have.status(400); + }) + .catch(helpers.handler); + }); + + it('POST /budget/import blocks an upload of a bad csv file for budget import (bad budget number)', () => { + return agent.post(`/budget/import/${fiscalYearId}`) + .attach('file', fs.createReadStream(invalidBudgetFile)) + .then(res => { + expect(res).to.have.status(400); + }) + .catch(helpers.handler); + }); + + it('POST /budget/import blocks an upload of a bad csv file for budget import (negative budget number)', () => { + return agent.post(`/budget/import/${fiscalYearId}`) + .attach('file', fs.createReadStream(invalidNegativeBudgetFile)) + .then(res => { + expect(res).to.have.status(400); + }) + .catch(helpers.handler); + }); + + it('POST /budget/import blocks an upload of a bad csv file for budget import (invalid account type)', () => { + return agent.post(`/budget/import/${fiscalYearId}`) + .attach('file', fs.createReadStream(invalidAcctTypeFile)) + .then(res => { + expect(res).to.have.status(400); + }) + .catch(helpers.handler); + }); + + it('POST /budget/import blocks an upload of a bad csv file for budget import (incorrect account type)', () => { + return agent.post(`/budget/import/${fiscalYearId}`) + .attach('file', fs.createReadStream(invalidAcctTypeIncorrectFile)) + .then(res => { + expect(res).to.have.status(400); + }) + .catch(helpers.handler); + }); + +}); diff --git a/test/integration/shipment/shipmentContainerTypes.js b/test/integration/shipment/shipmentContainerTypes.js index 2ce4439e7d..8636dd16a4 100644 --- a/test/integration/shipment/shipmentContainerTypes.js +++ b/test/integration/shipment/shipmentContainerTypes.js @@ -1,7 +1,6 @@ /* eslint no-unused-expressions:"off" */ /* global expect, agent */ -// const moment = require('moment'); const helpers = require('../helpers'); // the /shipment_container_types API endpoint diff --git a/yarn.lock b/yarn.lock index 131cfea3c6..8cdfd662d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -84,10 +84,10 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.53.0": - version "8.53.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.53.0.tgz#bea56f2ed2b5baea164348ff4d5a879f6f81f20d" - integrity sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w== +"@eslint/js@8.54.0": + version "8.54.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.54.0.tgz#4fab9a2ff7860082c304f750e94acd644cf984cf" + integrity sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ== "@humanwhocodes/config-array@^0.11.13": version "0.11.13" @@ -444,10 +444,10 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== -"@types/angular@^1.8.4", "@types/angular@^1.8.8": - version "1.8.8" - resolved "https://registry.yarnpkg.com/@types/angular/-/angular-1.8.8.tgz#9406aeb29447c6463f83fbdd25e1365c835cf00f" - integrity sha512-Uq1dujhVh/ZpplkgOmMZK2stKZKdhVJgBo9fcm/yRGQzB0p3pFHHPN1s9bMXtgznxqKuB/mt+8t8cNDgiPlbuQ== +"@types/angular@^1.8.4", "@types/angular@^1.8.9": + version "1.8.9" + resolved "https://registry.yarnpkg.com/@types/angular/-/angular-1.8.9.tgz#d1ae3e87a67f72342d55ff26e611df83df0fb176" + integrity sha512-Z0HukqZkx0fotsV3QO00yqU9NzcQI+tMcrum+8MvfB4ePqCawZctF/gz6QiuII+T1ax+LitNoPx/eICTgnF4sg== "@types/chai@4": version "4.3.5" @@ -2254,17 +2254,6 @@ css-declaration-sorter@^6.3.1: resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz#28beac7c20bad7f1775be3a7129d7eae409a3a71" integrity sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g== -css-select@^4.1.3: - version "4.3.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" - integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== - dependencies: - boolbase "^1.0.0" - css-what "^6.0.1" - domhandler "^4.3.1" - domutils "^2.8.0" - nth-check "^2.0.1" - css-select@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" @@ -2276,14 +2265,6 @@ css-select@^5.1.0: domutils "^3.0.1" nth-check "^2.0.1" -css-tree@^1.1.2, css-tree@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" - integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== - dependencies: - mdn-data "2.0.14" - source-map "^0.6.1" - css-tree@^2.2.1: version "2.3.1" resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" @@ -2300,7 +2281,7 @@ css-tree@~2.2.0: mdn-data "2.0.28" source-map-js "^1.0.1" -css-what@^6.0.1, css-what@^6.1.0: +css-what@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== @@ -2358,14 +2339,7 @@ cssnano@^6.0.0: cssnano-preset-default "^6.0.1" lilconfig "^2.1.0" -csso@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" - integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== - dependencies: - css-tree "^1.1.2" - -csso@^5.0.5: +csso@5.0.5, csso@^5.0.5: version "5.0.5" resolved "https://registry.yarnpkg.com/csso/-/csso-5.0.5.tgz#f9b7fe6cc6ac0b7d90781bb16d5e9874303e2ca6" integrity sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ== @@ -2724,15 +2698,6 @@ dom-serialize@^2.2.1: extend "^3.0.0" void-elements "^2.0.0" -dom-serializer@^1.0.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" - integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.0" - entities "^2.0.0" - dom-serializer@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" @@ -2742,18 +2707,11 @@ dom-serializer@^2.0.0: domhandler "^5.0.2" entities "^4.2.0" -domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: +domelementtype@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== -domhandler@^4.2.0, domhandler@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" - integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== - dependencies: - domelementtype "^2.2.0" - domhandler@^5.0.2, domhandler@^5.0.3: version "5.0.3" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" @@ -2761,16 +2719,7 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -domutils@^2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" - integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== - dependencies: - dom-serializer "^1.0.1" - domelementtype "^2.2.0" - domhandler "^4.2.0" - -domutils@^3.0.1: +domutils@^3.0.1, domutils@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA== @@ -2919,12 +2868,7 @@ ent@~2.2.0: resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d" integrity sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA== -entities@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" - integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== - -entities@^4.2.0, entities@^4.4.0: +entities@^4.2.0, entities@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== @@ -3295,15 +3239,15 @@ eslint@^6.1.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -eslint@^8.53.0: - version "8.53.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.53.0.tgz#14f2c8244298fcae1f46945459577413ba2697ce" - integrity sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag== +eslint@^8.54.0: + version "8.54.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.54.0.tgz#588e0dd4388af91a2e8fa37ea64924074c783537" + integrity sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.6.1" "@eslint/eslintrc" "^2.1.3" - "@eslint/js" "8.53.0" + "@eslint/js" "8.54.0" "@humanwhocodes/config-array" "^0.11.13" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" @@ -4716,15 +4660,15 @@ hosted-git-info@^4.0.0, hosted-git-info@^4.0.1: dependencies: lru-cache "^6.0.0" -htmlparser2@^8.0.1: - version "8.0.2" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" - integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== +htmlparser2@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.0.0.tgz#e431142b7eeb1d91672742dea48af8ac7140cddb" + integrity sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ== dependencies: domelementtype "^2.3.0" domhandler "^5.0.3" - domutils "^3.0.1" - entities "^4.4.0" + domutils "^3.1.0" + entities "^4.5.0" http-cache-semantics@^4.1.1: version "4.1.1" @@ -4882,16 +4826,16 @@ ini@^1.3.2, ini@^1.3.4, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== -inline-source@^8.0.2: - version "8.0.2" - resolved "https://registry.yarnpkg.com/inline-source/-/inline-source-8.0.2.tgz#9e5bcf055cb808145783ead5b23283f30ace17c6" - integrity sha512-D+ZstDNX2D6N43NiwGv1FB13St2/+hveOVB4vqanv69ZGueVPnO427AO/cYFvRjYzHWmvaC3ZSuZvEdTfx71Tw== +inline-source@^8.0.2, inline-source@^8.0.3: + version "8.0.3" + resolved "https://registry.yarnpkg.com/inline-source/-/inline-source-8.0.3.tgz#623891d26d9a4c273a4d1ad5b416a0df5f4bff8b" + integrity sha512-2k0V+qA8buiBSfchYGg6KgugU/YFboGRZyRWQS0AC1wPBIwehi63KVTKLuk7grnqtneafktIxMUn/nhCDCkW5Q== dependencies: csso "^5.0.5" - htmlparser2 "^8.0.1" - node-fetch "^3.2.10" - svgo "^2.8.0" - terser "^5.15.0" + htmlparser2 "^9.0.0" + node-fetch "^3.3.2" + svgo "^3.0.0" + terser "^5.24.0" inquirer@9.2.12: version "9.2.12" @@ -5539,10 +5483,10 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -jsbarcode@^3.11.5: - version "3.11.5" - resolved "https://registry.yarnpkg.com/jsbarcode/-/jsbarcode-3.11.5.tgz#390b3efd0271f35b9d68c7b8af6e972445969014" - integrity sha512-zv3KsH51zD00I/LrFzFSM6dst7rDn0vIMzaiZFL7qusTjPZiPtxg3zxetp0RR7obmjTw4f6NyGgbdkBCgZUIrA== +jsbarcode@^3.11.6: + version "3.11.6" + resolved "https://registry.yarnpkg.com/jsbarcode/-/jsbarcode-3.11.6.tgz#96e8fbc3395476e162982a6064b98a09b5ea02c0" + integrity sha512-G5TKGyKY1zJo0ZQKFM1IIMfy0nF2rs92BLlCz+cU4/TazIc4ZH+X1GYeDRt7TKjrYqmPfTjwTBkU/QnQlsYiuA== jsdoc-type-pratt-parser@~4.0.0: version "4.0.0" @@ -6174,11 +6118,6 @@ matchdep@^2.0.0: resolve "^1.4.0" stack-trace "0.0.10" -mdn-data@2.0.14: - version "2.0.14" - resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" - integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== - mdn-data@2.0.28: version "2.0.28" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba" @@ -6590,7 +6529,7 @@ node-domexception@^1.0.0: resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== -node-fetch@3.3.2, node-fetch@^3.2.10: +node-fetch@3.3.2, node-fetch@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b" integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== @@ -8663,11 +8602,6 @@ sqlstring@2.3.1: resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.1.tgz#475393ff9e91479aea62dcaf0ca3d14983a7fb40" integrity sha512-ooAzh/7dxIG5+uDik1z/Rd1vli0+38izZhGzSa34FwR7IbelPWCCKSNIl8jlL/F7ERvy8CB2jNeM1E9i9mXMAQ== -stable@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" - integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== - stack-trace@0.0.10, stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" @@ -9009,29 +8943,17 @@ sver-compat@^1.5.0: es6-iterator "^2.0.1" es6-symbol "^3.1.1" -svgo@^2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24" - integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg== - dependencies: - "@trysound/sax" "0.2.0" - commander "^7.2.0" - css-select "^4.1.3" - css-tree "^1.1.3" - csso "^4.2.0" - picocolors "^1.0.0" - stable "^0.1.8" - -svgo@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.0.2.tgz#5e99eeea42c68ee0dc46aa16da093838c262fe0a" - integrity sha512-Z706C1U2pb1+JGP48fbazf3KxHrWOsLme6Rv7imFBn5EnuanDW1GPaA/P1/dvObE670JDePC3mnj0k0B7P0jjQ== +svgo@^3.0.0, svgo@^3.0.2: + version "3.0.4" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.0.4.tgz#67b40a710743e358e8d19ec288de8f1e388afbb4" + integrity sha512-T+Xul3JwuJ6VGXKo/p2ndqx1ibxNKnLTvRc1ZTWKCfyKS/GgNjRZcYsK84fxTsy/izr91g/Rwx6fGnVgaFSI5g== dependencies: "@trysound/sax" "0.2.0" commander "^7.2.0" css-select "^5.1.0" css-tree "^2.2.1" - csso "^5.0.5" + css-what "^6.1.0" + csso "5.0.5" picocolors "^1.0.0" table@^5.2.3: @@ -9088,10 +9010,10 @@ ternary-stream@^3.0.0: merge-stream "^2.0.0" through2 "^3.0.1" -terser@^5.15.0: - version "5.19.2" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.19.2.tgz#bdb8017a9a4a8de4663a7983f45c506534f9234e" - integrity sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA== +terser@^5.24.0: + version "5.24.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.24.0.tgz#4ae50302977bca4831ccc7b4fef63a3c04228364" + integrity sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.8.2" @@ -9393,10 +9315,10 @@ typeface-open-sans@^1.1.13: resolved "https://registry.yarnpkg.com/typeface-open-sans/-/typeface-open-sans-1.1.13.tgz#32a09ebd7df59601e01ad81216f98ce641eeafd1" integrity sha512-lVGVHvYl7UJDFB9vN8r7NHw3sVm7Rjeow6b9AeABc/J+2mDaCkmcdVtw3QZnsJW39P+xm5zeggIj9gLHYGn9Iw== -typescript@^5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" - integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +typescript@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" + integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== ua-parser-js@^0.7.30: version "0.7.35"