2
0
mirror of https://github.com/frappe/books.git synced 2024-12-22 10:58:59 +00:00

incr: add GeneralLedger business logic

This commit is contained in:
18alantom 2022-05-12 15:34:37 +05:30
parent 86c4889959
commit 1e88c5511f
10 changed files with 578 additions and 259 deletions

View File

@ -51,7 +51,7 @@ individual ones, check the `README.md` in those subdirectories:
| `build` | _server_ | Build specific files not used unless building the project | | `build` | _server_ | Build specific files not used unless building the project |
| `translations` | _server_ | Collection of csv files containing translations | | `translations` | _server_ | Collection of csv files containing translations |
| `src` | _client_ | Code that mainly deals with the view layer (all `.vue` are stored here) | | `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. | | `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 | | `fyo` | _client\*_ | Code for the underlying library that manages the client side |
| `utils` | _agnostic_ | Collection of code used by either sides. | | `utils` | _agnostic_ | Collection of code used by either sides. |

View File

@ -122,7 +122,7 @@ export class DatabaseHandler extends DatabaseBase {
async getAllRaw( async getAllRaw(
schemaName: string, schemaName: string,
options: GetAllOptions = {} options: GetAllOptions = {}
): Promise<DocValueMap[]> { ): Promise<RawValueMap[]> {
const all = await this.#getAll(schemaName, options); const all = await this.#getAll(schemaName, options);
this.observer.trigger(`getAllRaw:${schemaName}`, options); this.observer.trigger(`getAllRaw:${schemaName}`, options);
return all; return all;

View File

@ -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: '<b>Opening</b>' },
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: '<b>Total</b>' },
party: '',
debit: debitTotal,
credit: creditTotal,
balance: balance,
referenceType: '',
referenceName: '',
});
glEntries.push({
date: '',
account: { template: '<b>Closing</b>' },
party: '',
debit: debitTotal,
credit: creditTotal,
balance: balance,
referenceType: '',
referenceName: '',
});
return glEntries;
}
}
export default GeneralLedger;

View File

@ -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<string, LedgerEntry[]>;
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 [];
}
}

View File

@ -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: `
<div class="flex items-center" v-if="label">
<Avatar class="flex-shrink-0" :imageURL="imageURL" :label="label" size="sm" />
<span class="ml-2 truncate">{{ label }}</span>
</div>
`,
};
}
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;

6
reports/README.md Normal file
View File

@ -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.

72
reports/Report.ts Normal file
View File

@ -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<RawValue> {
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<string, RawValue> = {};
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<void>;
}

2
reports/index.ts Normal file
View File

@ -0,0 +1,2 @@
import { GeneralLedger } from './GeneralLedger/GeneralLedger';
export { GeneralLedger };

View File

@ -1,14 +1,19 @@
import { AccountRootType } from 'models/baseModels/Account/types'; import { AccountRootType } from 'models/baseModels/Account/types';
import { BaseField } from 'schemas/types';
export type ExportExtension = 'csv' | 'json'; export type ExportExtension = 'csv' | 'json';
export interface ReportData { export interface ReportCell {
rows: unknown[]; bold?: boolean;
columns: unknown[]; italics?: boolean;
align?: 'left' | 'right' | 'center';
value: string;
} }
export abstract class Report { export type ReportRow = ReportCell[];
abstract run(filters: Record<string, unknown>): ReportData; export type ReportData = ReportRow[];
export interface ColumnField extends BaseField {
width?: number;
} }
export type BalanceType = 'Credit' | 'Debit'; export type BalanceType = 'Credit' | 'Debit';

View File

@ -1,6 +1,6 @@
import { ipcRenderer } from 'electron'; import { ipcRenderer } from 'electron';
import { ConfigKeys } from 'fyo/core/types'; import { ConfigKeys } from 'fyo/core/types';
import { DateTime } from 'luxon'; import { GeneralLedger } from 'reports';
import { IPC_ACTIONS } from 'utils/messages'; import { IPC_ACTIONS } from 'utils/messages';
import { App as VueApp, createApp } from 'vue'; import { App as VueApp, createApp } from 'vue';
import App from './App.vue'; import App from './App.vue';
@ -108,4 +108,4 @@ function setOnWindow() {
} }
// @ts-ignore // @ts-ignore
window.DateTime = DateTime; window.GL = GeneralLedger;