diff --git a/META.md b/META.md index f0fd65be..df58d2c7 100644 --- a/META.md +++ b/META.md @@ -51,7 +51,7 @@ individual ones, check the `README.md` in those subdirectories: | `build` | _server_ | Build specific files not used unless building the project | | `translations` | _server_ | Collection of csv files containing translations | | `src` | _client_ | Code that mainly deals with the view layer (all `.vue` are stored here) | -| `reports` | _client_ | Collection of logic code and view layer config files for displaying reports. | +| `reports` | _client\*_ | Collection of logic code and view layer config files for displaying reports. | | `models` | _client\*_ | Collection of `Model.ts` files that manage the data and some business logic on the client side. | | `fyo` | _client\*_ | Code for the underlying library that manages the client side | | `utils` | _agnostic_ | Collection of code used by either sides. | diff --git a/fyo/core/dbHandler.ts b/fyo/core/dbHandler.ts index 87c1bc5f..9a725684 100644 --- a/fyo/core/dbHandler.ts +++ b/fyo/core/dbHandler.ts @@ -122,7 +122,7 @@ export class DatabaseHandler extends DatabaseBase { async getAllRaw( schemaName: string, options: GetAllOptions = {} - ): Promise { + ): Promise { const all = await this.#getAll(schemaName, options); this.observer.trigger(`getAllRaw:${schemaName}`, options); return all; diff --git a/reports/GeneralLedger/GeneralLedger.js b/reports/GeneralLedger/GeneralLedger.js deleted file mode 100644 index 35cee083..00000000 --- a/reports/GeneralLedger/GeneralLedger.js +++ /dev/null @@ -1,97 +0,0 @@ -import { fyo } from 'src/initFyo'; - -class GeneralLedger { - async run(params) { - const filters = {}; - if (params.account) filters.account = params.account; - if (params.party) filters.party = params.party; - if (params.referenceType !== 'All') - filters.referenceType = params.referenceType; - if (params.referenceName) filters.referenceName = params.referenceName; - if (params.toDate || params.fromDate) { - filters.date = []; - if (params.toDate) filters.date.push('<=', params.toDate); - if (params.fromDate) filters.date.push('>=', params.fromDate); - } - - let data = ( - await fyo.db.getAll({ - doctype: 'AccountingLedgerEntry', - fields: [ - 'date', - 'account', - 'party', - 'referenceType', - 'referenceName', - 'debit', - 'credit', - 'reverted', - ], - filters: filters, - }) - ) - .filter((d) => !d.reverted || (d.reverted && params.reverted)) - .map((row) => { - row.debit = row.debit.float; - row.credit = row.credit.float; - - return row; - }); - - return this.appendOpeningEntry(data); - } - - appendOpeningEntry(data) { - let glEntries = []; - let balance = 0, - debitTotal = 0, - creditTotal = 0; - - glEntries.push({ - date: '', - account: { template: 'Opening' }, - party: '', - debit: 0, - credit: 0, - balance: 0, - referenceType: '', - referenceName: '', - }); - for (let entry of data) { - balance += entry.debit > 0 ? entry.debit : -entry.credit; - debitTotal += entry.debit; - creditTotal += entry.credit; - entry.balance = balance; - if (entry.debit === 0) { - entry.debit = ''; - } - if (entry.credit === 0) { - entry.credit = ''; - } - glEntries.push(entry); - } - glEntries.push({ - date: '', - account: { template: 'Total' }, - party: '', - debit: debitTotal, - credit: creditTotal, - balance: balance, - referenceType: '', - referenceName: '', - }); - glEntries.push({ - date: '', - account: { template: 'Closing' }, - party: '', - debit: debitTotal, - credit: creditTotal, - balance: balance, - referenceType: '', - referenceName: '', - }); - return glEntries; - } -} - -export default GeneralLedger; diff --git a/reports/GeneralLedger/GeneralLedger.ts b/reports/GeneralLedger/GeneralLedger.ts new file mode 100644 index 00000000..0ffaac7f --- /dev/null +++ b/reports/GeneralLedger/GeneralLedger.ts @@ -0,0 +1,484 @@ +import { Fyo, t } from 'fyo'; +import { Action } from 'fyo/model/types'; +import { isPesa } from 'fyo/utils'; +import { DateTime } from 'luxon'; +import { ModelNameEnum } from 'models/types'; +import Money from 'pesa/dist/types/src/money'; +import { Report } from 'reports/Report'; +import { ColumnField, ReportData } from 'reports/types'; +import { Field, FieldTypeEnum, RawValue } from 'schemas/types'; +import { QueryFilter } from 'utils/db/types'; + +interface RawLedgerEntry { + name: string; + account: string; + date: string; + debit: string; + credit: string; + referenceType: string; + referenceName: string; + party: string; + reverted: number; + reverts: string; + [key: string]: RawValue; +} + +interface LedgerEntry { + name: number; + account: string; + date: Date | null; + debit: Money | null; + credit: Money | null; + balance: Money | null; + referenceType: string; + referenceName: string; + party: string; + reverted: boolean; + reverts: string; +} + +type GroupedMap = Map; + +export class GeneralLedger extends Report { + static title = t`General Ledger`; + static reportName = 'general-ledger'; + + ascending!: boolean; + groupBy!: 'none' | 'party' | 'account' | 'referenceName'; + _rawData: LedgerEntry[] = []; + + constructor(fyo: Fyo) { + super(fyo); + + if (!this.toField) { + this.toField = DateTime.now().toISODate(); + this.fromField = DateTime.now().minus({ years: 1 }).toISODate(); + } + } + + async setReportData(filter?: string) { + if (filter !== 'grouped' || this._rawData.length === 0) { + console.time('_setRawData'); + await this._setRawData(); + console.timeEnd('_setRawData'); + } + + console.time('_getGroupedMap'); + const map = this._getGroupedMap(); + console.timeEnd('_getGroupedMap'); + + console.time('_getTotalsAndSetBalance'); + const { totalDebit, totalCredit } = this._getTotalsAndSetBalance(map); + console.timeEnd('_getTotalsAndSetBalance'); + + console.time('_consolidateEntries'); + const consolidated = this._consolidateEntries(map); + console.timeEnd('_consolidateEntries'); + + /** + * Push a blank row if last row isn't blank + */ + if (consolidated.at(-1)!.name !== -3) { + this._pushBlankEntry(consolidated); + } + + /** + * Set the closing row + */ + consolidated.push({ + name: -2, // Bold + account: t`Closing`, + date: null, + debit: totalDebit, + credit: totalCredit, + balance: totalDebit.sub(totalCredit), + referenceType: '', + referenceName: '', + party: '', + reverted: false, + reverts: '', + }); + + console.time('_convertEntriesToReportData'); + this.reportData = this._convertEntriesToReportData(consolidated); + console.timeEnd('_convertEntriesToReportData'); + } + + _convertEntriesToReportData(entries: LedgerEntry[]): ReportData { + const reportData = []; + const fieldnames = this.columns.map((f) => f.fieldname); + for (const entry of entries) { + const row = this._getRowFromEntry(entry, fieldnames); + reportData.push(row); + } + + return reportData; + } + + _getRowFromEntry(entry: LedgerEntry, fieldnames: string[]) { + if (entry.name === -3) { + return Array(fieldnames.length).fill({ value: '' }); + } + + const row = []; + for (const n of fieldnames) { + let value = entry[n as keyof LedgerEntry]; + if (value === null || value === undefined) { + row.push({ value: '' }); + continue; + } + + let align = 'left'; + if (value instanceof Date) { + value = this.fyo.format(value, FieldTypeEnum.Date); + } + + if (isPesa(value)) { + align = 'right'; + value = this.fyo.format(value, FieldTypeEnum.Currency); + } + + if (typeof value === 'boolean' && n === 'reverted' && value) { + value = t`Reverted`; + } + + row.push({ + italics: entry.name === -1, + bold: entry.name === -2, + value, + align, + }); + } + + return row; + } + + _consolidateEntries(map: GroupedMap) { + const entries: LedgerEntry[] = []; + for (const key of map.keys()) { + entries.push(...map.get(key)!); + + /** + * Add blank row for spacing if groupBy + */ + if (this.groupBy !== 'none') { + this._pushBlankEntry(entries); + } + } + + return entries; + } + + _pushBlankEntry(entries: LedgerEntry[]) { + entries.push({ + name: -3, // Empty + account: '', + date: null, + debit: null, + credit: null, + balance: null, + referenceType: '', + referenceName: '', + party: '', + reverted: false, + reverts: '', + }); + } + + _getTotalsAndSetBalance(map: GroupedMap) { + let totalDebit = this.fyo.pesa(0); + let totalCredit = this.fyo.pesa(0); + + for (const key of map.keys()) { + let balance = this.fyo.pesa(0); + let debit = this.fyo.pesa(0); + let credit = this.fyo.pesa(0); + + for (const entry of map.get(key)!) { + debit = debit.add(entry.debit!); + credit = credit.add(entry.credit!); + + const diff = entry.debit!.sub(entry.credit!); + balance = balance.add(diff); + entry.balance = balance; + } + + /** + * Total row incase groupBy is used + */ + if (this.groupBy !== 'none') { + map.get(key)?.push({ + name: -1, // Italics + account: t`Total`, + date: null, + debit, + credit, + balance: debit.sub(credit), + referenceType: '', + referenceName: '', + party: '', + reverted: false, + reverts: '', + }); + } + + /** + * Total debit and credit for the final row + */ + totalDebit = totalDebit.add(debit); + totalCredit = totalCredit.add(credit); + } + + return { totalDebit, totalCredit }; + } + + _getGroupedMap(): GroupedMap { + let groupBy: keyof LedgerEntry = 'referenceName'; + if (this.groupBy !== 'none') { + groupBy = this.groupBy; + } + + /** + * Sort rows by ascending or descending + */ + this._rawData.sort((a, b) => { + if (this.ascending) { + return a.name - b.name; + } + + return b.name - a.name; + }); + + /** + * Map remembers the order of insertion + * ∴ presorting maintains grouping order + */ + const map: GroupedMap = new Map(); + for (const entry of this._rawData) { + const groupingKey = entry[groupBy]; + if (!map.has(groupingKey)) { + map.set(groupingKey, []); + } + + map.get(groupingKey)!.push(entry); + } + + return map; + } + + async _setRawData() { + const fields = [ + 'name', + 'account', + 'date', + 'debit', + 'credit', + 'referenceType', + 'referenceName', + 'party', + 'reverted', + 'reverts', + ]; + + const filters = this._getFilters(); + const entries = (await this.fyo.db.getAllRaw( + ModelNameEnum.AccountingLedgerEntry, + { + fields, + filters, + } + )) as RawLedgerEntry[]; + + this._rawData = entries.map((entry) => { + return { + name: parseInt(entry.name), + account: entry.account, + date: new Date(entry.date), + debit: this.fyo.pesa(entry.debit), + credit: this.fyo.pesa(entry.credit), + balance: this.fyo.pesa(0), + referenceType: entry.referenceType, + referenceName: entry.referenceName, + party: entry.party, + reverted: Boolean(entry.reverted), + reverts: entry.reverts, + } as LedgerEntry; + }); + } + + _getFilters(): QueryFilter { + const filters: QueryFilter = {}; + const stringFilters = ['account', 'party', 'referenceName']; + + for (const sf in stringFilters) { + const value = this[sf]; + if (value === undefined) { + continue; + } + + filters[sf] = value as string; + } + + if (this.referenceType !== 'All') { + filters.referenceType = this.referenceType as string; + } + + if (this.toDate) { + filters.date ??= []; + (filters.date as string[]).push('<=', this.toDate as string); + } + + if (this.fromDate) { + filters.date ??= []; + (filters.date as string[]).push('>=', this.fromDate as string); + } + + if (!this.reverted) { + filters.reverted = false; + } + + return filters; + } + + getFilters() { + return [ + { + fieldtype: 'Select', + options: [ + { label: t`All`, value: 'All' }, + { label: t`Sales Invoices`, value: 'SalesInvoice' }, + { label: t`Purchase Invoices`, value: 'PurchaseInvoice' }, + { label: t`Payments`, value: 'Payment' }, + { label: t`Journal Entries`, value: 'JournalEntry' }, + ], + + label: t`Reference Type`, + fieldname: 'referenceType', + placeholder: t`Reference Type`, + default: 'All', + }, + { + fieldtype: 'DynamicLink', + placeholder: t`Reference Name`, + references: 'referenceType', + label: t`Reference Name`, + fieldname: 'referenceName', + }, + { + fieldtype: 'Link', + target: 'Account', + placeholder: t`Account`, + label: t`Account`, + fieldname: 'account', + }, + { + fieldtype: 'Link', + target: 'Party', + label: t`Party`, + placeholder: t`Party`, + fieldname: 'party', + }, + { + fieldtype: 'Date', + placeholder: t`From Date`, + label: t`From Date`, + fieldname: 'fromDate', + }, + { + fieldtype: 'Date', + placeholder: t`To Date`, + label: t`To Date`, + fieldname: 'toDate', + }, + { + fieldtype: 'Check', + default: false, + label: t`Cancelled`, + fieldname: 'reverted', + }, + { + fieldtype: 'Check', + default: false, + label: t`Ascending`, + fieldname: 'ascending', + }, + { + fieldtype: 'Check', + default: 'none', + label: t`Group By`, + fieldname: 'groupBy', + options: [ + { label: t`None`, value: 'none' }, + { label: t`Party`, value: 'party' }, + { label: t`Account`, value: 'account' }, + { label: t`Reference`, value: 'referenceName' }, + ], + }, + ] as Field[]; + } + + getColumns(): ColumnField[] { + let columns = [ + { + label: t`Account`, + fieldtype: 'Link', + fieldname: 'account', + width: 1.5, + }, + { + label: t`Date`, + fieldtype: 'Date', + fieldname: 'date', + width: 0.75, + }, + { + label: t`Debit`, + fieldtype: 'Currency', + fieldname: 'debit', + width: 1.25, + }, + { + label: t`Credit`, + fieldtype: 'Currency', + fieldname: 'credit', + width: 1.25, + }, + { + label: t`Balance`, + fieldtype: 'Currency', + fieldname: 'balance', + width: 1.25, + }, + { + label: t`Reference Type`, + fieldtype: 'Data', + fieldname: 'referenceType', + }, + { + label: t`Reference Name`, + fieldtype: 'Data', + fieldname: 'referenceName', + }, + { + label: t`Party`, + fieldtype: 'Link', + fieldname: 'party', + }, + { + label: t`Reverted`, + fieldtype: 'Check', + fieldname: 'reverted', + }, + ] as ColumnField[]; + + if (!this.reverted) { + columns = columns.filter((f) => f.fieldname !== 'reverted'); + } + + return columns; + } + + getActions(): Action[] { + return []; + } +} diff --git a/reports/GeneralLedger/viewConfig.js b/reports/GeneralLedger/viewConfig.js deleted file mode 100644 index 09adff14..00000000 --- a/reports/GeneralLedger/viewConfig.js +++ /dev/null @@ -1,153 +0,0 @@ -import { t } from 'fyo'; -import Avatar from 'src/components/Avatar.vue'; -import { fyo } from 'src/initFyo'; -import getCommonExportActions from '../commonExporter'; - -export function getPartyWithAvatar(partyName) { - return { - data() { - return { - imageURL: null, - label: null, - }; - }, - components: { - Avatar, - }, - async mounted() { - const p = await fyo.db.get('Party', partyName); - this.imageURL = p.image; - this.label = partyName; - }, - template: ` -
- - {{ label }} -
- `, - }; -} - -let title = t`General Ledger`; - -const viewConfig = { - title, - filterFields: [ - { - fieldtype: 'Select', - options: [ - { label: t`All References`, value: 'All' }, - { label: t`Invoices`, value: 'SalesInvoice' }, - { label: t`Bills`, value: 'PurchaseInvoice' }, - { label: t`Payment`, value: 'Payment' }, - { label: t`Journal Entry`, value: 'JournalEntry' }, - ], - size: 'small', - label: t`Reference Type`, - fieldname: 'referenceType', - placeholder: t`Reference Type`, - default: 'All', - }, - { - fieldtype: 'DynamicLink', - size: 'small', - placeholder: t`Reference Name`, - references: 'referenceType', - label: t`Reference Name`, - fieldname: 'referenceName', - }, - { - fieldtype: 'Link', - target: 'Account', - size: 'small', - placeholder: t`Account`, - label: t`Account`, - fieldname: 'account', - }, - { - fieldtype: 'Link', - target: 'Party', - label: t`Party`, - size: 'small', - placeholder: t`Party`, - fieldname: 'party', - }, - { - fieldtype: 'Date', - size: 'small', - placeholder: t`From Date`, - label: t`From Date`, - fieldname: 'fromDate', - }, - { - fieldtype: 'Date', - size: 'small', - placeholder: t`To Date`, - label: t`To Date`, - fieldname: 'toDate', - }, - { - fieldtype: 'Check', - size: 'small', - default: 0, - label: t`Cancelled`, - fieldname: 'reverted', - }, - ], - method: 'general-ledger', - actions: getCommonExportActions('general-ledger'), - getColumns() { - return [ - { - label: t`Account`, - fieldtype: 'Link', - fieldname: 'account', - width: 1.5, - }, - { - label: t`Date`, - fieldtype: 'Date', - fieldname: 'date', - width: 0.75, - }, - { - label: t`Debit`, - fieldtype: 'Currency', - fieldname: 'debit', - width: 1.25, - }, - { - label: t`Credit`, - fieldtype: 'Currency', - fieldname: 'credit', - width: 1.25, - }, - { - label: t`Balance`, - fieldtype: 'Currency', - fieldname: 'balance', - width: 1.25, - }, - { - label: t`Reference Type`, - fieldtype: 'Data', - fieldname: 'referenceType', - }, - { - label: t`Reference Name`, - fieldtype: 'Data', - fieldname: 'referenceName', - }, - { - label: t`Party`, - fieldtype: 'Link', - fieldname: 'party', - component(cellValue) { - return getPartyWithAvatar(cellValue); - }, - }, - ]; - }, -}; - -export default viewConfig; diff --git a/reports/README.md b/reports/README.md new file mode 100644 index 00000000..483d3009 --- /dev/null +++ b/reports/README.md @@ -0,0 +1,6 @@ +# Reports + +Reports are a view of stored data, the code here doesn't alter any data. + +All reports should extend the `Report` class in `reports/Report.ts`, depending +on the report it may have custom `.vue` files. diff --git a/reports/Report.ts b/reports/Report.ts new file mode 100644 index 00000000..6fada474 --- /dev/null +++ b/reports/Report.ts @@ -0,0 +1,72 @@ +import { Fyo } from 'fyo'; +import { Action } from 'fyo/model/types'; +import Observable from 'fyo/utils/observable'; +import { Field, RawValue } from 'schemas/types'; +import { getIsNullOrUndef } from 'utils'; +import { ColumnField, ReportData } from './types'; + +export abstract class Report extends Observable { + static title: string; + static reportName: string; + + fyo: Fyo; + columns: ColumnField[]; + filters: Field[]; + reportData: ReportData; + + abstract getActions(): Action[]; + abstract getFilters(): Field[]; + + constructor(fyo: Fyo) { + super(); + this.fyo = fyo; + this.reportData = []; + this.filters = this.getFilters(); + this.columns = this.getColumns(); + this.initializeFilters(); + } + + get filterMap() { + const filterMap: Record = {}; + for (const { fieldname } of this.filters) { + const value = this.get(fieldname); + if (getIsNullOrUndef(value)) { + continue; + } + + filterMap[fieldname] = value; + } + + return filterMap; + } + + async set(key: string, value: RawValue) { + const field = this.filters.find((f) => f.fieldname === key); + if (field === undefined || value === undefined) { + return; + } + + const prevValue = this[key]; + if (prevValue === value) { + return; + } + + this[key] = value; + this.columns = this.getColumns(); + await this.setReportData(key); + } + + initializeFilters() { + for (const field of this.filters) { + if (!field.default) { + this[field.fieldname] = undefined; + continue; + } + + this[field.fieldname] = field.default; + } + } + + abstract getColumns(): ColumnField[]; + abstract setReportData(filter?: string): Promise; +} diff --git a/reports/index.ts b/reports/index.ts new file mode 100644 index 00000000..d642dcc8 --- /dev/null +++ b/reports/index.ts @@ -0,0 +1,2 @@ +import { GeneralLedger } from './GeneralLedger/GeneralLedger'; +export { GeneralLedger }; diff --git a/reports/types.ts b/reports/types.ts index 830c73ef..8c85141e 100644 --- a/reports/types.ts +++ b/reports/types.ts @@ -1,14 +1,19 @@ import { AccountRootType } from 'models/baseModels/Account/types'; +import { BaseField } from 'schemas/types'; export type ExportExtension = 'csv' | 'json'; -export interface ReportData { - rows: unknown[]; - columns: unknown[]; +export interface ReportCell { + bold?: boolean; + italics?: boolean; + align?: 'left' | 'right' | 'center'; + value: string; } -export abstract class Report { - abstract run(filters: Record): ReportData; +export type ReportRow = ReportCell[]; +export type ReportData = ReportRow[]; +export interface ColumnField extends BaseField { + width?: number; } export type BalanceType = 'Credit' | 'Debit'; diff --git a/src/renderer.ts b/src/renderer.ts index ed958626..a7cbcb6c 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from 'electron'; import { ConfigKeys } from 'fyo/core/types'; -import { DateTime } from 'luxon'; +import { GeneralLedger } from 'reports'; import { IPC_ACTIONS } from 'utils/messages'; import { App as VueApp, createApp } from 'vue'; import App from './App.vue'; @@ -108,4 +108,4 @@ function setOnWindow() { } // @ts-ignore -window.DateTime = DateTime; +window.GL = GeneralLedger;