oss file updated, profitl loss as per the day month and also with date select
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derives all seven General Ledger reports from the posting ledger.
|
||||
*
|
||||
* Each report is a pure function of (postings, date range, branch), so changing
|
||||
* the day / month / branch filter genuinely recomputes the figures. Results are
|
||||
* memoised per filter key because the ledger runs to roughly twenty thousand
|
||||
* postings and Angular re-evaluates template getters on every change detection.
|
||||
*/
|
||||
|
||||
import {
|
||||
GL_ACCOUNTS,
|
||||
GL_PERIOD,
|
||||
GlAccount,
|
||||
GlPosting,
|
||||
glAccount,
|
||||
glPostings
|
||||
} from './gl-reports-data';
|
||||
|
||||
export interface GlDateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface TrialBalanceRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
openingDebit: number;
|
||||
openingCredit: number;
|
||||
periodDebit: number;
|
||||
periodCredit: number;
|
||||
closingDebit: number;
|
||||
closingCredit: number;
|
||||
}
|
||||
|
||||
export interface PnlRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface BalanceSheetRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface ComparativeRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
side: 'ASSET' | 'LIABILITY';
|
||||
current: number;
|
||||
previous: number;
|
||||
variance: number;
|
||||
variancePct: number | null;
|
||||
}
|
||||
|
||||
export interface GlWiseRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
type: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
net: number;
|
||||
vouchers: number;
|
||||
}
|
||||
|
||||
export interface MiscExpenseRow {
|
||||
date: string;
|
||||
voucherNo: string;
|
||||
branchCode: string;
|
||||
code: string;
|
||||
name: string;
|
||||
narration: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** Natural side of an account — assets and expenses sit on the debit side. */
|
||||
function isDebitSide(account: GlAccount): boolean {
|
||||
return account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
|
||||
}
|
||||
|
||||
function matchesBranch(p: GlPosting, branchCode: string): boolean {
|
||||
return !branchCode || p.branchCode === branchCode;
|
||||
}
|
||||
|
||||
/** Signed balance on the account's natural side, for postings up to and including `to`. */
|
||||
function cumulativeBalances(to: string, branchCode: string): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
for (const p of glPostings()) {
|
||||
if (p.date > to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
const account = glAccount(p.accountCode);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
|
||||
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Debit and credit turnover strictly inside the range. */
|
||||
function periodTurnover(range: GlDateRange, branchCode: string): Map<string, { debit: number; credit: number; vouchers: Set<string> }> {
|
||||
const out = new Map<string, { debit: number; credit: number; vouchers: Set<string> }>();
|
||||
for (const p of glPostings()) {
|
||||
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
let entry = out.get(p.accountCode);
|
||||
if (!entry) {
|
||||
entry = { debit: 0, credit: 0, vouchers: new Set<string>() };
|
||||
out.set(p.accountCode, entry);
|
||||
}
|
||||
entry.debit += p.debit;
|
||||
entry.credit += p.credit;
|
||||
entry.vouchers.add(p.voucherNo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The day before `date`, ISO formatted — used for opening balances. */
|
||||
function previousDay(date: string): string {
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ── Reports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function trialBalance(range: GlDateRange, branchCode: string): TrialBalanceRow[] {
|
||||
const opening = cumulativeBalances(previousDay(range.from), branchCode);
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const rows: TrialBalanceRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
// Contingent accounts are memoranda and never appear in the trial balance.
|
||||
if (account.type === 'CONTINGENT') {
|
||||
continue;
|
||||
}
|
||||
const open = opening.get(account.code) ?? 0;
|
||||
const move = turnover.get(account.code);
|
||||
const periodDebit = move?.debit ?? 0;
|
||||
const periodCredit = move?.credit ?? 0;
|
||||
if (!open && !periodDebit && !periodCredit) {
|
||||
continue;
|
||||
}
|
||||
const debitSide = isDebitSide(account);
|
||||
const closingNatural = open + (debitSide ? periodDebit - periodCredit : periodCredit - periodDebit);
|
||||
const closingSigned = debitSide ? closingNatural : -closingNatural;
|
||||
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
openingDebit: debitSide && open > 0 ? open : !debitSide && open < 0 ? -open : 0,
|
||||
openingCredit: !debitSide && open > 0 ? open : debitSide && open < 0 ? -open : 0,
|
||||
periodDebit,
|
||||
periodCredit,
|
||||
closingDebit: closingSigned > 0 ? closingSigned : 0,
|
||||
closingCredit: closingSigned < 0 ? -closingSigned : 0
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function profitAndLoss(range: GlDateRange, branchCode: string): { income: PnlRow[]; expense: PnlRow[] } {
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const income: PnlRow[] = [];
|
||||
const expense: PnlRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
if (account.type !== 'INCOME' && account.type !== 'EXPENSE') {
|
||||
continue;
|
||||
}
|
||||
const move = turnover.get(account.code);
|
||||
if (!move) {
|
||||
continue;
|
||||
}
|
||||
const amount =
|
||||
account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
const row: PnlRow = { code: account.code, name: account.name, group: account.group, amount };
|
||||
(account.type === 'INCOME' ? income : expense).push(row);
|
||||
}
|
||||
return { income, expense };
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance sheet as at the period end. The current-period surplus is folded in as
|
||||
* a liability line so the two sides tally the way the printed statement does.
|
||||
*/
|
||||
export function balanceSheet(range: GlDateRange, branchCode: string): {
|
||||
assets: BalanceSheetRow[];
|
||||
liabilities: BalanceSheetRow[];
|
||||
surplus: number;
|
||||
} {
|
||||
const closing = cumulativeBalances(range.to, branchCode);
|
||||
const assets: BalanceSheetRow[] = [];
|
||||
const liabilities: BalanceSheetRow[] = [];
|
||||
let income = 0;
|
||||
let expense = 0;
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
const bal = closing.get(account.code) ?? 0;
|
||||
if (account.type === 'INCOME') {
|
||||
income += bal;
|
||||
continue;
|
||||
}
|
||||
if (account.type === 'EXPENSE') {
|
||||
expense += bal;
|
||||
continue;
|
||||
}
|
||||
if (account.type === 'CONTINGENT' || !bal) {
|
||||
continue;
|
||||
}
|
||||
const row: BalanceSheetRow = { code: account.code, name: account.name, group: account.group, amount: bal };
|
||||
(account.type === 'ASSET' ? assets : liabilities).push(row);
|
||||
}
|
||||
return { assets, liabilities, surplus: income - expense };
|
||||
}
|
||||
|
||||
/** Current period against the equivalent span immediately before it. */
|
||||
export function comparativeBalanceSheet(range: GlDateRange, branchCode: string): ComparativeRow[] {
|
||||
const priorTo = previousDay(range.from);
|
||||
const current = cumulativeBalances(range.to, branchCode);
|
||||
const previous = cumulativeBalances(priorTo, branchCode);
|
||||
const rows: ComparativeRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
if (account.type !== 'ASSET' && account.type !== 'LIABILITY') {
|
||||
continue;
|
||||
}
|
||||
const cur = current.get(account.code) ?? 0;
|
||||
const prev = previous.get(account.code) ?? 0;
|
||||
if (!cur && !prev) {
|
||||
continue;
|
||||
}
|
||||
const variance = cur - prev;
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
side: account.type,
|
||||
current: cur,
|
||||
previous: prev,
|
||||
variance,
|
||||
variancePct: prev ? (variance / Math.abs(prev)) * 100 : null
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function contingentLiabilities(range: GlDateRange, branchCode: string): BalanceSheetRow[] {
|
||||
const closing = cumulativeBalances(range.to, branchCode);
|
||||
return GL_ACCOUNTS.filter((a) => a.type === 'CONTINGENT')
|
||||
.map((a) => ({ code: a.code, name: a.name, group: a.group, amount: closing.get(a.code) ?? 0 }))
|
||||
.filter((r) => r.amount !== 0);
|
||||
}
|
||||
|
||||
export function glWiseDrCr(range: GlDateRange, branchCode: string): GlWiseRow[] {
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const rows: GlWiseRow[] = [];
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
const move = turnover.get(account.code);
|
||||
if (!move || (!move.debit && !move.credit)) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
type: account.type,
|
||||
debit: move.debit,
|
||||
credit: move.credit,
|
||||
net: move.debit - move.credit,
|
||||
vouchers: move.vouchers.size
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscExpenseRow[] {
|
||||
const miscCodes = new Set(GL_ACCOUNTS.filter((a) => a.miscExpense).map((a) => a.code));
|
||||
const rows: MiscExpenseRow[] = [];
|
||||
for (const p of glPostings()) {
|
||||
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
if (!miscCodes.has(p.accountCode) || !p.debit) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
date: p.date,
|
||||
voucherNo: p.voucherNo,
|
||||
branchCode: p.branchCode,
|
||||
code: p.accountCode,
|
||||
name: glAccount(p.accountCode)?.name ?? p.accountCode,
|
||||
narration: p.narration,
|
||||
amount: p.debit
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => (a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)));
|
||||
}
|
||||
|
||||
// ── Memoisation ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GlReportSet {
|
||||
trialBalance: TrialBalanceRow[];
|
||||
pnl: { income: PnlRow[]; expense: PnlRow[] };
|
||||
balanceSheet: { assets: BalanceSheetRow[]; liabilities: BalanceSheetRow[]; surplus: number };
|
||||
comparative: ComparativeRow[];
|
||||
contingent: BalanceSheetRow[];
|
||||
glWise: GlWiseRow[];
|
||||
miscExpense: MiscExpenseRow[];
|
||||
}
|
||||
|
||||
let cacheKey = '';
|
||||
let cached: GlReportSet | null = null;
|
||||
|
||||
/** All seven reports for one filter selection, computed once per selection. */
|
||||
export function buildReports(range: GlDateRange, branchCode: string): GlReportSet {
|
||||
const key = `${range.from}|${range.to}|${branchCode}`;
|
||||
if (cached && cacheKey === key) {
|
||||
return cached;
|
||||
}
|
||||
cached = {
|
||||
trialBalance: trialBalance(range, branchCode),
|
||||
pnl: profitAndLoss(range, branchCode),
|
||||
balanceSheet: balanceSheet(range, branchCode),
|
||||
comparative: comparativeBalanceSheet(range, branchCode),
|
||||
contingent: contingentLiabilities(range, branchCode),
|
||||
glWise: glWiseDrCr(range, branchCode),
|
||||
miscExpense: miscExpenseLedger(range, branchCode)
|
||||
};
|
||||
cacheKey = key;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Clamp a range to the generated ledger so filters cannot query empty space. */
|
||||
export function clampToLedger(range: GlDateRange): GlDateRange {
|
||||
return {
|
||||
from: range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from,
|
||||
to: range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to
|
||||
};
|
||||
}
|
||||
|
||||
export function sum<T>(rows: T[], pick: (row: T) => number): number {
|
||||
return rows.reduce((acc, r) => acc + pick(r), 0);
|
||||
}
|
||||
|
||||
/** Groups rows by their `group` field, preserving chart-of-accounts order. */
|
||||
export function groupBy<T extends { group: string }>(rows: T[]): { group: string; rows: T[]; total: number }[] {
|
||||
const order: string[] = [];
|
||||
const map = new Map<string, T[]>();
|
||||
for (const r of rows) {
|
||||
if (!map.has(r.group)) {
|
||||
map.set(r.group, []);
|
||||
order.push(r.group);
|
||||
}
|
||||
map.get(r.group)!.push(r);
|
||||
}
|
||||
return order.map((g) => ({ group: g, rows: map.get(g)!, total: 0 }));
|
||||
}
|
||||
Reference in New Issue
Block a user