537 lines
20 KiB
TypeScript
537 lines
20 KiB
TypeScript
/**
|
|
* 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/.
|
|
*/
|
|
|
|
import { Component, OnInit, inject } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { CashierService } from '../cashier.service';
|
|
import { SettingsService } from 'app/settings/settings.service';
|
|
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
|
|
|
/** One part of the day's work — a section of the day book, or a kind of entry within it. */
|
|
interface SummaryLine {
|
|
label: string;
|
|
credit: number;
|
|
debit: number;
|
|
count: number;
|
|
}
|
|
|
|
/**
|
|
* The single sheet a cashier signs the day off with.
|
|
*
|
|
* Every other cashier screen answers one question — what is in the drawer, which vouchers are
|
|
* unverified, what did each person handle. At close of day somebody has to sign one piece of paper
|
|
* saying the money was counted and it agreed, and until now that meant reading four screens and
|
|
* copying figures onto a form by hand.
|
|
*
|
|
* Nothing here is computed on the server that was not already being computed: the closing report
|
|
* supplies the drawer legs, the day book supplies the transactions, and the cashier position
|
|
* supplies what the system believes is held. This screen assembles them in the order a person
|
|
* verifying the day reads them in, and prints.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-end-of-day-report',
|
|
templateUrl: './end-of-day-report.component.html',
|
|
styleUrls: [
|
|
'../cashier-shared.scss',
|
|
'./end-of-day-report.component.scss'
|
|
],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
MatIconModule
|
|
]
|
|
})
|
|
export class EndOfDayReportComponent implements OnInit {
|
|
private service = inject(CashierService);
|
|
private settings = inject(SettingsService);
|
|
private auth = inject(AuthenticationService);
|
|
|
|
date = '';
|
|
loading = false;
|
|
loaded = false;
|
|
|
|
/** Who is signing, and for which branch — read from the session, never typed in. */
|
|
preparedBy = '';
|
|
branchName = '';
|
|
|
|
// ── Section 1: transaction summary ──
|
|
rows: any[] = [];
|
|
creditTotal = 0;
|
|
debitTotal = 0;
|
|
|
|
// ── Section 2: cash drawer ──
|
|
/** Drawn from the vault in the morning: a cashier's opening float. */
|
|
openingFloat = 0;
|
|
cashDeposits = 0;
|
|
interCashierIn = 0;
|
|
cashWithdrawals = 0;
|
|
settledOut = 0;
|
|
interCashierOut = 0;
|
|
/** Whether the server sent this day's own cash legs, which only a cashier-scoped call gets. */
|
|
hasDayLegs = false;
|
|
/** Whether the drawer figures are one cashier's or the whole branch's counter work. */
|
|
scopedToCashier = false;
|
|
|
|
/** What the drawers hold between them, per the system. */
|
|
drawersTotal: number | null = null;
|
|
|
|
/**
|
|
* Each cashier and what their drawer holds.
|
|
*
|
|
* A clerk holds no drawer of their own — they supervise the counter — so a single "actual cash"
|
|
* figure says nothing to them. The position endpoint already answers for whoever is asking: one
|
|
* drawer for a cashier, every drawer for a clerk or officer, so the same call serves both and the
|
|
* screen reads differently depending on who opened it.
|
|
*/
|
|
cashierPositions: { cashierId: number | null; cashierName: string; totalAmount: number }[] = [];
|
|
|
|
/**
|
|
* The branch's own expected cash, from the day register.
|
|
*
|
|
* A branch-wide reading cannot be assembled from the per-cashier day legs: those are only
|
|
* returned when the call is scoped to one cashier. The branch day register already keeps this
|
|
* arithmetic for the close-day gate — opening cash, plus approved deposits, less what was
|
|
* withdrawn — so the sheet reads it rather than inventing a second version of the same sum.
|
|
*
|
|
* Null when no day has been opened for this date, which is a real state: there is nothing to
|
|
* reconcile against on a day the branch never opened.
|
|
*/
|
|
branchOpeningCash: number | null = null;
|
|
branchDeposits: number | null = null;
|
|
branchWithdrawals: number | null = null;
|
|
branchExpectedCash: number | null = null;
|
|
|
|
/**
|
|
* What the vaults hold, which a branch-wide count must include.
|
|
*
|
|
* The branch register's expected cash covers the whole branch — vault and every drawer together —
|
|
* because cash moved from the vault to a cashier has not left the branch. Counting only the
|
|
* drawers against it would report the entire vault as a shortage.
|
|
*/
|
|
vaultTotal: number | null = null;
|
|
|
|
ngOnInit(): void {
|
|
this.date = this.businessDate();
|
|
this.preparedBy = this.currentUserName();
|
|
this.branchName = this.currentOfficeName();
|
|
this.load();
|
|
}
|
|
|
|
/**
|
|
* The date the books are open on, not the wall clock.
|
|
*
|
|
* An EOD sheet dated by the browser would be filed against the wrong day whenever the branch is
|
|
* still working yesterday's business date, which is exactly when a day is being closed.
|
|
*/
|
|
private businessDate(): string {
|
|
const d = this.settings.businessDate;
|
|
const safe = d instanceof Date && !isNaN(d.getTime()) ? d : new Date();
|
|
return `${safe.getFullYear()}-${String(safe.getMonth() + 1).padStart(2, '0')}-${String(safe.getDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
/** The member of staff behind the login, falling back to the username when no staff is linked. */
|
|
private currentUserName(): string {
|
|
const c = this.auth.getCredentials();
|
|
return c ? c.staffDisplayName || c.username || '' : '';
|
|
}
|
|
|
|
private currentOfficeName(): string {
|
|
return this.auth.getCredentials()?.officeName || '';
|
|
}
|
|
|
|
load(): void {
|
|
this.loading = true;
|
|
this.loaded = false;
|
|
|
|
this.service.dayBook(this.date).subscribe({
|
|
next: (res: any) => {
|
|
this.rows = res?.rows || [];
|
|
this.creditTotal = Number(res?.creditTotal || 0);
|
|
this.debitTotal = Number(res?.debitTotal || 0);
|
|
this.loading = false;
|
|
this.loaded = true;
|
|
},
|
|
error: () => {
|
|
this.rows = [];
|
|
this.creditTotal = 0;
|
|
this.debitTotal = 0;
|
|
this.loading = false;
|
|
this.loaded = true;
|
|
}
|
|
});
|
|
|
|
this.service.closingCashReport(this.date).subscribe({
|
|
next: (res: any) => {
|
|
// The four day legs are only sent for a cashier-scoped call. Read as absent rather than
|
|
// zero: a leg that is missing and a leg that is genuinely nil mean different things, and
|
|
// treating the first as the second is what let the sheet claim an expected balance of zero
|
|
// for a whole branch.
|
|
this.hasDayLegs = res?.todayVaultIn != null;
|
|
this.openingFloat = Number(res?.todayVaultIn || 0);
|
|
this.cashDeposits = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0);
|
|
this.cashWithdrawals = Number(res?.withdrawalTotal || 0);
|
|
this.settledOut = Number(res?.todaySettledOut || 0);
|
|
this.interCashierIn = Number(res?.todayInterCashierIn || 0);
|
|
this.interCashierOut = Number(res?.todayInterCashierOut || 0);
|
|
this.scopedToCashier = res?.scopedToCashier === true;
|
|
},
|
|
error: () => {
|
|
this.openingFloat = 0;
|
|
this.cashDeposits = 0;
|
|
this.cashWithdrawals = 0;
|
|
this.settledOut = 0;
|
|
this.interCashierIn = 0;
|
|
this.interCashierOut = 0;
|
|
this.scopedToCashier = false;
|
|
this.hasDayLegs = false;
|
|
}
|
|
});
|
|
|
|
// The branch's expected cash for the date, which is the only basis a branch-wide reading can
|
|
// reconcile on. Fails quietly for a plain cashier, who is not allowed to operate the day.
|
|
this.service.branchDayClosePreview(this.date).subscribe({
|
|
next: (res: any) => {
|
|
this.branchOpeningCash = res?.openingCash == null ? null : Number(res.openingCash);
|
|
this.branchDeposits = res?.deposits == null ? null : Number(res.deposits);
|
|
this.branchWithdrawals = res?.withdrawals == null ? null : Number(res.withdrawals);
|
|
this.branchExpectedCash = res?.expectedCash == null ? null : Number(res.expectedCash);
|
|
},
|
|
error: () => {
|
|
this.branchOpeningCash = null;
|
|
this.branchDeposits = null;
|
|
this.branchWithdrawals = null;
|
|
this.branchExpectedCash = null;
|
|
}
|
|
});
|
|
|
|
// One object for a cashier, an array of every cashier for a clerk or officer — the server
|
|
// decides from the caller's own roles, so this screen does not have to ask who is reading it.
|
|
this.service.cashierPositionReport(undefined, this.date).subscribe({
|
|
next: (res: any) => {
|
|
const list = Array.isArray(res) ? res : res ? [res] : [];
|
|
this.cashierPositions = list
|
|
.filter((p: any) => p && p.cashierId != null)
|
|
.map((p: any) => ({
|
|
cashierId: p.cashierId == null ? null : Number(p.cashierId),
|
|
cashierName: p.cashierName || `Cashier ${p.cashierId}`,
|
|
totalAmount: Number(p.totalAmount || 0)
|
|
}))
|
|
.sort((a: any, b: any) => b.totalAmount - a.totalAmount);
|
|
this.drawersTotal = this.cashierPositions.length
|
|
? this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0)
|
|
: null;
|
|
},
|
|
error: () => {
|
|
// Nothing to count against, so the variance line stays blank rather than reading zero — a
|
|
// variance of zero is a claim that somebody counted and it agreed.
|
|
this.cashierPositions = [];
|
|
this.drawersTotal = null;
|
|
}
|
|
});
|
|
|
|
// Vault stock, so a branch-wide count is of the whole branch and not just the counters.
|
|
this.service.vaultPosition(undefined, this.date).subscribe({
|
|
next: (res: any) => {
|
|
const list = Array.isArray(res) ? res : res ? [res] : [];
|
|
this.vaultTotal = list.reduce((s: number, v: any) => s + Number(v?.totalAmount || 0), 0);
|
|
},
|
|
error: () => {
|
|
this.vaultTotal = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Section 1 ──
|
|
|
|
/**
|
|
* Only the entries that actually moved money.
|
|
*
|
|
* A pending request is listed on the day book so nobody loses sight of it, but it has taken no
|
|
* cash — counting it into an EOD summary would make the sheet disagree with the drawer.
|
|
*/
|
|
/**
|
|
* Last night's batch: posted against yesterday, surfacing on this report.
|
|
*
|
|
* A voucher's timestamp is the minute the job ran, while the transaction and the journal entry
|
|
* behind it carry the business date. So the interest run that fires just after midnight stamps
|
|
* itself with today although the ledger says yesterday — and yesterday was counted, reported and
|
|
* signed off hours before. Re-opening that day would invalidate a signed sheet, so the work is
|
|
* listed here instead, under its own heading, saying which day it belongs to. It is excluded from
|
|
* every total on this page: it is not today's money.
|
|
*/
|
|
get priorDayRows(): any[] {
|
|
return this.rows.filter((r) => r.section === 'SYSTEM_PRIOR');
|
|
}
|
|
|
|
get priorDayCredit(): number {
|
|
return this.priorDayRows.reduce((s, r) => s + (r.side === 'CR' ? Number(r.amount || 0) : 0), 0);
|
|
}
|
|
|
|
get priorDayDebit(): number {
|
|
return this.priorDayRows.reduce((s, r) => s + (r.side === 'DR' ? Number(r.amount || 0) : 0), 0);
|
|
}
|
|
|
|
/** The business date last night's batch worked on, for the heading. */
|
|
get priorDayDate(): string | null {
|
|
const withDate = this.priorDayRows.find((r) => r.effectiveDate);
|
|
return withDate ? withDate.effectiveDate : null;
|
|
}
|
|
|
|
get approvedRows(): any[] {
|
|
return this.rows.filter(
|
|
(r) =>
|
|
r.section !== 'SYSTEM_PRIOR' &&
|
|
(r.status === 'APPROVED' || r.status === 'COMPLETED' || r.status === 'WITHDRAWN')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The day split the way this system actually records it: where the entry came from.
|
|
*
|
|
* Not split by payment mode, which is the obvious shape for a retail till but the wrong one here.
|
|
* A payment type is only recorded against a denomination breakdown, and a breakdown is only
|
|
* written for physical cash — so a UPI or transfer deposit carries no payment type at all, and a
|
|
* mode-wise table would file every non-cash entry under "not specified" while claiming to be a
|
|
* breakdown by mode. The day book's own sections are recorded on every row, so this agrees with
|
|
* the day book for the same date instead of quietly disagreeing with it.
|
|
*/
|
|
get sectionLines(): SummaryLine[] {
|
|
const order = [
|
|
'CASHIER',
|
|
'CLERK',
|
|
'SYSTEM'
|
|
];
|
|
const labels: Record<string, string> = {
|
|
CASHIER: 'Cash counter',
|
|
CLERK: 'Clerk desk',
|
|
SYSTEM: 'System — interest, charges, journals'
|
|
};
|
|
const map = new Map<string, SummaryLine>();
|
|
for (const r of this.approvedRows) {
|
|
const key = order.includes(r.section) ? r.section : 'SYSTEM';
|
|
let line = map.get(key);
|
|
if (!line) {
|
|
line = { label: labels[key] || key, credit: 0, debit: 0, count: 0 };
|
|
map.set(key, line);
|
|
}
|
|
this.addTo(line, r);
|
|
}
|
|
return order.filter((k) => map.has(k)).map((k) => map.get(k)!);
|
|
}
|
|
|
|
/**
|
|
* What kind of entry it was — deposit, withdrawal, interest posting, charge.
|
|
*
|
|
* The sections above say where the day's work came from; this says what the work was, which is
|
|
* the question somebody checking a figure against a register actually asks.
|
|
*/
|
|
get kindLines(): SummaryLine[] {
|
|
const map = new Map<string, SummaryLine>();
|
|
for (const r of this.approvedRows) {
|
|
const key = (r.kind || 'OTHER').toString();
|
|
let line = map.get(key);
|
|
if (!line) {
|
|
line = { label: this.titleCase(key), credit: 0, debit: 0, count: 0 };
|
|
map.set(key, line);
|
|
}
|
|
this.addTo(line, r);
|
|
}
|
|
return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
|
|
}
|
|
|
|
/** Adds one day-book row onto a summary line, on the side the row belongs to. */
|
|
private addTo(line: SummaryLine, row: any): void {
|
|
const amt = Number(row.amount || 0);
|
|
if (row.side === 'CR') {
|
|
line.credit += amt;
|
|
} else {
|
|
line.debit += amt;
|
|
}
|
|
line.count += 1;
|
|
}
|
|
|
|
/** DD CASH, INTEREST_POSTING and the like, as a person would read them. */
|
|
kindLabel(raw: string | null | undefined): string {
|
|
if (!raw) {
|
|
return '—';
|
|
}
|
|
return raw
|
|
.toString()
|
|
.replace(/_/g, ' ')
|
|
.toLowerCase()
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
|
|
private titleCase(raw: string): string {
|
|
return this.kindLabel(raw);
|
|
}
|
|
|
|
get txnCount(): number {
|
|
return this.approvedRows.length;
|
|
}
|
|
|
|
/** More than one drawer in view means a supervisor is reading, not a cashier. */
|
|
get supervising(): boolean {
|
|
return this.cashierPositions.length > 1 || (!this.scopedToCashier && this.cashierPositions.length > 0);
|
|
}
|
|
|
|
get cashierPositionTotal(): number {
|
|
return this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0);
|
|
}
|
|
|
|
/**
|
|
* Each person who handled the day's work, with their own credit, debit and net.
|
|
*
|
|
* The clerk signing the day off is verifying other people's work, so the sheet has to say whose.
|
|
* Read off the day book's own handledBy, which every register stamps.
|
|
*/
|
|
get byUser(): SummaryLine[] {
|
|
const map = new Map<string, SummaryLine>();
|
|
for (const r of this.approvedRows) {
|
|
const key = (r.handledBy || 'Unattributed').toString();
|
|
let line = map.get(key);
|
|
if (!line) {
|
|
line = { label: key, credit: 0, debit: 0, count: 0 };
|
|
map.set(key, line);
|
|
}
|
|
this.addTo(line, r);
|
|
}
|
|
return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
|
|
}
|
|
|
|
get summaryCreditTotal(): number {
|
|
return this.sectionLines.reduce((s, l) => s + l.credit, 0);
|
|
}
|
|
|
|
get summaryDebitTotal(): number {
|
|
return this.sectionLines.reduce((s, l) => s + l.debit, 0);
|
|
}
|
|
|
|
// ── Section 2 ──
|
|
|
|
/** Everything that came into the drawer today, over the counter and from another cashier. */
|
|
get totalReceived(): number {
|
|
return this.cashDeposits + this.interCashierIn;
|
|
}
|
|
|
|
/** Everything that left it: paid to members, handed to the vault, passed to another cashier. */
|
|
get totalPaidOut(): number {
|
|
return this.cashWithdrawals + this.settledOut + this.interCashierOut;
|
|
}
|
|
|
|
/** What should be on the desk: the float, plus what came in, less what went out. */
|
|
get expectedClosing(): number {
|
|
return this.openingFloat + this.totalReceived - this.totalPaidOut;
|
|
}
|
|
|
|
/**
|
|
* Whether an expected-against-held comparison can honestly be made.
|
|
*
|
|
* Only for a single cashier, and only when the server sent that cashier's day legs. Both halves
|
|
* then describe the same thing: today's legs, against a drawer that carries nothing forward
|
|
* because a cashier draws in the morning and hands back at close.
|
|
*
|
|
* Never branch-wide. The branch register's expected cash is *today's* arithmetic — yesterday's
|
|
* closing, plus today's deposits, less today's withdrawals — while the vault and drawer positions
|
|
* are running totals accumulated since the system went live. Subtracting one from the other
|
|
* compares an afternoon against a lifetime: it reported ₹2,11,932 extra on a day when ₹100 came
|
|
* in and ₹100 went out. There is no branch-wide running total to compare against, so the sheet
|
|
* shows the branch its movement and each drawer's position, and states no difference at all
|
|
* rather than a confident wrong one.
|
|
*/
|
|
get canReconcile(): boolean {
|
|
return this.scopedToCashier && this.hasDayLegs;
|
|
}
|
|
|
|
/** The cash the system believes this cashier is holding. */
|
|
get actualCash(): number | null {
|
|
return this.scopedToCashier ? this.drawersTotal : null;
|
|
}
|
|
|
|
/** What today's legs say should be in the drawer. */
|
|
get expectedCash(): number | null {
|
|
return this.canReconcile ? this.expectedClosing : null;
|
|
}
|
|
|
|
/** Held less expected, for one cashier's own day. Null wherever that comparison is not honest. */
|
|
get variance(): number | null {
|
|
const expected = this.expectedCash;
|
|
if (expected === null || this.actualCash === null) {
|
|
return null;
|
|
}
|
|
return this.actualCash - expected;
|
|
}
|
|
|
|
/** Vaults and drawers together — what the branch is holding, per the system. */
|
|
get branchHeldTotal(): number | null {
|
|
if (this.drawersTotal === null && this.vaultTotal === null) {
|
|
return null;
|
|
}
|
|
return Number(this.drawersTotal ?? 0) + Number(this.vaultTotal ?? 0);
|
|
}
|
|
|
|
/** Today's net cash movement across the branch: what came in less what went out. */
|
|
get branchNetMovement(): number {
|
|
return Number(this.branchDeposits ?? 0) - Number(this.branchWithdrawals ?? 0);
|
|
}
|
|
|
|
/** True only when a count exists and it agrees to the paisa. */
|
|
get varianceOk(): boolean {
|
|
return this.variance !== null && Math.abs(this.variance) < 0.005;
|
|
}
|
|
|
|
get varianceClass(): string {
|
|
if (this.variance === null) {
|
|
return '';
|
|
}
|
|
return this.varianceOk ? 'eod-ok' : 'eod-bad';
|
|
}
|
|
|
|
// ── Section 3 ──
|
|
|
|
/** Entries still waiting on somebody, which is what a day cannot be closed over. */
|
|
get pendingRows(): any[] {
|
|
return this.rows.filter((r) => r.status === 'PENDING' || r.status === 'SUBMITTED');
|
|
}
|
|
|
|
/** Cash entries whose paper slip nobody has checked against the screen. */
|
|
get unverifiedRows(): any[] {
|
|
return this.approvedRows.filter((r) => !r.verifyStatus);
|
|
}
|
|
|
|
/** A slip that was checked and did not agree with what was keyed in. */
|
|
get mismatchRows(): any[] {
|
|
return this.rows.filter((r) => r.verifyStatus === 'MISMATCH' || r.verifyStatus === 'DISPUTED');
|
|
}
|
|
|
|
/**
|
|
* Whether the day is ready to close.
|
|
*
|
|
* Nothing pending, no slip disagreeing, and — where a cash comparison was actually made — the
|
|
* cash agreeing. A branch-wide reading makes no comparison, so it is not held to one: requiring
|
|
* `varianceOk` there meant the sheet could never say a day was clear however clean it was.
|
|
*/
|
|
get allClear(): boolean {
|
|
if (this.pendingRows.length > 0 || this.mismatchRows.length > 0) {
|
|
return false;
|
|
}
|
|
return this.variance === null || this.varianceOk;
|
|
}
|
|
|
|
reload(): void {
|
|
this.load();
|
|
}
|
|
|
|
print(): void {
|
|
window.print();
|
|
}
|
|
}
|