From 06b5ac4f902eaf1b37e4e274c574094a6be42aa1 Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Mon, 16 May 2022 15:40:35 +0530 Subject: [PATCH] incr: do BalanceSheet (smthn wrng) --- fyo/utils/format.ts | 2 +- reports/AccountReport.ts | 617 ++++++++++++++++++++++ reports/BalanceSheet/BalanceSheet.ts | 147 ++++-- reports/BalanceSheet/viewConfig.js | 56 -- reports/GeneralLedger/GeneralLedger.ts | 1 + reports/ProfitAndLoss/ProfitAndLoss.ts | 694 ++----------------------- reports/index.js | 40 -- reports/index.ts | 3 +- reports/types.ts | 38 ++ src/utils/sidebarConfig.ts | 4 +- 10 files changed, 797 insertions(+), 805 deletions(-) create mode 100644 reports/AccountReport.ts delete mode 100644 reports/BalanceSheet/viewConfig.js delete mode 100644 reports/index.js diff --git a/fyo/utils/format.ts b/fyo/utils/format.ts index 2acc6b45..edf49ae4 100644 --- a/fyo/utils/format.ts +++ b/fyo/utils/format.ts @@ -91,7 +91,7 @@ function formatCurrency( function formatNumber(value: DocValue, fyo: Fyo): string { const numberFormatter = getNumberFormatter(fyo); if (typeof value === 'number') { - return numberFormatter.format(value); + value = fyo.pesa(value.toFixed(20)); } if ((value as Money).round) { diff --git a/reports/AccountReport.ts b/reports/AccountReport.ts new file mode 100644 index 00000000..0d8e2bc6 --- /dev/null +++ b/reports/AccountReport.ts @@ -0,0 +1,617 @@ +import { t } from 'fyo'; +import { Action } from 'fyo/model/types'; +import { cloneDeep } from 'lodash'; +import { DateTime } from 'luxon'; +import { AccountRootType } from 'models/baseModels/Account/types'; +import { isCredit } from 'models/helpers'; +import { ModelNameEnum } from 'models/types'; +import { LedgerReport } from 'reports/LedgerReport'; +import { + Account, + AccountList, + AccountListNode, + AccountNameValueMapMap, + AccountTree, + AccountTreeNode, + BasedOn, + ColumnField, + DateRange, + GroupedMap, + LedgerEntry, + Periodicity, + ReportCell, + ReportData, + ReportRow, + Tree, + TreeNode, + ValueMap, +} from 'reports/types'; +import { Field } from 'schemas/types'; +import { fyo } from 'src/initFyo'; +import { getMapFromList } from 'utils'; +import { QueryFilter } from 'utils/db/types'; + +const ACC_NAME_WIDTH = 2; +const ACC_BAL_WIDTH = 1; + +export abstract class AccountReport extends LedgerReport { + toDate?: string; + count: number = 3; + fromYear?: number; + toYear?: number; + consolidateColumns: boolean = false; + hideGroupBalance: boolean = false; + periodicity: Periodicity = 'Monthly'; + basedOn: BasedOn = 'Until Date'; + + _rawData: LedgerEntry[] = []; + _dateRanges?: DateRange[]; + + accountMap?: Record; + abstract get rootTypes(): AccountRootType[]; + + async initialize(): Promise { + await super.initialize(); + await this._setDateRanges(); + } + + async setDefaultFilters(): Promise { + if (this.basedOn === 'Until Date' && !this.toDate) { + this.toDate = DateTime.now().toISODate(); + } + + if (this.basedOn === 'Fiscal Year' && !this.toYear) { + this.fromYear = DateTime.now().year; + this.toYear = this.fromYear + 1; + } + + await this._setDateRanges(); + } + + async _setDateRanges() { + this._dateRanges = await this._getDateRanges(); + } + + getRootNode( + rootType: AccountRootType, + accountTree: AccountTree + ): AccountTreeNode | undefined { + const rootNodeList = Object.values(accountTree); + return rootNodeList.find((n) => n.rootType === rootType); + } + + getEmptyRow(): ReportRow { + const columns = this.getColumns(); + return { + isEmpty: true, + cells: columns.map( + (c) => + ({ + value: '', + rawValue: '', + width: c.width, + align: 'left', + } as ReportCell) + ), + }; + } + + async getTotalNode( + rootNode: AccountTreeNode, + name: string + ): Promise { + const accountTree = { [rootNode.name]: rootNode }; + const leafNodes = getListOfLeafNodes(accountTree) as AccountTreeNode[]; + + const totalMap = leafNodes.reduce((acc, node) => { + for (const key of this._dateRanges!) { + const bal = acc.get(key) ?? 0; + const val = node.valueMap?.get(key) ?? 0; + acc.set(key, bal + val); + } + + return acc; + }, new Map() as ValueMap); + + return { name, valueMap: totalMap, level: 0 } as AccountListNode; + } + + getReportRowsFromAccountList(accountList: AccountList): ReportData { + return accountList.map((al) => { + return this.getRowFromAccountListNode(al); + }); + } + + getRowFromAccountListNode(al: AccountListNode) { + const nameCell = { + value: al.name, + rawValue: al.name, + align: 'left', + width: ACC_NAME_WIDTH, + bold: !al.level, + italics: al.isGroup, + indent: al.level ?? 0, + } as ReportCell; + + const balanceCells = this._dateRanges!.map((k) => { + const rawValue = al.valueMap?.get(k) ?? 0; + let value = this.fyo.format(rawValue, 'Currency'); + if (this.hideGroupBalance && al.isGroup) { + value = ''; + } + + return { + rawValue, + value, + align: 'right', + width: ACC_BAL_WIDTH, + } as ReportCell; + }); + + return { + cells: [nameCell, balanceCells].flat(), + level: al.level, + isGroup: !!al.isGroup, + folded: false, + foldedBelow: false, + } as ReportRow; + } + + async _getGroupedByDateRanges( + map: GroupedMap + ): Promise { + const accountValueMap: AccountNameValueMapMap = new Map(); + const accountMap = await this._getAccountMap(); + + for (const account of map.keys()) { + const valueMap: ValueMap = new Map(); + + /** + * Set Balance for every DateRange key + */ + for (const entry of map.get(account)!) { + const key = this._getRangeMapKey(entry); + if (key === null) { + continue; + } + + const totalBalance = valueMap.get(key!) ?? 0; + const balance = (entry.debit ?? 0) - (entry.credit ?? 0); + const rootType = accountMap[entry.account].rootType; + + if (isCredit(rootType)) { + valueMap.set(key!, totalBalance - balance); + } else { + valueMap.set(key!, totalBalance + balance); + } + } + accountValueMap.set(account, valueMap); + } + + return accountValueMap; + } + + async _getAccountTree(rangeGroupedMap: AccountNameValueMapMap) { + const accountTree = cloneDeep(await this._getAccountMap()) as AccountTree; + + setPruneFlagOnAccountTreeNodes(accountTree); + setValueMapOnAccountTreeNodes(accountTree, rangeGroupedMap); + setChildrenOnAccountTreeNodes(accountTree); + deleteNonRootAccountTreeNodes(accountTree); + pruneAccountTree(accountTree); + + return accountTree; + } + + async _getAccountMap() { + if (this.accountMap) { + return this.accountMap; + } + + const accountList: Account[] = ( + await this.fyo.db.getAllRaw('Account', { + fields: ['name', 'rootType', 'isGroup', 'parentAccount'], + }) + ).map((rv) => ({ + name: rv.name as string, + rootType: rv.rootType as AccountRootType, + isGroup: Boolean(rv.isGroup), + parentAccount: rv.parentAccount as string | null, + })); + + this.accountMap = getMapFromList(accountList, 'name'); + return this.accountMap; + } + + _getRangeMapKey(entry: LedgerEntry): DateRange | null { + const entryDate = DateTime.fromISO( + entry.date!.toISOString().split('T')[0] + ).toMillis(); + + for (const dr of this._dateRanges!) { + const toDate = dr.toDate.toMillis(); + const fromDate = dr.fromDate.toMillis(); + + if (entryDate <= toDate && entryDate > fromDate) { + return dr; + } + } + + return null; + } + + async _getDateRanges(): Promise { + const endpoints = await this._getFromAndToDates(); + const fromDate = DateTime.fromISO(endpoints.fromDate); + const toDate = DateTime.fromISO(endpoints.toDate); + + if (this.consolidateColumns) { + return [ + { + toDate, + fromDate, + }, + ]; + } + + const months: number = monthsMap[this.periodicity]; + const dateRanges: DateRange[] = [ + { toDate, fromDate: toDate.minus({ months }) }, + ]; + + let count = this.count ?? 1; + if (this.basedOn === 'Fiscal Year') { + count = Math.ceil(((this.toYear! - this.fromYear!) * 12) / months); + } + + for (let i = 1; i < count; i++) { + const lastRange = dateRanges.at(-1)!; + dateRanges.push({ + toDate: lastRange.fromDate, + fromDate: lastRange.fromDate.minus({ months }), + }); + } + + return dateRanges.sort((b, a) => b.toDate.toMillis() - a.toDate.toMillis()); + } + + async _getFromAndToDates() { + let toDate: string; + let fromDate: string; + + if (this.basedOn === 'Until Date') { + toDate = this.toDate!; + const months = monthsMap[this.periodicity] * Math.max(this.count ?? 1, 1); + fromDate = DateTime.fromISO(toDate).minus({ months }).toISODate(); + } else { + const fy = await getFiscalEndpoints(this.toYear!, this.fromYear!); + toDate = fy.toDate; + fromDate = fy.fromDate; + } + + return { fromDate, toDate }; + } + + async _getQueryFilters(): Promise { + const filters: QueryFilter = {}; + const { fromDate, toDate } = await this._getFromAndToDates(); + + const dateFilter: string[] = []; + dateFilter.push('<=', toDate); + dateFilter.push('>=', fromDate); + + filters.date = dateFilter; + filters.reverted = false; + return filters; + } + + getFilters(): Field[] { + const periodNameMap: Record = { + Monthly: t`Months`, + Quarterly: t`Quarters`, + 'Half Yearly': t`Half Years`, + Yearly: t`Years`, + }; + + const filters = [ + { + fieldtype: 'Select', + options: [ + { label: t`Fiscal Year`, value: 'Fiscal Year' }, + { label: t`Until Date`, value: 'Until Date' }, + ], + label: t`Based On`, + fieldname: 'basedOn', + }, + { + fieldtype: 'Select', + options: [ + { label: t`Monthly`, value: 'Monthly' }, + { label: t`Quarterly`, value: 'Quarterly' }, + { label: t`Half Yearly`, value: 'Half Yearly' }, + { label: t`Yearly`, value: 'Yearly' }, + ], + label: t`Periodicity`, + fieldname: 'periodicity', + }, + , + ] as Field[]; + + let dateFilters = [ + { + fieldtype: 'Int', + fieldname: 'toYear', + placeholder: t`To Year`, + label: t`To Year`, + minvalue: 2000, + required: true, + }, + { + fieldtype: 'Int', + fieldname: 'fromYear', + placeholder: t`From Year`, + label: t`From Year`, + minvalue: 2000, + required: true, + }, + ] as Field[]; + + if (this.basedOn === 'Until Date') { + dateFilters = [ + { + fieldtype: 'Date', + fieldname: 'toDate', + placeholder: t`To Date`, + label: t`To Date`, + required: true, + }, + { + fieldtype: 'Int', + fieldname: 'count', + minvalue: 1, + placeholder: t`Number of ${periodNameMap[this.periodicity]}`, + label: t`Number of ${periodNameMap[this.periodicity]}`, + required: true, + }, + ] as Field[]; + } + + return [ + filters, + dateFilters, + { + fieldtype: 'Check', + label: t`Consolidate Columns`, + fieldname: 'consolidateColumns', + } as Field, + { + fieldtype: 'Check', + label: t`Hide Group Balance`, + fieldname: 'hideGroupBalance', + } as Field, + ].flat(); + } + + getColumns(): ColumnField[] { + const columns = [ + { + label: t`Account`, + fieldtype: 'Link', + fieldname: 'account', + align: 'left', + width: ACC_NAME_WIDTH, + }, + ] as ColumnField[]; + + const dateColumns = this._dateRanges!.sort( + (a, b) => b.toDate.toMillis() - a.toDate.toMillis() + ).map( + (d) => + ({ + label: this.fyo.format(d.toDate.toJSDate(), 'Date'), + fieldtype: 'Data', + fieldname: 'toDate', + align: 'right', + width: ACC_BAL_WIDTH, + } as ColumnField) + ); + + return [columns, dateColumns].flat(); + } + + getActions(): Action[] { + return []; + } + + metaFilters: string[] = ['basedOn']; +} + +async function getFiscalEndpoints(toYear: number, fromYear: number) { + const fys = (await fyo.getValue( + ModelNameEnum.AccountingSettings, + 'fiscalYearStart' + )) as Date; + const fye = (await fyo.getValue( + ModelNameEnum.AccountingSettings, + 'fiscalYearEnd' + )) as Date; + + /** + * Get the month and the day, and + * prepend with the passed year. + */ + + const fromDate = [ + fromYear, + fys.toISOString().split('T')[0].split('-').slice(1), + ] + .flat() + .join('-'); + + const toDate = [toYear, fye.toISOString().split('T')[0].split('-').slice(1)] + .flat() + .join('-'); + + return { fromDate, toDate }; +} + +const monthsMap: Record = { + Monthly: 1, + Quarterly: 3, + 'Half Yearly': 6, + Yearly: 12, +}; + +function setPruneFlagOnAccountTreeNodes(accountTree: AccountTree) { + for (const account of Object.values(accountTree)) { + account.prune = true; + } +} + +function setValueMapOnAccountTreeNodes( + accountTree: AccountTree, + rangeGroupedMap: AccountNameValueMapMap +) { + for (const name of rangeGroupedMap.keys()) { + const valueMap = rangeGroupedMap.get(name)!; + accountTree[name].valueMap = valueMap; + accountTree[name].prune = false; + + /** + * Set the update the parent account values recursively + * also prevent pruning of the parent accounts. + */ + let parentAccountName: string | null = accountTree[name].parentAccount; + + while (parentAccountName !== null) { + parentAccountName = updateParentAccountWithChildValues( + accountTree, + parentAccountName, + valueMap + ); + } + } +} + +function updateParentAccountWithChildValues( + accountTree: AccountTree, + parentAccountName: string, + valueMap: ValueMap +): string { + const parentAccount = accountTree[parentAccountName]; + parentAccount.prune = false; + parentAccount.valueMap ??= new Map(); + + for (const key of valueMap.keys()) { + const value = parentAccount.valueMap!.get(key) ?? 0; + parentAccount.valueMap!.set(key, value + valueMap.get(key)!); + } + + return parentAccount.parentAccount!; +} + +function setChildrenOnAccountTreeNodes(accountTree: AccountTree) { + const parentNodes: Set = new Set(); + + for (const name of Object.keys(accountTree)) { + const ac = accountTree[name]; + if (!ac.parentAccount) { + continue; + } + + accountTree[ac.parentAccount].children ??= []; + accountTree[ac.parentAccount].children!.push(ac!); + + parentNodes.add(ac.parentAccount); + } +} + +function deleteNonRootAccountTreeNodes(accountTree: AccountTree) { + for (const name of Object.keys(accountTree)) { + const ac = accountTree[name]; + if (!ac.parentAccount) { + continue; + } + + delete accountTree[name]; + } +} + +function pruneAccountTree(accountTree: AccountTree) { + for (const root of Object.keys(accountTree)) { + if (accountTree[root].prune) { + delete accountTree[root]; + } + } + + for (const root of Object.keys(accountTree)) { + accountTree[root].children = getPrunedChildren(accountTree[root].children!); + } +} + +function getPrunedChildren(children: AccountTreeNode[]): AccountTreeNode[] { + return children.filter((child) => { + if (child.children) { + child.children = getPrunedChildren(child.children); + } + + return !child.prune; + }); +} + +export function convertAccountRootNodeToAccountList( + rootNode: AccountTreeNode +): AccountList { + if (!rootNode) { + return []; + } + + const accountList: AccountList = []; + pushToAccountList(rootNode, accountList, 0); + return accountList; +} + +function pushToAccountList( + accountTreeNode: AccountTreeNode, + accountList: AccountList, + level: number +) { + accountList.push({ + name: accountTreeNode.name, + rootType: accountTreeNode.rootType, + isGroup: accountTreeNode.isGroup, + parentAccount: accountTreeNode.parentAccount, + valueMap: accountTreeNode.valueMap, + level, + }); + + const children = accountTreeNode.children ?? []; + const childLevel = level + 1; + + for (const childNode of children) { + pushToAccountList(childNode, accountList, childLevel); + } +} + +function getListOfLeafNodes(tree: Tree): TreeNode[] { + const nonGroupChildren: TreeNode[] = []; + for (const node of Object.values(tree)) { + if (!node) { + continue; + } + + const groupChildren = node.children ?? []; + + while (groupChildren.length) { + const child = groupChildren.shift()!; + if (!child?.children?.length) { + nonGroupChildren.push(child); + continue; + } + + groupChildren.unshift(...(child.children ?? [])); + } + } + + return nonGroupChildren; +} diff --git a/reports/BalanceSheet/BalanceSheet.ts b/reports/BalanceSheet/BalanceSheet.ts index 02431843..e87ac040 100644 --- a/reports/BalanceSheet/BalanceSheet.ts +++ b/reports/BalanceSheet/BalanceSheet.ts @@ -1,59 +1,106 @@ -import { Fyo } from 'fyo'; -import { unique } from 'fyo/utils'; -import { FinancialStatements } from 'reports/FinancialStatements/financialStatements'; -import { FinancialStatementOptions } from 'reports/types'; +import { t } from 'fyo'; +import { + AccountRootType, + AccountRootTypeEnum, +} from 'models/baseModels/Account/types'; +import { + AccountReport, + convertAccountRootNodeToAccountList, +} from 'reports/AccountReport'; +import { AccountTreeNode, ReportData } from 'reports/types'; +import { getMapFromList } from 'utils'; -class BalanceSheet { - async run(options: FinancialStatementOptions, fyo: Fyo) { - const { fromDate, toDate, periodicity } = options; - const fs = new FinancialStatements(fyo); - const asset = await fs.getData({ - rootType: 'Asset', - balanceMustBe: 'Debit', - fromDate, - toDate, - periodicity, - accumulateValues: true, - }); +type RootTypeRow = { + rootType: AccountRootType; + rootNode: AccountTreeNode; + rows: ReportData; +}; - const liability = await fs.getData({ - rootType: 'Liability', - balanceMustBe: 'Credit', - fromDate, - toDate, - periodicity, - accumulateValues: true, - }); +export class BalanceSheet extends AccountReport { + static title = t`Balance Sheet`; + static reportName = 'balance-sheet'; - const equity = await fs.getData({ - rootType: 'Equity', - balanceMustBe: 'Credit', - fromDate, - toDate, - periodicity, - accumulateValues: true, - }); + get rootTypes(): AccountRootType[] { + return [ + AccountRootTypeEnum.Asset, + AccountRootTypeEnum.Liability, + AccountRootTypeEnum.Equity, + ]; + } - const rows = [ - ...asset.accounts, - asset.totalRow, - [], - ...liability.accounts, - liability.totalRow, - [], - ...equity.accounts, - equity.totalRow, - [], + async setReportData(filter?: string) { + if (filter !== 'hideGroupBalance') { + await this._setRawData(); + } + + const map = this._getGroupedMap(true, 'account'); + const rangeGroupedMap = await this._getGroupedByDateRanges(map); + const accountTree = await this._getAccountTree(rangeGroupedMap); + + for (const name of Object.keys(accountTree)) { + const { rootType } = accountTree[name]; + if (this.rootTypes.includes(rootType)) { + continue; + } + + delete accountTree[name]; + } + + const rootTypeRows: RootTypeRow[] = this.rootTypes + .map((rootType) => { + const rootNode = this.getRootNode(rootType, accountTree)!; + const rootList = convertAccountRootNodeToAccountList(rootNode); + return { + rootType, + rootNode, + rows: this.getReportRowsFromAccountList(rootList), + }; + }) + .filter((row) => !!row.rootNode); + + this.reportData = await this.getReportDataFromRows( + getMapFromList(rootTypeRows, 'rootType') + ); + } + + async getReportDataFromRows( + rootTypeRows: Record + ): Promise { + const typeNameList = [ + { + rootType: AccountRootTypeEnum.Asset, + totalName: t`Total Asset (Debit)`, + }, + { + rootType: AccountRootTypeEnum.Liability, + totalName: t`Total Liability (Credit)`, + }, + { + rootType: AccountRootTypeEnum.Equity, + totalName: t`Total Equity (Credit)`, + }, ]; - const columns = unique([ - ...asset.periodList, - ...liability.periodList, - ...equity.periodList, - ]); + const reportData: ReportData = []; + const emptyRow = this.getEmptyRow(); + for (const { rootType, totalName } of typeNameList) { + const row = rootTypeRows[rootType]; + if (!row) { + continue; + } - return { rows, columns }; + const totalNode = await this.getTotalNode(row.rootNode, totalName); + const totalRow = this.getRowFromAccountListNode(totalNode); + + reportData.push(...row.rows); + reportData.push(totalRow); + reportData.push(emptyRow); + } + + if (reportData.at(-1)?.isEmpty) { + reportData.pop(); + } + + return reportData; } } - -export default BalanceSheet; diff --git a/reports/BalanceSheet/viewConfig.js b/reports/BalanceSheet/viewConfig.js deleted file mode 100644 index 3d573002..00000000 --- a/reports/BalanceSheet/viewConfig.js +++ /dev/null @@ -1,56 +0,0 @@ -import { t } from 'fyo'; -import getCommonExportActions from '../commonExporter'; -import { fyo } from 'src/initFyo'; - -const periodicityMap = { - Monthly: t`Monthly`, - Quarterly: t`Quarterly`, - 'Half Yearly': t`Half Yearly`, - Yearly: t`Yearly`, -}; -export default { - title: t`Balance Sheet`, - method: 'balance-sheet', - filterFields: [ - { - fieldtype: 'Date', - fieldname: 'toDate', - size: 'small', - placeholder: t`To Date`, - label: t`To Date`, - required: 1, - default: async () => { - return (await fyo.doc.getSingle('AccountingSettings')).fiscalYearEnd; - }, - }, - { - fieldtype: 'Select', - placeholder: t`Select Period`, - size: 'small', - options: Object.keys(periodicityMap), - map: periodicityMap, - label: t`Periodicity`, - fieldname: 'periodicity', - default: 'Monthly', - }, - ], - actions: getCommonExportActions('balance-sheet'), - getColumns({ data }) { - const columns = [ - { label: t`Account`, fieldtype: 'Data', fieldname: 'account', width: 2 }, - ]; - - if (data && data.columns) { - const currencyColumns = data.columns; - const columnDefs = currencyColumns.map((name) => ({ - label: name, - fieldname: name, - fieldtype: 'Currency', - })); - - columns.push(...columnDefs); - } - - return columns; - }, -}; diff --git a/reports/GeneralLedger/GeneralLedger.ts b/reports/GeneralLedger/GeneralLedger.ts index ae279aab..a5dd6d2b 100644 --- a/reports/GeneralLedger/GeneralLedger.ts +++ b/reports/GeneralLedger/GeneralLedger.ts @@ -104,6 +104,7 @@ export class GeneralLedger extends LedgerReport { _getRowFromEntry(entry: LedgerEntry, columns: ColumnField[]): ReportRow { if (entry.name === -3) { return { + isEmpty: true, cells: columns.map((c) => ({ rawValue: '', value: '', diff --git a/reports/ProfitAndLoss/ProfitAndLoss.ts b/reports/ProfitAndLoss/ProfitAndLoss.ts index 5a3bf614..83376cb2 100644 --- a/reports/ProfitAndLoss/ProfitAndLoss.ts +++ b/reports/ProfitAndLoss/ProfitAndLoss.ts @@ -1,159 +1,80 @@ import { t } from 'fyo'; -import { Action } from 'fyo/model/types'; -import { cloneDeep } from 'lodash'; -import { DateTime } from 'luxon'; import { AccountRootType, - AccountRootTypeEnum + AccountRootTypeEnum, } from 'models/baseModels/Account/types'; -import { isCredit } from 'models/helpers'; -import { ModelNameEnum } from 'models/types'; -import { LedgerReport } from 'reports/LedgerReport'; import { - ColumnField, - GroupedMap, - LedgerEntry, - Periodicity, - ReportCell, - ReportData, - ReportRow -} from 'reports/types'; -import { Field } from 'schemas/types'; -import { fyo } from 'src/initFyo'; -import { getMapFromList } from 'utils'; -import { QueryFilter } from 'utils/db/types'; + AccountReport, + convertAccountRootNodeToAccountList, +} from 'reports/AccountReport'; +import { AccountListNode, AccountTreeNode, ReportData } from 'reports/types'; -type DateRange = { fromDate: DateTime; toDate: DateTime }; -type ValueMap = Map; -type AccountNameValueMapMap = Map; -type BasedOn = 'Fiscal Year' | 'Until Date'; - -interface Account { - name: string; - rootType: AccountRootType; - isGroup: boolean; - parentAccount: string | null; -} - -interface TreeNode { - name: string; - children?: TreeNode[]; -} - -type Tree = Record; - -type AccountTree = Record; -interface AccountTreeNode extends Account { - children?: AccountTreeNode[]; - valueMap?: ValueMap; - prune?: boolean; -} - -type AccountList = AccountListNode[]; -interface AccountListNode extends Account { - valueMap?: ValueMap; - level?: number; -} - -const PNL_ROOT_TYPES: AccountRootType[] = [ - AccountRootTypeEnum.Income, - AccountRootTypeEnum.Expense, -]; - -const ACC_NAME_WIDTH = 2; -const ACC_BAL_WIDTH = 1; - -export class ProfitAndLoss extends LedgerReport { +export class ProfitAndLoss extends AccountReport { static title = t`Profit And Loss`; static reportName = 'profit-and-loss'; - toDate?: string; - count: number = 3; - fromYear?: number; - toYear?: number; - singleColumn: boolean = false; - hideGroupBalance: boolean = false; - periodicity: Periodicity = 'Monthly'; - basedOn: BasedOn = 'Until Date'; - - _rawData: LedgerEntry[] = []; - _dateRanges?: DateRange[]; - - accountMap?: Record; - - async initialize(): Promise { - await super.initialize(); - await this._setDateRanges(); - } - - async setDefaultFilters(): Promise { - if (this.basedOn === 'Until Date' && !this.toDate) { - this.toDate = DateTime.now().toISODate(); - } - - if (this.basedOn === 'Fiscal Year' && !this.toYear) { - this.fromYear = DateTime.now().year; - this.toYear = this.fromYear + 1; - } - - await this._setDateRanges(); - } - - async _setDateRanges() { - this._dateRanges = await this._getDateRanges(); + get rootTypes(): AccountRootType[] { + return [AccountRootTypeEnum.Income, AccountRootTypeEnum.Expense]; } async setReportData(filter?: string) { - await this._setRawData(); + if (filter !== 'hideGroupBalance') { + await this._setRawData(); + } + const map = this._getGroupedMap(true, 'account'); const rangeGroupedMap = await this._getGroupedByDateRanges(map); const accountTree = await this._getAccountTree(rangeGroupedMap); for (const name of Object.keys(accountTree)) { const { rootType } = accountTree[name]; - if (PNL_ROOT_TYPES.includes(rootType)) { + if (this.rootTypes.includes(rootType)) { continue; } delete accountTree[name]; } - const rootNodeList = Object.values(accountTree); - const incomeTree = { - [AccountRootTypeEnum.Income]: rootNodeList.find( - (n) => n.rootType === AccountRootTypeEnum.Income - )!, - }; - const expenseTree = { - [AccountRootTypeEnum.Expense]: rootNodeList.find( - (n) => n.rootType === AccountRootTypeEnum.Expense - )!, - }; + /** + * Income Rows + */ + const incomeRoot = this.getRootNode( + AccountRootTypeEnum.Income, + accountTree + )!; + const incomeList = convertAccountRootNodeToAccountList(incomeRoot); + const incomeRows = this.getReportRowsFromAccountList(incomeList); + + /** + * Expense Rows + */ + const expenseRoot = this.getRootNode( + AccountRootTypeEnum.Expense, + accountTree + )!; + const expenseList = convertAccountRootNodeToAccountList(expenseRoot); + const expenseRows = this.getReportRowsFromAccountList(expenseList); - const incomeList = convertAccountTreeToAccountList(incomeTree); - const expenseList = convertAccountTreeToAccountList(expenseTree); - const incomeRows = this.getPnlRowsFromAccountList(incomeList); - const expenseRows = this.getPnlRowsFromAccountList(expenseList); this.reportData = await this.getReportDataFromRows( incomeRows, expenseRows, - incomeTree, - expenseTree + incomeRoot, + expenseRoot ); } async getReportDataFromRows( incomeRows: ReportData, expenseRows: ReportData, - incomeTree: AccountTree, - expenseTree: AccountTree + incomeRoot: AccountTreeNode, + expenseRoot: AccountTreeNode ): Promise { const totalIncome = await this.getTotalNode( - incomeTree, + incomeRoot, t`Total Income (Credit)` ); const totalExpense = await this.getTotalNode( - expenseTree, + expenseRoot, t`Total Expense (Debit)` ); @@ -187,7 +108,7 @@ export class ProfitAndLoss extends LedgerReport { } }); - const emptyRow = await this.getEmptyRow(); + const emptyRow = this.getEmptyRow(); return [ incomeRows, @@ -200,541 +121,4 @@ export class ProfitAndLoss extends LedgerReport { totalProfitRow, ].flat() as ReportData; } - - async getEmptyRow(): Promise { - const columns = await this.getColumns(); - return { - cells: columns.map( - (c) => - ({ - value: '', - rawValue: '', - width: c.width, - align: 'left', - } as ReportCell) - ), - }; - } - - async getTotalNode( - accountTree: AccountTree, - name: string - ): Promise { - const leafNodes = getListOfLeafNodes(accountTree) as AccountTreeNode[]; - - const totalMap = leafNodes.reduce((acc, node) => { - for (const key of this._dateRanges!) { - const bal = acc.get(key) ?? 0; - const val = node.valueMap?.get(key) ?? 0; - acc.set(key, bal + val); - } - - return acc; - }, new Map() as ValueMap); - - return { name, valueMap: totalMap, level: 0 } as AccountListNode; - } - - getPnlRowsFromAccountList(accountList: AccountList): ReportData { - return accountList.map((al) => { - return this.getRowFromAccountListNode(al); - }); - } - - getRowFromAccountListNode(al: AccountListNode) { - const nameCell = { - value: al.name, - rawValue: al.name, - align: 'left', - width: ACC_NAME_WIDTH, - bold: !al.level, - italics: al.isGroup, - indent: al.level ?? 0, - } as ReportCell; - - const balanceCells = this._dateRanges!.map((k) => { - const rawValue = al.valueMap?.get(k) ?? 0; - let value = this.fyo.format(rawValue, 'Currency'); - if (this.hideGroupBalance && al.isGroup) { - value = ''; - } - - return { - rawValue, - value, - align: 'right', - width: ACC_BAL_WIDTH, - } as ReportCell; - }); - - return { - cells: [nameCell, balanceCells].flat(), - level: al.level, - isGroup: !!al.isGroup, - folded: false, - foldedBelow: false, - } as ReportRow; - } - - async _getGroupedByDateRanges( - map: GroupedMap - ): Promise { - const accountValueMap: AccountNameValueMapMap = new Map(); - const accountMap = await this._getAccountMap(); - - for (const account of map.keys()) { - const valueMap: ValueMap = new Map(); - - /** - * Set Balance for every DateRange key - */ - for (const entry of map.get(account)!) { - const key = this._getRangeMapKey(entry); - if (key === null) { - continue; - } - - const totalBalance = valueMap.get(key!) ?? 0; - const balance = (entry.debit ?? 0) - (entry.credit ?? 0); - const rootType = accountMap[entry.account].rootType; - - if (isCredit(rootType)) { - valueMap.set(key!, totalBalance - balance); - } else { - valueMap.set(key!, totalBalance + balance); - } - } - accountValueMap.set(account, valueMap); - } - - return accountValueMap; - } - - async _getAccountTree(rangeGroupedMap: AccountNameValueMapMap) { - const accountTree = cloneDeep(await this._getAccountMap()) as AccountTree; - - setPruneFlagOnAccountTreeNodes(accountTree); - setValueMapOnAccountTreeNodes(accountTree, rangeGroupedMap); - setChildrenOnAccountTreeNodes(accountTree); - deleteNonRootAccountTreeNodes(accountTree); - pruneAccountTree(accountTree); - - return accountTree; - } - - async _getAccountMap() { - if (this.accountMap) { - return this.accountMap; - } - - const accountList: Account[] = ( - await this.fyo.db.getAllRaw('Account', { - fields: ['name', 'rootType', 'isGroup', 'parentAccount'], - }) - ).map((rv) => ({ - name: rv.name as string, - rootType: rv.rootType as AccountRootType, - isGroup: Boolean(rv.isGroup), - parentAccount: rv.parentAccount as string | null, - })); - - this.accountMap = getMapFromList(accountList, 'name'); - return this.accountMap; - } - - _getRangeMapKey(entry: LedgerEntry): DateRange | null { - const entryDate = DateTime.fromISO( - entry.date!.toISOString().split('T')[0] - ).toMillis(); - - for (const dr of this._dateRanges!) { - const toDate = dr.toDate.toMillis(); - const fromDate = dr.fromDate.toMillis(); - - if (entryDate <= toDate && entryDate > fromDate) { - return dr; - } - } - - return null; - } - - async _getDateRanges(): Promise { - const endpoints = await this._getFromAndToDates(); - const fromDate = DateTime.fromISO(endpoints.fromDate); - const toDate = DateTime.fromISO(endpoints.toDate); - - if (this.singleColumn) { - return [ - { - toDate, - fromDate, - }, - ]; - } - - const months: number = monthsMap[this.periodicity]; - const dateRanges: DateRange[] = [ - { toDate, fromDate: toDate.minus({ months }) }, - ]; - - let count = this.count ?? 1; - if (this.basedOn === 'Fiscal Year') { - count = Math.ceil(((this.toYear! - this.fromYear!) * 12) / months); - } - - for (let i = 1; i < count; i++) { - const lastRange = dateRanges.at(-1)!; - dateRanges.push({ - toDate: lastRange.fromDate, - fromDate: lastRange.fromDate.minus({ months }), - }); - } - - return dateRanges.sort((b, a) => b.toDate.toMillis() - a.toDate.toMillis()); - } - - async _getFromAndToDates() { - let toDate: string; - let fromDate: string; - - if (this.basedOn === 'Until Date') { - toDate = this.toDate!; - const months = monthsMap[this.periodicity] * Math.max(this.count ?? 1, 1); - fromDate = DateTime.fromISO(toDate).minus({ months }).toISODate(); - } else { - const fy = await getFiscalEndpoints(this.toYear!, this.fromYear!); - toDate = fy.toDate; - fromDate = fy.fromDate; - } - - return { fromDate, toDate }; - } - - async _getQueryFilters(): Promise { - const filters: QueryFilter = {}; - const { fromDate, toDate } = await this._getFromAndToDates(); - - const dateFilter: string[] = []; - dateFilter.push('<=', toDate); - dateFilter.push('>=', fromDate); - - filters.date = dateFilter; - filters.reverted = false; - return filters; - } - - getFilters(): Field[] { - const periodNameMap: Record = { - Monthly: t`Months`, - Quarterly: t`Quarters`, - 'Half Yearly': t`Half Years`, - Yearly: t`Years`, - }; - - const filters = [ - { - fieldtype: 'Select', - options: [ - { label: t`Fiscal Year`, value: 'Fiscal Year' }, - { label: t`Until Date`, value: 'Until Date' }, - ], - label: t`Based On`, - fieldname: 'basedOn', - }, - { - fieldtype: 'Select', - options: [ - { label: t`Monthly`, value: 'Monthly' }, - { label: t`Quarterly`, value: 'Quarterly' }, - { label: t`Half Yearly`, value: 'Half Yearly' }, - { label: t`Yearly`, value: 'Yearly' }, - ], - label: t`Periodicity`, - fieldname: 'periodicity', - }, - , - ] as Field[]; - - let dateFilters = [ - { - fieldtype: 'Int', - fieldname: 'toYear', - placeholder: t`To Year`, - label: t`To Year`, - minvalue: 2000, - required: true, - }, - { - fieldtype: 'Int', - fieldname: 'fromYear', - placeholder: t`From Year`, - label: t`From Year`, - minvalue: 2000, - required: true, - }, - ] as Field[]; - - if (this.basedOn === 'Until Date') { - dateFilters = [ - { - fieldtype: 'Date', - fieldname: 'toDate', - placeholder: t`To Date`, - label: t`To Date`, - required: true, - }, - { - fieldtype: 'Int', - fieldname: 'count', - minvalue: 1, - placeholder: t`Number of ${periodNameMap[this.periodicity]}`, - label: t`Number of ${periodNameMap[this.periodicity]}`, - required: true, - }, - ] as Field[]; - } - - return [ - filters, - dateFilters, - { - fieldtype: 'Check', - label: t`Single Column`, - fieldname: 'singleColumn', - } as Field, - { - fieldtype: 'Check', - label: t`Hide Group Balance`, - fieldname: 'hideGroupBalance', - } as Field, - ].flat(); - } - - getColumns(): ColumnField[] { - const columns = [ - { - label: t`Account`, - fieldtype: 'Link', - fieldname: 'account', - align: 'left', - width: ACC_NAME_WIDTH, - }, - ] as ColumnField[]; - - const dateColumns = this._dateRanges!.sort( - (a, b) => b.toDate.toMillis() - a.toDate.toMillis() - ).map( - (d) => - ({ - label: this.fyo.format(d.toDate.toJSDate(), 'Date'), - fieldtype: 'Data', - fieldname: 'toDate', - align: 'right', - width: ACC_BAL_WIDTH, - } as ColumnField) - ); - - return [columns, dateColumns].flat(); - } - - getActions(): Action[] { - return []; - } - - metaFilters: string[] = ['basedOn']; -} - -async function getFiscalEndpoints(toYear: number, fromYear: number) { - const fys = (await fyo.getValue( - ModelNameEnum.AccountingSettings, - 'fiscalYearStart' - )) as Date; - const fye = (await fyo.getValue( - ModelNameEnum.AccountingSettings, - 'fiscalYearEnd' - )) as Date; - - /** - * Get the month and the day, and - * prepend with the passed year. - */ - - const fromDate = [ - fromYear, - fys.toISOString().split('T')[0].split('-').slice(1), - ] - .flat() - .join('-'); - - const toDate = [toYear, fye.toISOString().split('T')[0].split('-').slice(1)] - .flat() - .join('-'); - - return { fromDate, toDate }; -} - -const monthsMap: Record = { - Monthly: 1, - Quarterly: 3, - 'Half Yearly': 6, - Yearly: 12, -}; - -function setPruneFlagOnAccountTreeNodes(accountTree: AccountTree) { - for (const account of Object.values(accountTree)) { - account.prune = true; - } -} - -function setValueMapOnAccountTreeNodes( - accountTree: AccountTree, - rangeGroupedMap: AccountNameValueMapMap -) { - for (const name of rangeGroupedMap.keys()) { - const valueMap = rangeGroupedMap.get(name)!; - accountTree[name].valueMap = valueMap; - accountTree[name].prune = false; - - /** - * Set the update the parent account values recursively - * also prevent pruning of the parent accounts. - */ - let parentAccountName: string | null = accountTree[name].parentAccount; - - while (parentAccountName !== null) { - parentAccountName = updateParentAccountWithChildValues( - accountTree, - parentAccountName, - valueMap - ); - } - } -} - -function updateParentAccountWithChildValues( - accountTree: AccountTree, - parentAccountName: string, - valueMap: ValueMap -): string { - const parentAccount = accountTree[parentAccountName]; - parentAccount.prune = false; - parentAccount.valueMap ??= new Map(); - - for (const key of valueMap.keys()) { - const value = parentAccount.valueMap!.get(key) ?? 0; - parentAccount.valueMap!.set(key, value + valueMap.get(key)!); - } - - return parentAccount.parentAccount!; -} - -function setChildrenOnAccountTreeNodes(accountTree: AccountTree) { - const parentNodes: Set = new Set(); - - for (const name of Object.keys(accountTree)) { - const ac = accountTree[name]; - if (!ac.parentAccount) { - continue; - } - - accountTree[ac.parentAccount].children ??= []; - accountTree[ac.parentAccount].children!.push(ac!); - - parentNodes.add(ac.parentAccount); - } -} - -function deleteNonRootAccountTreeNodes(accountTree: AccountTree) { - for (const name of Object.keys(accountTree)) { - const ac = accountTree[name]; - if (!ac.parentAccount) { - continue; - } - - delete accountTree[name]; - } -} - -function pruneAccountTree(accountTree: AccountTree) { - for (const root of Object.keys(accountTree)) { - if (accountTree[root].prune) { - delete accountTree[root]; - } - } - - for (const root of Object.keys(accountTree)) { - accountTree[root].children = getPrunedChildren(accountTree[root].children!); - } -} - -function getPrunedChildren(children: AccountTreeNode[]): AccountTreeNode[] { - return children.filter((child) => { - if (child.children) { - child.children = getPrunedChildren(child.children); - } - - return !child.prune; - }); -} - -function convertAccountTreeToAccountList( - accountTree: AccountTree -): AccountList { - const accountList: AccountList = []; - - for (const rootNode of Object.values(accountTree)) { - if (!rootNode) { - continue; - } - - pushToAccountList(rootNode, accountList, 0); - } - - return accountList; -} - -function pushToAccountList( - accountTreeNode: AccountTreeNode, - accountList: AccountList, - level: number -) { - accountList.push({ - name: accountTreeNode.name, - rootType: accountTreeNode.rootType, - isGroup: accountTreeNode.isGroup, - parentAccount: accountTreeNode.parentAccount, - valueMap: accountTreeNode.valueMap, - level, - }); - - const children = accountTreeNode.children ?? []; - const childLevel = level + 1; - - for (const childNode of children) { - pushToAccountList(childNode, accountList, childLevel); - } -} - -function getListOfLeafNodes(tree: Tree): TreeNode[] { - const nonGroupChildren: TreeNode[] = []; - for (const node of Object.values(tree)) { - if (!node) { - continue; - } - - const groupChildren = node.children ?? []; - - while (groupChildren.length) { - const child = groupChildren.shift()!; - if (!child?.children?.length) { - nonGroupChildren.push(child); - continue; - } - - groupChildren.unshift(...(child.children ?? [])); - } - } - - return nonGroupChildren; } diff --git a/reports/index.js b/reports/index.js deleted file mode 100644 index 2f6be6fe..00000000 --- a/reports/index.js +++ /dev/null @@ -1,40 +0,0 @@ -/* -import AccountsReceivablePayable from './AccountsReceivablePayable/AccountsReceivablePayable'; -import BalanceSheet from './BalanceSheet/BalanceSheet'; -import BankReconciliation from './BankReconciliation/BankReconciliation'; -import GeneralLedger from './GeneralLedger/GeneralLedger'; -import GSTR1 from './GoodsAndServiceTax/GSTR1'; -import GSTR2 from './GoodsAndServiceTax/GSTR2'; -import ProfitAndLoss from './ProfitAndLoss/ProfitAndLoss'; -import PurchaseRegister from './PurchaseRegister/PurchaseRegister'; -import SalesRegister from './SalesRegister/SalesRegister'; -import TrialBalance from './TrialBalance/TrialBalance'; - -export function getReportData(method, filters) { - const reports = { - 'general-ledger': GeneralLedger, - 'profit-and-loss': ProfitAndLoss, - 'balance-sheet': BalanceSheet, - 'trial-balance': TrialBalance, - 'gstr-1': GSTR1, - 'gstr-2': GSTR2, - 'sales-register': SalesRegister, - 'purchase-register': PurchaseRegister, - 'bank-reconciliation': BankReconciliation, - }; - - if (method === 'accounts-receivable') { - return new AccountsReceivablePayable().run('Receivable', filters); - } - - if (method === 'accounts-payable') { - return new AccountsReceivablePayable().run('Payable', filters); - } - - const ReportClass = reports[method]; - return new ReportClass().run(filters); -} -*/ -export function getReportData(method, filters) { - return { rows: [], columns: [] }; -} diff --git a/reports/index.ts b/reports/index.ts index ec8f6e84..97a80101 100644 --- a/reports/index.ts +++ b/reports/index.ts @@ -1,4 +1,5 @@ +import { BalanceSheet } from './BalanceSheet/BalanceSheet'; import { GeneralLedger } from './GeneralLedger/GeneralLedger'; import { ProfitAndLoss } from './ProfitAndLoss/ProfitAndLoss'; -export const reports = { GeneralLedger, ProfitAndLoss }; +export const reports = { GeneralLedger, ProfitAndLoss, BalanceSheet }; diff --git a/reports/types.ts b/reports/types.ts index ad0b5f97..866d79d3 100644 --- a/reports/types.ts +++ b/reports/types.ts @@ -1,3 +1,4 @@ +import { DateTime } from 'luxon'; import { AccountRootType } from 'models/baseModels/Account/types'; import { BaseField, RawValue } from 'schemas/types'; @@ -18,6 +19,7 @@ export interface ReportRow { cells: ReportCell[]; level?: number; isGroup?: boolean; + isEmpty?: boolean; folded?: boolean; foldedBelow?: boolean; } @@ -68,3 +70,39 @@ export interface LedgerEntry { } export type GroupedMap = Map; + +export type DateRange = { fromDate: DateTime; toDate: DateTime }; +export type ValueMap = Map; + +export interface Account { + name: string; + rootType: AccountRootType; + isGroup: boolean; + parentAccount: string | null; +} + +export type AccountTree = Record; +export interface AccountTreeNode extends Account { + children?: AccountTreeNode[]; + valueMap?: ValueMap; + prune?: boolean; +} + +export type AccountList = AccountListNode[]; +export interface AccountListNode extends Account { + valueMap?: ValueMap; + level?: number; +} + +export type AccountNameValueMapMap = Map; +export type BasedOn = 'Fiscal Year' | 'Until Date'; + + +export interface TreeNode { + name: string; + children?: TreeNode[]; +} + +export type Tree = Record; + + diff --git a/src/utils/sidebarConfig.ts b/src/utils/sidebarConfig.ts index 48dc83fc..6deacba3 100644 --- a/src/utils/sidebarConfig.ts +++ b/src/utils/sidebarConfig.ts @@ -148,12 +148,12 @@ function getCompleteSidebar(): SidebarConfig { name: 'profit-and-loss', route: '/report/ProfitAndLoss', }, - /* { label: t`Balance Sheet`, name: 'balance-sheet', - route: '/report/balance-sheet', + route: '/report/BalanceSheet', }, + /* { label: t`Trial Balance`, name: 'trial-balance',