form 91/98 report, min max value update in AML rule and whihc rule apply day, week, month and some more updates

This commit is contained in:
2026-08-02 11:46:40 +05:30
parent 2a9df828ca
commit 3030e2f34e
37 changed files with 9292 additions and 620 deletions
+245 -18
View File
@@ -114,18 +114,84 @@ export const GL_ACCOUNTS: GlAccount[] = [
{ code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420105', name: 'Depreciation on Bank Property', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '430101', name: 'Printing & Stationery', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430102', name: 'Postage, Telegram & Telephone', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430103', name: 'Advertisement & Publicity', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430104', name: 'Repairs & Maintenance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430105', name: 'Travelling & Conveyance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430106', name: 'Computer & Software Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430107', name: 'Legal & Professional Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430108', name: 'Insurance (DICGC & Other)', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430109', name: 'Bank Charges & Commission Paid', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430110', name: 'AGM & Meeting Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{
code: '430101',
name: 'Printing & Stationery',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430102',
name: 'Postage, Telegram & Telephone',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430103',
name: 'Advertisement & Publicity',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430104',
name: 'Repairs & Maintenance',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430105',
name: 'Travelling & Conveyance',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430106',
name: 'Computer & Software Expenses',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430107',
name: 'Legal & Professional Charges',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430108',
name: 'Insurance (DICGC & Other)',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430109',
name: 'Bank Charges & Commission Paid',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430110',
name: 'AGM & Meeting Expenses',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{ code: '430111', name: 'Electricity Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430112', name: 'Other Miscellaneous Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{
code: '430112',
name: 'Other Miscellaneous Expenses',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
// ── Contingent (memoranda, not part of the balance sheet totals) ──────────
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
@@ -261,8 +327,20 @@ const VOUCHER_TEMPLATES: {
{ debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 },
{ debit: '110101', credit: '320103', narration: 'Loan processing fees', scale: 18_000, frequency: 0.5 },
{ debit: '110101', credit: '320105', narration: 'Miscellaneous income', scale: 7_500, frequency: 0.4 },
{ debit: '410101', credit: '230101', narration: 'Interest provided on savings deposits', scale: 128_000, frequency: 0.8 },
{ debit: '410102', credit: '230101', narration: 'Interest provided on term deposits', scale: 265_000, frequency: 0.9 },
{
debit: '410101',
credit: '230101',
narration: 'Interest provided on savings deposits',
scale: 128_000,
frequency: 0.8
},
{
debit: '410102',
credit: '230101',
narration: 'Interest provided on term deposits',
scale: 265_000,
frequency: 0.9
},
{ debit: '420101', credit: '110101', narration: 'Salaries and allowances paid', scale: 210_000, frequency: 0.35 },
{ debit: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 },
{ debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
@@ -275,14 +353,32 @@ const VOUCHER_TEMPLATES: {
{ debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 },
{ debit: '430111', credit: '110101', narration: 'Electricity charges', scale: 13_600, frequency: 0.25 },
{ debit: '430112', credit: '110101', narration: 'Other miscellaneous expenses', scale: 8_900, frequency: 0.3 },
{ debit: '110102', credit: '110101', narration: 'Cash remitted to RBI currency chest', scale: 1_800_000, frequency: 0.2 },
{ debit: '110101', credit: '110103', narration: 'Funds drawn from correspondent bank', scale: 1_400_000, frequency: 0.2 }
{
debit: '110102',
credit: '110101',
narration: 'Cash remitted to RBI currency chest',
scale: 1_800_000,
frequency: 0.2
},
{
debit: '110101',
credit: '110103',
narration: 'Funds drawn from correspondent bank',
scale: 1_400_000,
frequency: 0.2
}
];
/** Head-Office-only vouchers — investments, reserves and centralised charges. */
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 },
{ debit: '120102', credit: '110102', narration: 'Purchase of State Govt securities', scale: 1_900_000, frequency: 0.12 },
{
debit: '120102',
credit: '110102',
narration: 'Purchase of State Govt securities',
scale: 1_900_000,
frequency: 0.12
},
{ debit: '110102', credit: '320104', narration: 'Profit on sale of securities', scale: 78_000, frequency: 0.1 },
{ debit: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 },
{ debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
@@ -342,7 +438,10 @@ export function glPostings(): GlPosting[] {
let openingDebit = 0;
let openingCredit = 0;
for (const [code, amount] of Object.entries(OPENING_BALANCES)) {
for (const [
code,
amount
] of Object.entries(OPENING_BALANCES)) {
if (!amount) {
continue;
}
@@ -425,7 +524,10 @@ export function glPostings(): GlPosting[] {
for (const branch of GL_BRANCHES) {
// Head Office posts fewer counter transactions than the branches.
const weight = branch.code === 'HO' ? 0.45 : 1;
const templates = branch.code === 'HO' ? [...VOUCHER_TEMPLATES, ...HO_TEMPLATES] : VOUCHER_TEMPLATES;
const templates = branch.code === 'HO' ? [
...VOUCHER_TEMPLATES,
...HO_TEMPLATES
] : VOUCHER_TEMPLATES;
for (const tpl of templates) {
if (rand() > tpl.frequency * weight) {
@@ -487,6 +589,131 @@ export function glPostings(): GlPosting[] {
return out;
}
/**
* Customer sub-ledger.
*
* The GL is account-level, so a client-wise report needs a customer dimension on top.
* Each customer holds a share of one GL head at one branch; balances are not stored,
* they are derived at query time as `share x that head's GL balance`. That way the
* client-wise report always reconciles to the General Ledger exactly, whatever period
* or branch is selected, instead of drifting as a second source of truth.
*/
export interface GlCustomer {
cif: string;
name: string;
branchCode: string;
/** GL head this relationship sits under. */
accountCode: string;
accountNo: string;
/** Fraction of the branch's balance in that head. Shares per (branch, head) sum to 1. */
share: number;
}
const FIRST_NAMES = [
'Ravi',
'Priya',
'Vikram',
'Nirav',
'Kavita',
'Arjun',
'Lakshmi',
'Sameer',
'Anjali',
'Rohan',
'Meera',
'Karan',
'Divya',
'Sanjay',
'Pooja',
'Amit',
'Sneha',
'Manish',
'Rekha',
'Deepak'
];
const LAST_NAMES = [
'Sharma',
'Patel',
'Reddy',
'Shah',
'Verma',
'Deshmukh',
'Iyer',
'Kapoor',
'Nair',
'Joshi',
'Mehta',
'Gupta',
'Rao',
'Bhatt',
'Chauhan'
];
const FIRMS = [
'Traders',
'Enterprises',
'Agencies',
'& Sons',
'Industries',
'Stores',
'Corporation'
];
/** Customer-facing heads a client relationship can sit under. */
const CUSTOMER_HEADS = [
'220101',
'220102',
'220103',
'220104',
'130101',
'130102',
'130103',
'130104',
'130105',
'130106'
];
let cachedCustomers: GlCustomer[] | null = null;
export function glCustomers(): GlCustomer[] {
if (cachedCustomers) {
return cachedCustomers;
}
const rand = lcg(76543210);
const out: GlCustomer[] = [];
let seq = 1;
for (const branch of GL_BRANCHES) {
for (const head of CUSTOMER_HEADS) {
// Deposits are held by many customers; advances by fewer, larger ones.
const isAdvance = head.startsWith('13');
const count = isAdvance ? 2 + Math.floor(rand() * 3) : 3 + Math.floor(rand() * 4);
const weights: number[] = [];
for (let i = 0; i < count; i++) {
weights.push(0.35 + rand() * 0.65);
}
const weightTotal = weights.reduce((a, b) => a + b, 0);
for (let i = 0; i < count; i++) {
const corporate = isAdvance && rand() > 0.55;
const name = corporate
? `${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]} ${FIRMS[Math.floor(rand() * FIRMS.length)]}`
: `${FIRST_NAMES[Math.floor(rand() * FIRST_NAMES.length)]} ${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]}`;
out.push({
cif: `CIF${`${100000 + seq}`}`,
name,
branchCode: branch.code,
accountCode: head,
accountNo: `${branch.code === 'HO' ? '0001' : branch.code}${head}${`${seq}`.padStart(4, '0')}`,
share: weights[i] / weightTotal
});
seq++;
}
}
}
cachedCustomers = out;
return out;
}
export function glAccount(code: string): GlAccount | undefined {
return GL_ACCOUNTS.find((a) => a.code === code);
}
+552 -20
View File
@@ -79,17 +79,33 @@
<div class="glr-filter">
<label>Branch</label>
<select class="glr-inp" [(ngModel)]="branchCode">
<option value="">All Branches (Consolidated)</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
}
</select>
@if (canSelectBranch) {
<select class="glr-inp" [(ngModel)]="branchCode">
<option value="">All Branches (Consolidated)</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
}
</select>
} @else {
<span class="glr-chip" title="Your login is restricted to this branch">
<fa-icon icon="university"></fa-icon> {{ scopeName }}
</span>
}
</div>
<button class="glr-btn" (click)="resetFilters()">Reset</button>
</div>
@if (scopeIsUnresolved) {
<div class="glr-balance glr-balance--bad">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
Your office <strong>{{ scopeName }}</strong> does not match any branch in the ledger, so no figures can be
shown. Ask an administrator to align your office name with a branch.
</span>
</div>
}
<!-- REPORT SELECTOR -->
<div class="glr-tabs">
@for (r of reports; track r.code) {
@@ -193,7 +209,9 @@
@for (r of pnlExpense; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
@@ -229,7 +247,9 @@
@for (r of pnlIncome; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
@@ -281,7 +301,9 @@
@for (r of bsLiabilities; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
}
@@ -312,7 +334,9 @@
@for (r of bsAssets; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
}
@@ -331,11 +355,17 @@
@if (activeReport === 'comparative-balance-sheet') {
<div class="glr-section">
<p class="glr-note">
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before
this period began. Change the filter above to compare a different span.
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before this
period began. Change the filter above to compare a different span.
</p>
@for (block of [{ title: 'Capital & Liabilities', rows: compLiabilities }, { title: 'Assets', rows: compAssets }]; track block.title) {
@for (
block of [
{ title: 'Capital & Liabilities', rows: compLiabilities },
{ title: 'Assets', rows: compAssets }
];
track block.title
) {
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">{{ block.title }}</div>
<table class="glr-tbl">
@@ -353,7 +383,9 @@
@for (r of block.rows; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.current | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.previous | number: '1.2-2' }}</td>
<td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0">
@@ -379,8 +411,8 @@
@if (activeReport === 'contingent-liabilities') {
<div class="glr-section">
<p class="glr-note">
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong>. These are memoranda accounts
and are deliberately excluded from the Trial Balance and Balance Sheet totals.
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong
>. These are memoranda accounts and are deliberately excluded from the Trial Balance and Balance Sheet totals.
</p>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
@@ -419,8 +451,8 @@
@if (activeReport === 'gl-wise-dr-cr') {
<div class="glr-section">
<p class="glr-note">
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every
voucher is double-entry, so the debit and credit totals below must match.
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every voucher
is double-entry, so the debit and credit totals below must match.
</p>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
@@ -474,8 +506,8 @@
@if (activeReport === 'misc-expense-ledger') {
<div class="glr-section">
<p class="glr-note">
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}
&mdash; {{ miscRows.length | number }} entries.
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for
{{ branchLabel }} &mdash; {{ miscRows.length | number }} entries.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
@@ -518,6 +550,506 @@
</div>
}
<!-- ══════════════ GL AVERAGE BALANCE ══════════════ -->
@if (activeReport === 'gl-average-balance') {
<div class="glr-section">
<p class="glr-note">
Average of each account's closing balance across every day in
<strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Banks work from average balances rather than a closing
snapshot for interest, CRR/SLR and profitability, because a single day's figure can be dressed up.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Group</th>
<th class="ta-r">Opening</th>
<th class="ta-r">Closing</th>
<th class="ta-r">Average Daily</th>
<th class="ta-r">Peak</th>
<th class="ta-r">Lowest</th>
<th class="ta-c">Active Days</th>
</tr>
</thead>
<tbody>
@for (r of averageRows; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.group }}</td>
<td class="ta-r mono">{{ r.opening | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.closing | number: '1.2-2' }}</td>
<td class="ta-r mono">
<strong>{{ r.average | number: '1.2-2' }}</strong>
</td>
<td class="ta-r mono">{{ r.peak | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.lowest | number: '1.2-2' }}</td>
<td class="ta-c mono">{{ r.activeDays }}</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="glr-empty">No ledger activity for this period and branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="5">TOTAL ({{ averageRows.length }} accounts)</td>
<td class="ta-r mono">{{ avgTotalAverage | number: '1.2-2' }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- ══════════════ GL MAPPING FOR REPORT ══════════════ -->
@if (activeReport === 'gl-mapping') {
<div class="glr-section">
<p class="glr-note">
Which General Ledger accounts feed each RBI return line. Mappings are created on the OSS 1, OSS 2 and OSS 3
screens using <strong>Add / Remove GLs</strong>; this is the consolidated view of them, with the value each
mapping currently produces from the ledger.
</p>
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">Mapped return lines ({{ mappingRows.length }})</div>
<table class="glr-tbl">
<thead>
<tr>
<th>Return</th>
<th>Return line</th>
<th>Mapped GL accounts</th>
<th class="ta-r">Ledger value (&#8377; thousands)</th>
</tr>
</thead>
<tbody>
@for (r of mappingRows; track r.returnCode + r.lineKey) {
<tr>
<td>{{ r.returnLabel }}</td>
<td class="mono">{{ r.lineKey }}</td>
<td>
@for (c of r.codes; track c) {
<span class="glr-gl-chip mono">{{ c }}</span>
}
</td>
<td class="ta-r mono">{{ r.value | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="glr-empty">
No return lines mapped yet. Open OSS 1, 2 or 3 and use “Add / Remove GLs” to pin accounts to a line.
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<div class="glr-tbl-h">Unmapped GL accounts ({{ unmappedAccounts.length }}) — not feeding any return line</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Group</th>
</tr>
</thead>
<tbody>
@for (a of unmappedAccounts; track a.code) {
<tr>
<td class="mono">{{ a.code }}</td>
<td>{{ a.name }}</td>
<td class="muted">{{ a.group }}</td>
</tr>
} @empty {
<tr>
<td colspan="3" class="glr-empty">Every GL account feeds at least one return line.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ ACCOUNT WISE BALANCE LIST ══════════════ -->
@if (activeReport === 'account-wise-balance') {
<div class="glr-section">
<p class="glr-note">
Closing balance of every account as at <strong>{{ range.to }}</strong
>, broken out by branch — which branch is actually carrying each balance.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Group</th>
@for (c of branchColumns; track c) {
<th class="ta-r">{{ branchName(c) }}</th>
}
<th class="ta-r">Total</th>
</tr>
</thead>
<tbody>
@for (r of accountWiseRows; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.group }}</td>
@for (c of branchColumns; track c) {
<td class="ta-r mono">{{ r.byBranch[c] ? (r.byBranch[c] | number: '1.2-2') : '' }}</td>
}
<td class="ta-r mono">
<strong>{{ r.total | number: '1.2-2' }}</strong>
</td>
</tr>
} @empty {
<tr>
<td [attr.colspan]="branchColumns.length + 4" class="glr-empty">
No balances for this period and branch.
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3">TOTAL ({{ accountWiseRows.length }} accounts)</td>
@for (c of branchColumns; track c) {
<td class="ta-r mono">{{ branchColumnTotal(c) | number: '1.2-2' }}</td>
}
<td class="ta-r mono">{{ accountWiseGrandTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- ══════════════ DISTRIBUTION OF BALANCE ══════════════ -->
@if (activeReport === 'distribution-of-balance') {
<div class="glr-section">
<p class="glr-note">
Composition of the book as at <strong>{{ range.to }}</strong> for {{ branchLabel }} — how concentrated each side
of the balance sheet is by account group.
</p>
<div class="glr-two-col">
@for (
block of [
{ title: 'Capital & Liabilities', rows: liabilityDistribution, total: liabilityDistTotal },
{ title: 'Assets', rows: assetDistribution, total: assetDistTotal }
];
track block.title
) {
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">{{ block.title }}</div>
<table class="glr-tbl">
<thead>
<tr>
<th>Group</th>
<th class="ta-c">A/cs</th>
<th class="ta-r">Amount (&#8377;)</th>
<th class="ta-r">Share</th>
<th class="glr-bar-col">Distribution</th>
</tr>
</thead>
<tbody>
@for (r of block.rows; track r.group) {
<tr>
<td>{{ r.group }}</td>
<td class="ta-c mono">{{ r.accounts }}</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
<td class="ta-r mono">
<strong>{{ r.share | number: '1.2-2' }}%</strong>
</td>
<td>
<div class="glr-bar">
<div class="glr-bar-fill" [style.width.%]="r.share"></div>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="glr-empty">No balances on this side.</td>
</tr>
}
<tr class="total-row">
<td colspan="2">Total</td>
<td class="ta-r mono">{{ block.total | number: '1.2-2' }}</td>
<td class="ta-r mono">100.00%</td>
<td></td>
</tr>
</tbody>
</table>
</div>
}
</div>
</div>
}
<!-- ══════════════ CLIENT WISE BALANCE ══════════════ -->
@if (activeReport === 'client-wise-balance') {
<div class="glr-section">
<div class="glr-kpis">
<div class="glr-kpi glr-kpi--teal">
<div class="glr-kpi-n">&#8377;{{ clientDeposits | number: '1.2-2' }}</div>
<div class="glr-kpi-l">Customer Deposits</div>
</div>
<div class="glr-kpi glr-kpi--amber">
<div class="glr-kpi-n">&#8377;{{ clientAdvances | number: '1.2-2' }}</div>
<div class="glr-kpi-l">Customer Advances</div>
</div>
<div class="glr-kpi glr-kpi--green">
<div class="glr-kpi-n">{{ clientRows.length | number }}</div>
<div class="glr-kpi-l">Relationships</div>
<div class="glr-kpi-sub">as at {{ range.to }} · {{ branchLabel }}</div>
</div>
</div>
<p class="glr-note">
Customer balances are derived from the General Ledger — each relationship holds a share of its branch's balance
in that head — so this report always reconciles to the GL rather than drifting as a second source.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>CIF</th>
<th>Customer</th>
<th>Branch</th>
<th>Account No</th>
<th>Product</th>
<th class="ta-c">Side</th>
<th class="ta-r">Balance (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (r of clientRows; track r.cif) {
<tr>
<td class="mono">{{ r.cif }}</td>
<td>{{ r.name }}</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="mono">{{ r.accountNo }}</td>
<td class="muted">{{ r.product }}</td>
<td class="ta-c">
<span
class="type-pill"
[class.type-liability]="r.side === 'DEPOSIT'"
[class.type-asset]="r.side === 'ADVANCE'"
>
{{ r.side }}
</span>
</td>
<td class="ta-r mono">{{ r.balance | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="glr-empty">No customer relationships for this period and branch.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ BANKER HEAD REPORT ══════════════ -->
@if (activeReport === 'banker-head') {
<div class="glr-section">
<p class="glr-note">
Head-wise abstract for <strong>{{ rangeLabel }}</strong> — the trial balance rolled up to principal heads, with
opening, turnover and closing for each. Debit and credit turnover must agree.
</p>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
<thead>
<tr>
<th>Principal Head</th>
<th class="ta-c">Accounts</th>
<th class="ta-r">Opening</th>
<th class="ta-r">Debit</th>
<th class="ta-r">Credit</th>
<th class="ta-r">Closing</th>
</tr>
</thead>
<tbody>
@for (r of bankerHeadRows; track r.head) {
<tr>
<td>
<strong>{{ r.head }}</strong>
</td>
<td class="ta-c mono">{{ r.accounts }}</td>
<td class="ta-r mono">{{ r.opening | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.debit ? (r.debit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.credit ? (r.credit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">
<strong>{{ r.closing | number: '1.2-2' }}</strong>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="glr-empty">No activity for this period and branch.</td>
</tr>
}
<tr class="total-row">
<td colspan="2">TOTAL ({{ bankerHeadRows.length }} heads)</td>
<td></td>
<td class="ta-r mono">{{ headDebitTotal | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ headCreditTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ SUNDRY REGISTER ══════════════ -->
@if (activeReport === 'sundry-register') {
<div class="glr-section">
<p class="glr-note">
Outstanding items in the sundry, suspense and bills-payable heads as at <strong>{{ range.to }}</strong
>, aged from their posting date. These heads are where unreconciled entries collect, so ageing is the point of
the register.
</p>
@if (sundryBuckets.length) {
<div class="glr-kpis">
@for (b of sundryBuckets; track b.bucket) {
<div
class="glr-kpi"
[class.glr-kpi--green]="b.bucket === 'Up to 30 days'"
[class.glr-kpi--amber]="b.bucket === '31 to 90 days'"
[class.glr-kpi--red]="b.bucket === 'Over 180 days'"
>
<div class="glr-kpi-n">&#8377;{{ b.amount | number: '1.2-2' }}</div>
<div class="glr-kpi-l">{{ b.bucket }}</div>
<div class="glr-kpi-sub">{{ b.count }} item(s)</div>
</div>
}
</div>
}
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Branch</th>
<th>GL Code</th>
<th>Account</th>
<th>Narration</th>
<th class="ta-r">Amount (&#8377;)</th>
<th class="ta-c">Age</th>
</tr>
</thead>
<tbody>
@for (r of sundryRows; track r.voucherNo + r.code + r.date) {
<tr>
<td class="mono">{{ r.date }}</td>
<td class="mono">{{ r.voucherNo }}</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.narration }}</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
<td class="ta-c">
<span
class="type-pill"
[class.type-income]="r.bucket === 'Up to 30 days'"
[class.type-expense]="r.bucket === '31 to 90 days'"
[class.type-contingent]="r.bucket === 'Over 180 days'"
>
{{ r.ageDays }}d
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="glr-empty">No sundry or suspense items outstanding for this selection.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="6">Total outstanding ({{ sundryRows.length }} items)</td>
<td class="ta-r mono">{{ sundryTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- ══════════════ VOUCHER REGISTER ══════════════ -->
@if (activeReport === 'voucher-register') {
<div class="glr-section">
<div class="glr-balance" [class.glr-balance--bad]="unbalancedVouchers > 0">
<fa-icon [icon]="unbalancedVouchers ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
<span>
{{ voucherRows.length | number }} voucher(s) raised in {{ rangeLabel }} for {{ branchLabel }} —
{{
unbalancedVouchers
? unbalancedVouchers + ' voucher(s) do NOT balance and must be investigated'
: 'every voucher is double-entry and balances'
}}
</span>
</div>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Branch</th>
<th>Narration</th>
<th>Debit account(s)</th>
<th>Credit account(s)</th>
<th class="ta-r">Amount (&#8377;)</th>
<th class="ta-c">Legs</th>
</tr>
</thead>
<tbody>
@for (r of voucherRows; track r.voucherNo) {
<tr [class.glr-unbalanced]="!r.balanced">
<td class="mono">{{ r.date }}</td>
<td class="mono">{{ r.voucherNo }}</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="muted">{{ r.narration }}</td>
<td class="mono">{{ r.debitAccounts }}</td>
<td class="mono">{{ r.creditAccounts }}</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
<td class="ta-c mono">{{ r.legs }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="glr-empty">No vouchers raised in this period for this branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="6">Total ({{ voucherRows.length }} vouchers)</td>
<td class="ta-r mono">{{ voucherTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- TOAST -->
<div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -160,6 +160,21 @@
}
}
/* Read-only branch shown to a branch-level login in place of the selector. */
.glr-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
min-width: 190px;
border: 1px solid var(--g-g200);
border-radius: 8px;
background: var(--g-g100);
font-size: 13px;
font-weight: 600;
color: var(--g-g700);
}
/* Day / Month / Range segmented control */
.glr-seg {
display: inline-flex;
@@ -434,6 +449,45 @@
font-style: italic;
}
/* A voucher whose debits and credits disagree. */
.glr-unbalanced td {
background: var(--g-red-l) !important;
color: #7f1d1d;
font-weight: 600;
}
/* GL code chips on the mapping report */
.glr-gl-chip {
display: inline-block;
padding: 1px 8px;
margin: 1px 3px 1px 0;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 11px;
font-weight: 600;
}
/* Share bars on the distribution report */
.glr-bar-col {
width: 150px;
}
.glr-bar {
height: 9px;
border-radius: 999px;
background: var(--g-g100);
overflow: hidden;
min-width: 110px;
}
.glr-bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--g-navy), var(--g-blue));
transition: width 0.25s;
}
/* Account-type pills on the GL Wise report */
.type-pill {
display: inline-block;
+743 -33
View File
@@ -6,24 +6,42 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { BranchScopeService } from 'app/core/branch-scope.service';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs';
import { GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
import { GL_ACCOUNTS, GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
import {
AccountBranchRow,
AverageBalanceRow,
BalanceSheetRow,
ComparativeRow,
DistributionRow,
GlDateRange,
GlReportSet,
MiscExpenseRow,
PnlRow,
BankerHeadRow,
ClientBalanceRow,
SundryItemRow,
VoucherRow,
accountWiseByBranch,
averageBalances,
balanceDistribution,
bankerHeadSummary,
clientWiseBalances,
sundryRegister,
voucherRegister,
buildReports,
clampToLedger,
glValueForCodes,
sum
} from './gl-reports.compute';
import { GlMappingEntry, allMappings, glLabel, returnName } from 'app/rbi-circulars/gl-mapping';
export type GlReportCode =
| 'trial-balance'
@@ -32,7 +50,15 @@ export type GlReportCode =
| 'comparative-balance-sheet'
| 'contingent-liabilities'
| 'gl-wise-dr-cr'
| 'misc-expense-ledger';
| 'misc-expense-ledger'
| 'gl-average-balance'
| 'gl-mapping'
| 'account-wise-balance'
| 'distribution-of-balance'
| 'client-wise-balance'
| 'banker-head'
| 'sundry-register'
| 'voucher-register';
type PeriodMode = 'day' | 'month' | 'range';
@@ -49,6 +75,19 @@ type PeriodMode = 'day' | 'month' | 'range';
export class GlReportsComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private scope = inject(BranchScopeService);
private destroyRef = inject(DestroyRef);
/** Head Office may pick any branch; a branch login is locked to its own book. */
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get scopeIsUnresolved(): boolean {
return this.scope.isUnresolved;
}
get scopeName(): string {
return this.scope.scopeLabel(this.branchCode);
}
readonly reports: { code: GlReportCode; label: string; icon: string }[] = [
{ code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' },
@@ -57,7 +96,15 @@ export class GlReportsComponent implements OnInit {
{ code: 'comparative-balance-sheet', label: 'Comparative Balance Sheet', icon: 'balance-scale' },
{ code: 'contingent-liabilities', label: 'Contingent Liabilities', icon: 'exclamation-circle' },
{ code: 'gl-wise-dr-cr', label: 'GL Wise DR / CR', icon: 'project-diagram' },
{ code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' }
{ code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' },
{ code: 'gl-average-balance', label: 'GL Average Balance', icon: 'calculator' },
{ code: 'gl-mapping', label: 'GL Mapping for Report', icon: 'project-diagram' },
{ code: 'account-wise-balance', label: 'Account wise Balance List', icon: 'list' },
{ code: 'distribution-of-balance', label: 'Distribution of Balance', icon: 'chart-pie' },
{ code: 'client-wise-balance', label: 'Client wise Balance', icon: 'users' },
{ code: 'banker-head', label: 'Banker Head Report', icon: 'university' },
{ code: 'sundry-register', label: 'Sundry Register', icon: 'clipboard-list' },
{ code: 'voucher-register', label: 'Voucher Register', icon: 'receipt' }
];
activeReport: GlReportCode = 'trial-balance';
@@ -80,16 +127,29 @@ export class GlReportsComponent implements OnInit {
private toastTimer: any;
ngOnInit(): void {
const param = this.route.snapshot.paramMap.get('report') as GlReportCode | null;
if (param && this.reports.some((r) => r.code === param)) {
this.activeReport = param;
}
// Subscribe rather than read the snapshot: navigating between GL menu items only
// changes the route parameter, so Angular reuses this component and ngOnInit would
// never fire again — leaving the previously selected report on screen.
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const param = params.get('report') as GlReportCode | null;
if (param && this.reports.some((r) => r.code === param)) {
this.activeReport = param;
}
});
// A branch login opens on its own book and cannot move off it.
this.branchCode = this.scope.defaultBranchCode;
}
showReport(code: GlReportCode): void {
this.activeReport = code;
// Keep the URL deep-linkable so the GL menu items land on the right report.
this.router.navigate(['/gl/reports', code], { replaceUrl: true });
this.router.navigate(
[
'/gl/reports',
code
],
{ replaceUrl: true }
);
}
get reportLabel(): string {
@@ -120,21 +180,33 @@ export class GlReportsComponent implements OnInit {
}
get branchLabel(): string {
if (!this.canSelectBranch) {
return this.scopeName;
}
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
}
/**
* The branch actually used for every query. Applied at the data boundary rather than
* only in the UI so a branch login cannot widen its scope by tampering with state.
*/
private get effectiveBranch(): string {
return this.scope.enforce(this.branchCode);
}
resetFilters(): void {
this.periodMode = 'month';
this.filterMonth = '2026-07';
this.filterDay = '2026-07-31';
this.filterFrom = GL_PERIOD.fyStart;
this.filterTo = GL_PERIOD.fyEnd;
this.branchCode = '';
// Reset returns a branch login to its own branch, never to "all".
this.branchCode = this.scope.defaultBranchCode;
}
/** All seven reports for the current filter — memoised inside buildReports. */
get data(): GlReportSet {
return buildReports(this.range, this.branchCode);
return buildReports(this.range, this.effectiveBranch);
}
// ── Trial Balance ──────────────────────────────────────────────────────────
@@ -234,6 +306,139 @@ export class GlReportsComponent implements OnInit {
return glBranchName(code);
}
// ── GL Average Balance ─────────────────────────────────────────────────────
get averageRows(): AverageBalanceRow[] {
return averageBalances(this.range, this.effectiveBranch);
}
get avgTotalAverage(): number {
return sum(this.averageRows, (r) => r.average);
}
// ── GL Mapping for Report ──────────────────────────────────────────────────
/** Every RBI return line currently pinned to GL accounts, with its ledger value. */
get mappingRows(): {
returnCode: string;
returnLabel: string;
lineKey: string;
codes: string[];
codeLabels: string;
value: number;
}[] {
return allMappings()
.map((m: GlMappingEntry) => ({
returnCode: m.returnCode,
returnLabel: returnName(m.returnCode),
lineKey: m.lineKey,
codes: m.codes,
codeLabels: m.codes.map((c) => glLabel(c)).join(', '),
// Returns are in thousands; the ledger is in rupees.
value: glValueForCodes(m.codes, this.range, this.effectiveBranch) / 1000
}))
.sort((a, b) => a.returnCode.localeCompare(b.returnCode) || a.lineKey.localeCompare(b.lineKey));
}
/** GL accounts not pinned to any return line — the gap a mapping review looks for. */
get unmappedAccounts(): { code: string; name: string; group: string }[] {
const used = new Set(allMappings().flatMap((m) => m.codes));
return this.chartOfAccounts
.filter((a) => !used.has(a.code))
.map((a) => ({ code: a.code, name: a.name, group: a.group }));
}
readonly chartOfAccounts = GL_ACCOUNTS;
// ── Account wise Balance List ──────────────────────────────────────────────
/** Branch columns shown — every branch for Head Office, just their own otherwise. */
get branchColumns(): string[] {
return this.canSelectBranch ? GL_BRANCHES.map((b) => b.code) : [this.effectiveBranch];
}
get accountWiseRows(): AccountBranchRow[] {
return accountWiseByBranch(this.range, this.branchColumns);
}
branchColumnTotal(code: string): number {
return sum(this.accountWiseRows, (r) => r.byBranch[code] ?? 0);
}
get accountWiseGrandTotal(): number {
return sum(this.accountWiseRows, (r) => r.total);
}
// ── Client wise Balance ────────────────────────────────────────────────────
get clientRows(): ClientBalanceRow[] {
return clientWiseBalances(this.range, this.effectiveBranch);
}
get clientDeposits(): number {
return sum(
this.clientRows.filter((r) => r.side === 'DEPOSIT'),
(r) => r.balance
);
}
get clientAdvances(): number {
return sum(
this.clientRows.filter((r) => r.side === 'ADVANCE'),
(r) => r.balance
);
}
// ── Banker Head Report ─────────────────────────────────────────────────────
get bankerHeadRows(): BankerHeadRow[] {
return bankerHeadSummary(this.range, this.effectiveBranch);
}
get headDebitTotal(): number {
return sum(this.bankerHeadRows, (r) => r.debit);
}
get headCreditTotal(): number {
return sum(this.bankerHeadRows, (r) => r.credit);
}
// ── Sundry Register ────────────────────────────────────────────────────────
get sundryRows(): SundryItemRow[] {
return sundryRegister(this.range, this.effectiveBranch);
}
get sundryTotal(): number {
return sum(this.sundryRows, (r) => r.amount);
}
/** Ageing summary shown above the register. */
get sundryBuckets(): { bucket: string; count: number; amount: number }[] {
const order = [
'Up to 30 days',
'31 to 90 days',
'91 to 180 days',
'Over 180 days'
];
return order
.map((bucket) => {
const rows = this.sundryRows.filter((r) => r.bucket === bucket);
return { bucket, count: rows.length, amount: sum(rows, (r) => r.amount) };
})
.filter((b) => b.count > 0);
}
// ── Voucher Register ───────────────────────────────────────────────────────
get voucherRows(): VoucherRow[] {
return voucherRegister(this.range, this.effectiveBranch);
}
get voucherTotal(): number {
return sum(this.voucherRows, (r) => r.amount);
}
/** Vouchers whose debits and credits do not agree — should always be zero. */
get unbalancedVouchers(): number {
return this.voucherRows.filter((r) => !r.balanced).length;
}
// ── Distribution of Balance ────────────────────────────────────────────────
get assetDistribution(): DistributionRow[] {
return balanceDistribution(this.range, this.effectiveBranch, 'ASSET');
}
get liabilityDistribution(): DistributionRow[] {
return balanceDistribution(this.range, this.effectiveBranch, 'LIABILITY');
}
get assetDistTotal(): number {
return sum(this.assetDistribution, (r) => r.amount);
}
get liabilityDistTotal(): number {
return sum(this.liabilityDistribution, (r) => r.amount);
}
// ── Excel export ───────────────────────────────────────────────────────────
/** Exports whichever report is on screen, carrying the active filter into the header. */
@@ -314,7 +519,17 @@ export class GlReportsComponent implements OnInit {
'Closing Dr',
'Closing Cr'
],
widths: [12, 42, 26, 16, 16, 16, 16, 16, 16],
widths: [
12,
42,
26,
16,
16,
16,
16,
16,
16
],
rows: [
...this.data.trialBalance.map((r) => [
r.code,
@@ -349,13 +564,49 @@ export class GlReportsComponent implements OnInit {
'Group',
'Amount'
],
widths: [14, 12, 42, 26, 18],
widths: [
14,
12,
42,
26,
18
],
rows: [
...this.pnlExpense.map((r) => ['Expenditure', r.code, r.name, r.group, r.amount]),
['Total Expenditure', '', '', '', this.totalExpense],
...this.pnlIncome.map((r) => ['Income', r.code, r.name, r.group, r.amount]),
['Total Income', '', '', '', this.totalIncome],
['Net Profit', '', '', '', this.netProfit]
...this.pnlExpense.map((r) => [
'Expenditure',
r.code,
r.name,
r.group,
r.amount
]),
[
'Total Expenditure',
'',
'',
'',
this.totalExpense
],
...this.pnlIncome.map((r) => [
'Income',
r.code,
r.name,
r.group,
r.amount
]),
[
'Total Income',
'',
'',
'',
this.totalIncome
],
[
'Net Profit',
'',
'',
'',
this.netProfit
]
]
};
case 'balance-sheet':
@@ -367,13 +618,49 @@ export class GlReportsComponent implements OnInit {
'Group',
'Amount'
],
widths: [14, 12, 42, 26, 18],
widths: [
14,
12,
42,
26,
18
],
rows: [
...this.bsLiabilities.map((r) => ['Liability', r.code, r.name, r.group, r.amount]),
['Liability', '', 'Surplus for the period', 'Profit & Loss', this.bsSurplus],
['Total Liabilities', '', '', '', this.bsTotalLiabilities],
...this.bsAssets.map((r) => ['Asset', r.code, r.name, r.group, r.amount]),
['Total Assets', '', '', '', this.bsTotalAssets]
...this.bsLiabilities.map((r) => [
'Liability',
r.code,
r.name,
r.group,
r.amount
]),
[
'Liability',
'',
'Surplus for the period',
'Profit & Loss',
this.bsSurplus
],
[
'Total Liabilities',
'',
'',
'',
this.bsTotalLiabilities
],
...this.bsAssets.map((r) => [
'Asset',
r.code,
r.name,
r.group,
r.amount
]),
[
'Total Assets',
'',
'',
'',
this.bsTotalAssets
]
]
};
case 'comparative-balance-sheet':
@@ -387,7 +674,15 @@ export class GlReportsComponent implements OnInit {
'Variance',
'Variance %'
],
widths: [12, 12, 42, 18, 18, 18, 14],
widths: [
12,
12,
42,
18,
18,
18,
14
],
rows: this.data.comparative.map((r) => [
r.side,
r.code,
@@ -406,10 +701,25 @@ export class GlReportsComponent implements OnInit {
'Group',
'Amount'
],
widths: [12, 48, 26, 18],
widths: [
12,
48,
26,
18
],
rows: [
...this.data.contingent.map((r) => [r.code, r.name, r.group, r.amount]),
['TOTAL', '', '', this.contingentTotal]
...this.data.contingent.map((r) => [
r.code,
r.name,
r.group,
r.amount
]),
[
'TOTAL',
'',
'',
this.contingentTotal
]
]
};
case 'gl-wise-dr-cr':
@@ -424,10 +734,394 @@ export class GlReportsComponent implements OnInit {
'Net (Dr-Cr)',
'Vouchers'
],
widths: [12, 42, 14, 26, 18, 18, 18, 12],
widths: [
12,
42,
14,
26,
18,
18,
18,
12
],
rows: [
...this.data.glWise.map((r) => [r.code, r.name, r.type, r.group, r.debit, r.credit, r.net, r.vouchers]),
['TOTAL', '', '', '', this.glWiseDebit, this.glWiseCredit, this.glWiseDebit - this.glWiseCredit, null]
...this.data.glWise.map((r) => [
r.code,
r.name,
r.type,
r.group,
r.debit,
r.credit,
r.net,
r.vouchers
]),
[
'TOTAL',
'',
'',
'',
this.glWiseDebit,
this.glWiseCredit,
this.glWiseDebit - this.glWiseCredit,
null
]
]
};
case 'gl-average-balance':
return {
headers: [
'GL Code',
'Account',
'Group',
'Opening',
'Closing',
'Average Daily Balance',
'Peak',
'Lowest',
'Active Days'
],
widths: [
12,
42,
26,
18,
18,
22,
18,
18,
12
],
rows: [
...this.averageRows.map((r) => [
r.code,
r.name,
r.group,
r.opening,
r.closing,
r.average,
r.peak,
r.lowest,
r.activeDays
]),
[
'TOTAL',
'',
'',
null,
null,
this.avgTotalAverage,
null,
null,
null
]
]
};
case 'gl-mapping':
return {
headers: [
'Return',
'Return Line',
'Mapped GL Accounts',
'Ledger Value (Rs Thousands)'
],
widths: [
42,
46,
60,
24
],
rows: [
...this.mappingRows.map((r) => [
r.returnLabel,
r.lineKey,
r.codeLabels,
r.value
]),
...(this.mappingRows.length ? [] : [[
'— no return lines mapped —',
'',
'',
null
]]),
[
'',
'',
'',
null
],
[
'UNMAPPED GL ACCOUNTS',
'',
'',
null
],
...this.unmappedAccounts.map((a) => [
'',
a.code + ' — ' + a.name,
a.group,
null
])
]
};
case 'account-wise-balance':
return {
headers: [
'GL Code',
'Account',
'Group',
...this.branchColumns.map((c) => this.branchName(c)),
'Total'
],
widths: [
12,
40,
24,
...this.branchColumns.map(() => 18),
20
],
rows: [
...this.accountWiseRows.map((r) => [
r.code,
r.name,
r.group,
...this.branchColumns.map((c) => r.byBranch[c] ?? 0),
r.total
]),
[
'TOTAL',
'',
'',
...this.branchColumns.map((c) => this.branchColumnTotal(c)),
this.accountWiseGrandTotal
]
]
};
case 'distribution-of-balance':
return {
headers: [
'Side',
'Group',
'Accounts',
'Amount',
'Share %'
],
widths: [
16,
40,
14,
22,
14
],
rows: [
...this.liabilityDistribution.map((r) => [
'Liability',
r.group,
r.accounts,
r.amount,
Number(r.share.toFixed(2))
]),
[
'Total Liabilities',
'',
null,
this.liabilityDistTotal,
100
],
...this.assetDistribution.map((r) => [
'Asset',
r.group,
r.accounts,
r.amount,
Number(r.share.toFixed(2))
]),
[
'Total Assets',
'',
null,
this.assetDistTotal,
100
]
]
};
case 'client-wise-balance':
return {
headers: [
'CIF',
'Customer',
'Branch',
'Account No',
'Product',
'Side',
'Balance'
],
widths: [
14,
34,
18,
24,
34,
12,
20
],
rows: [
...this.clientRows.map((r) => [
r.cif,
r.name,
this.branchName(r.branchCode),
r.accountNo,
r.product,
r.side,
r.balance
]),
[
'TOTAL DEPOSITS',
'',
'',
'',
'',
'',
this.clientDeposits
],
[
'TOTAL ADVANCES',
'',
'',
'',
'',
'',
this.clientAdvances
]
]
};
case 'banker-head':
return {
headers: [
'Principal Head',
'Accounts',
'Opening',
'Debit',
'Credit',
'Closing'
],
widths: [
34,
12,
20,
20,
20,
20
],
rows: [
...this.bankerHeadRows.map((r) => [
r.head,
r.accounts,
r.opening,
r.debit,
r.credit,
r.closing
]),
[
'TOTAL',
null,
null,
this.headDebitTotal,
this.headCreditTotal,
null
]
]
};
case 'sundry-register':
return {
headers: [
'Date',
'Voucher No',
'Branch',
'GL Code',
'Account',
'Narration',
'Amount',
'Age (days)',
'Ageing Bucket'
],
widths: [
14,
20,
18,
12,
32,
42,
18,
12,
18
],
rows: [
...this.sundryRows.map((r) => [
r.date,
r.voucherNo,
this.branchName(r.branchCode),
r.code,
r.name,
r.narration,
r.amount,
r.ageDays,
r.bucket
]),
[
'TOTAL',
'',
'',
'',
'',
'',
this.sundryTotal,
null,
''
]
]
};
case 'voucher-register':
return {
headers: [
'Date',
'Voucher No',
'Branch',
'Narration',
'Debit Account(s)',
'Credit Account(s)',
'Amount',
'Legs'
],
widths: [
14,
20,
18,
42,
38,
38,
20,
10
],
rows: [
...this.voucherRows.map((r) => [
r.date,
r.voucherNo,
this.branchName(r.branchCode),
r.narration,
r.debitAccounts,
r.creditAccounts,
r.amount,
r.legs
]),
[
'TOTAL',
'',
'',
'',
'',
'',
this.voucherTotal,
null
]
]
};
case 'misc-expense-ledger':
@@ -442,7 +1136,15 @@ export class GlReportsComponent implements OnInit {
'Narration',
'Amount'
],
widths: [14, 20, 18, 12, 36, 46, 18],
widths: [
14,
20,
18,
12,
36,
46,
18
],
rows: [
...this.miscRows.map((r) => [
r.date,
@@ -453,7 +1155,15 @@ export class GlReportsComponent implements OnInit {
r.narration,
r.amount
]),
['TOTAL', '', '', '', '', '', this.miscTotal]
[
'TOTAL',
'',
'',
'',
'',
'',
this.miscTotal
]
]
};
}
+477 -13
View File
@@ -15,14 +15,7 @@
* postings and Angular re-evaluates template getters on every change detection.
*/
import {
GL_ACCOUNTS,
GL_PERIOD,
GlAccount,
GlPosting,
glAccount,
glPostings
} from './gl-reports-data';
import { GL_ACCOUNTS, GL_PERIOD, GlAccount, GlPosting, glAccount, glCustomers, glPostings } from './gl-reports-data';
export interface GlDateRange {
from: string;
@@ -96,8 +89,22 @@ function matchesBranch(p: GlPosting, branchCode: string): boolean {
return !branchCode || p.branchCode === branchCode;
}
/**
* Both scans below walk the whole ~21k-posting ledger, so their results are cached
* per (date, branch). Angular re-evaluates template getters on every change-detection
* pass; without this a screen with a few hundred mapped lines would spend seconds per
* click rescanning the ledger and appear frozen.
*/
const balanceCache = new Map<string, Map<string, number>>();
const turnoverCache = new Map<string, Map<string, { debit: number; credit: number; vouchers: Set<string> }>>();
/** 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 key = `${to}|${branchCode}`;
const hit = balanceCache.get(key);
if (hit) {
return hit;
}
const out = new Map<string, number>();
for (const p of glPostings()) {
if (p.date > to || !matchesBranch(p, branchCode)) {
@@ -110,11 +117,20 @@ function cumulativeBalances(to: string, branchCode: string): Map<string, number>
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
}
balanceCache.set(key, out);
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> }> {
function periodTurnover(
range: GlDateRange,
branchCode: string
): Map<string, { debit: number; credit: number; vouchers: Set<string> }> {
const key = `${range.from}|${range.to}|${branchCode}`;
const hit = turnoverCache.get(key);
if (hit) {
return hit;
}
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)) {
@@ -129,6 +145,7 @@ function periodTurnover(range: GlDateRange, branchCode: string): Map<string, { d
entry.credit += p.credit;
entry.vouchers.add(p.voucherNo);
}
turnoverCache.set(key, out);
return out;
}
@@ -190,8 +207,7 @@ export function profitAndLoss(range: GlDateRange, branchCode: string): { income:
if (!move) {
continue;
}
const amount =
account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
const amount = account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
if (!amount) {
continue;
}
@@ -205,7 +221,10 @@ export function profitAndLoss(range: GlDateRange, branchCode: string): { income:
* 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): {
export function balanceSheet(
range: GlDateRange,
branchCode: string
): {
assets: BalanceSheetRow[];
liabilities: BalanceSheetRow[];
surplus: number;
@@ -315,7 +334,417 @@ export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscE
amount: p.debit
});
}
return rows.sort((a, b) => (a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)));
return rows.sort((a, b) =>
a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)
);
}
export interface AverageBalanceRow {
code: string;
name: string;
group: string;
type: string;
opening: number;
closing: number;
average: number;
peak: number;
lowest: number;
/** Days on which the account moved. */
activeDays: number;
}
export interface AccountBranchRow {
code: string;
name: string;
group: string;
/** Closing balance per branch code, plus a `total`. */
byBranch: Record<string, number>;
total: number;
}
export interface DistributionRow {
group: string;
amount: number;
share: number;
accounts: number;
}
/** Calendar days in the range, inclusive. */
function daysInRange(range: GlDateRange): string[] {
const out: string[] = [];
const cur = new Date(`${range.from}T00:00:00Z`);
const end = new Date(`${range.to}T00:00:00Z`);
while (cur <= end) {
out.push(cur.toISOString().slice(0, 10));
cur.setUTCDate(cur.getUTCDate() + 1);
}
return out;
}
/**
* Average daily balance per account across the period, plus the peak and lowest
* balance touched. Banks use average balances rather than closing snapshots for
* interest, CRR/SLR and profitability work, because a closing figure can be
* window-dressed on the last day.
*
* Computed by carrying a running balance forward day by day rather than
* recomputing a cumulative balance per day, which would be quadratic.
*/
export function averageBalances(range: GlDateRange, branchCode: string): AverageBalanceRow[] {
const opening = cumulativeBalances(previousDay(range.from), branchCode);
const days = daysInRange(range);
// Net movement per account per day, on the account's natural side.
const dailyDelta = new Map<string, Map<string, number>>();
const movedOn = new Map<string, Set<string>>();
for (const p of glPostings()) {
if (p.date < range.from || p.date > range.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;
let perDay = dailyDelta.get(p.accountCode);
if (!perDay) {
perDay = new Map<string, number>();
dailyDelta.set(p.accountCode, perDay);
}
perDay.set(p.date, (perDay.get(p.date) ?? 0) + delta);
let seen = movedOn.get(p.accountCode);
if (!seen) {
seen = new Set<string>();
movedOn.set(p.accountCode, seen);
}
seen.add(p.date);
}
const rows: AverageBalanceRow[] = [];
for (const account of GL_ACCOUNTS) {
const open = opening.get(account.code) ?? 0;
const perDay = dailyDelta.get(account.code);
if (!open && !perDay) {
continue;
}
let running = open;
let total = 0;
let peak = open;
let lowest = open;
for (const d of days) {
running += perDay?.get(d) ?? 0;
total += running;
peak = Math.max(peak, running);
lowest = Math.min(lowest, running);
}
rows.push({
code: account.code,
name: account.name,
group: account.group,
type: account.type,
opening: open,
closing: running,
average: days.length ? total / days.length : 0,
peak,
lowest,
activeDays: movedOn.get(account.code)?.size ?? 0
});
}
return rows;
}
/**
* Closing balance of every account broken out by branch. Answers "which branch is
* carrying this balance", which a single consolidated column cannot.
*/
export function accountWiseByBranch(range: GlDateRange, branchCodes: string[]): AccountBranchRow[] {
const perBranch = new Map<string, Map<string, number>>();
for (const b of branchCodes) {
perBranch.set(b, cumulativeBalances(range.to, b));
}
const rows: AccountBranchRow[] = [];
for (const account of GL_ACCOUNTS) {
const byBranch: Record<string, number> = {};
let total = 0;
let any = false;
for (const b of branchCodes) {
const v = perBranch.get(b)?.get(account.code) ?? 0;
byBranch[b] = v;
total += v;
if (v) {
any = true;
}
}
if (!any) {
continue;
}
rows.push({ code: account.code, name: account.name, group: account.group, byBranch, total });
}
return rows;
}
/**
* Share of the closing book held by each account group, for one side of the
* balance sheet. Used to see concentration at a glance.
*/
export function balanceDistribution(
range: GlDateRange,
branchCode: string,
side: 'ASSET' | 'LIABILITY'
): DistributionRow[] {
const closing = cumulativeBalances(range.to, branchCode);
const byGroup = new Map<string, { amount: number; accounts: number }>();
let grand = 0;
for (const account of GL_ACCOUNTS) {
if (account.type !== side) {
continue;
}
const v = closing.get(account.code) ?? 0;
if (!v) {
continue;
}
const entry = byGroup.get(account.group) ?? { amount: 0, accounts: 0 };
entry.amount += v;
entry.accounts += 1;
byGroup.set(account.group, entry);
grand += v;
}
return [...byGroup.entries()]
.map(
([
group,
e
]) => ({
group,
amount: e.amount,
accounts: e.accounts,
share: grand ? (e.amount / grand) * 100 : 0
})
)
.sort((a, b) => b.amount - a.amount);
}
export interface ClientBalanceRow {
cif: string;
name: string;
branchCode: string;
accountNo: string;
accountCode: string;
product: string;
/** DEPOSIT or ADVANCE — which side of the relationship this is. */
side: 'DEPOSIT' | 'ADVANCE';
balance: number;
}
export interface BankerHeadRow {
head: string;
opening: number;
debit: number;
credit: number;
closing: number;
accounts: number;
}
export interface SundryItemRow {
date: string;
voucherNo: string;
branchCode: string;
code: string;
name: string;
narration: string;
amount: number;
/** Days outstanding as at the period end. */
ageDays: number;
bucket: string;
}
export interface VoucherRow {
voucherNo: string;
date: string;
branchCode: string;
narration: string;
debitAccounts: string;
creditAccounts: string;
amount: number;
legs: number;
/** False when debits and credits on the voucher do not agree. */
balanced: boolean;
}
/**
* Client-wise balances, derived from the GL rather than stored separately so the two
* can never disagree. Each customer's balance is its share of the branch's closing
* balance in the head it sits under.
*/
export function clientWiseBalances(range: GlDateRange, branchCode: string): ClientBalanceRow[] {
const rows: ClientBalanceRow[] = [];
const branchClosings = new Map<string, Map<string, number>>();
for (const c of glCustomers()) {
if (branchCode && c.branchCode !== branchCode) {
continue;
}
let closing = branchClosings.get(c.branchCode);
if (!closing) {
closing = cumulativeBalances(range.to, c.branchCode);
branchClosings.set(c.branchCode, closing);
}
const headBalance = closing.get(c.accountCode) ?? 0;
const balance = headBalance * c.share;
if (Math.abs(balance) < 0.005) {
continue;
}
const account = glAccount(c.accountCode);
rows.push({
cif: c.cif,
name: c.name,
branchCode: c.branchCode,
accountNo: c.accountNo,
accountCode: c.accountCode,
product: account?.name ?? c.accountCode,
side: c.accountCode.startsWith('13') ? 'ADVANCE' : 'DEPOSIT',
balance
});
}
return rows.sort((a, b) => b.balance - a.balance);
}
/**
* Head-wise abstract: the trial balance rolled up to principal heads, the summary a
* banker reads before the detail. Opening, turnover and closing per head.
*/
export function bankerHeadSummary(range: GlDateRange, branchCode: string): BankerHeadRow[] {
const opening = cumulativeBalances(previousDay(range.from), branchCode);
const turnover = periodTurnover(range, branchCode);
const byHead = new Map<string, BankerHeadRow>();
for (const account of GL_ACCOUNTS) {
// Contingents are single-sided off-balance-sheet memoranda. Including them would
// make the abstract's debit and credit turnover disagree and stop it reconciling
// to the trial balance, which also excludes them. They have their own report.
if (account.type === 'CONTINGENT') {
continue;
}
const open = opening.get(account.code) ?? 0;
const move = turnover.get(account.code);
if (!open && !move) {
continue;
}
const debit = move?.debit ?? 0;
const credit = move?.credit ?? 0;
const closing = open + (isDebitSide(account) ? debit - credit : credit - debit);
const row = byHead.get(account.group) ?? {
head: account.group,
opening: 0,
debit: 0,
credit: 0,
closing: 0,
accounts: 0
};
row.opening += open;
row.debit += debit;
row.credit += credit;
row.closing += closing;
row.accounts += 1;
byHead.set(account.group, row);
}
return [...byHead.values()];
}
const SUNDRY_CODES = [
'140104',
'230103',
'140199',
'230102'
];
/**
* Outstanding items in the sundry and suspense heads, aged from the posting date.
* These accounts are where unreconciled entries accumulate, so ageing is the point
* of the register.
*/
export function sundryRegister(range: GlDateRange, branchCode: string): SundryItemRow[] {
const wanted = new Set(SUNDRY_CODES);
const end = new Date(`${range.to}T00:00:00Z`).getTime();
const rows: SundryItemRow[] = [];
for (const p of glPostings()) {
if (p.date > range.to || !matchesBranch(p, branchCode) || !wanted.has(p.accountCode)) {
continue;
}
const ageDays = Math.max(0, Math.round((end - new Date(`${p.date}T00:00:00Z`).getTime()) / 86400000));
const bucket =
ageDays <= 30
? 'Up to 30 days'
: ageDays <= 90
? '31 to 90 days'
: ageDays <= 180
? '91 to 180 days'
: 'Over 180 days';
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 || p.credit,
ageDays,
bucket
});
}
return rows.sort((a, b) => b.ageDays - a.ageDays);
}
/**
* Every voucher raised in the period with its debit and credit legs collapsed onto
* one line, plus a per-voucher double-entry check.
*/
export function voucherRegister(range: GlDateRange, branchCode: string): VoucherRow[] {
const grouped = new Map<string, GlPosting[]>();
for (const p of glPostings()) {
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
continue;
}
const list = grouped.get(p.voucherNo);
if (list) {
list.push(p);
} else {
grouped.set(p.voucherNo, [p]);
}
}
const rows: VoucherRow[] = [];
for (const [
voucherNo,
legs
] of grouped) {
const debits = legs.filter((l) => l.debit > 0);
const credits = legs.filter((l) => l.credit > 0);
const drTotal = debits.reduce((s, l) => s + l.debit, 0);
const crTotal = credits.reduce((s, l) => s + l.credit, 0);
const nameOf = (codes: GlPosting[], side: 'debit' | 'credit') =>
[...new Set(codes.map((l) => `${l.accountCode} ${glAccount(l.accountCode)?.name ?? ''}`.trim()))].join(', ');
rows.push({
voucherNo,
date: legs[0].date,
branchCode: legs[0].branchCode,
narration: legs[0].narration,
debitAccounts: nameOf(debits, 'debit') || '—',
creditAccounts: nameOf(credits, 'credit') || '—',
amount: Math.max(drTotal, crTotal),
legs: legs.length,
// Contingent and opening memoranda are single-sided by design.
balanced: voucherNo.startsWith('CNT/') || voucherNo.startsWith('OB/') || Math.round(drTotal - crTotal) === 0
});
}
return rows.sort((a, b) =>
a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)
);
}
// ── Memoisation ──────────────────────────────────────────────────────────────
@@ -360,6 +789,41 @@ export function clampToLedger(range: GlDateRange): GlDateRange {
};
}
/**
* Value of a set of GL accounts for a period, used when an RBI return line item is
* mapped to the chart of accounts.
*
* Balance-sheet accounts contribute their closing balance as at the period end;
* income and expenditure accounts contribute their movement within the period.
* That mirrors how the two kinds of figure are read on a return.
*/
export function glValueForCodes(codes: string[], range: GlDateRange, branchCode = ''): number {
if (!codes.length) {
return 0;
}
const wanted = new Set(codes);
const closing = cumulativeBalances(range.to, branchCode);
const movement = periodTurnover(range, branchCode);
let total = 0;
for (const code of wanted) {
const account = glAccount(code);
if (!account) {
continue;
}
if (account.type === 'INCOME' || account.type === 'EXPENSE') {
const move = movement.get(code);
if (!move) {
continue;
}
total += account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
} else {
total += closing.get(code) ?? 0;
}
}
return total;
}
export function sum<T>(rows: T[], pick: (row: T) => number): number {
return rows.reduce((acc, r) => acc + pick(r), 0);
}