feat: cash-only registers, day book sections and DD amendments
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-09-03 18:26:22 +05:30
parent 6442562df0
commit fbce73fd17
111 changed files with 9076 additions and 3507 deletions
@@ -9,11 +9,19 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
/**
* Which side of the register a screen shows.
*
* 'CR' is the Received report (money in), 'DR' the Payment report (money out). Set from the route,
* so the two screens are the same component pointed at opposite sides.
*/
export type VoucherSide = 'CR' | 'DR';
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
voucherNo: string;
@@ -30,6 +38,13 @@ interface VoucherRow {
creditAccount: string | null;
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
extraLines: number;
/**
* Whether a denomination breakdown was recorded against this voucher.
*
* True proves cash was counted. False does not prove the opposite: several registers pay real
* money out without recording a breakdown, so this marks a row as unconfirmed, not as non-cash.
*/
denominationCounted: boolean;
}
interface VoucherReport {
@@ -38,6 +53,8 @@ interface VoucherReport {
username: string | null;
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
canSeeAllUsers: boolean;
/** Whether the server restricted the register to cash movements. */
cashOnly: boolean;
vouchers: VoucherRow[];
count: number;
totalAmount: number;
@@ -60,12 +77,12 @@ interface VoucherReport {
imports: [
CommonModule,
FormsModule,
RouterLink,
MatIconModule
]
})
export class VoucherReportComponent implements OnInit {
private service = inject(CashierService);
private route = inject(ActivatedRoute);
/** Day is the working default; a month is what you ask for at a close. */
period: 'DAY' | 'MONTH' = 'DAY';
@@ -74,21 +91,67 @@ export class VoucherReportComponent implements OnInit {
usernameFilter = '';
typeFilter = '';
/** Which side this screen is: Received (CR) or Payment (DR). Comes from the route's data. */
side: VoucherSide = 'CR';
/**
* Whether this screen is the cash register rather than the whole voucher register.
*
* A cashier handles notes and coins, so entries that cannot be cash — an interest posting, a
* clearing settlement, a cheque book fee — are left out, and everything that could have crossed
* the counter stays in. The filter errs towards keeping a row: a book entry on the register can
* be seen and recognised, whereas cash that never appears is invisible until it turns up as an
* unexplained difference at the end of the day.
*/
cashOnly = false;
report: VoucherReport | null = null;
loading = false;
error: string | null = null;
ngOnInit(): void {
this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR';
this.cashOnly = this.route.snapshot.data['cashOnly'] === true;
this.load();
}
get isReceived(): boolean {
return this.side === 'CR';
}
get title(): string {
return this.isReceived ? 'Received Report' : 'Payment Report';
}
get subtitle(): string {
if (this.cashOnly) {
return this.isReceived
? 'Cash taken in over a day or a month, and who took it — book entries left out.'
: 'Cash paid out over a day or a month, and who paid it — book entries left out.';
}
return this.isReceived
? 'Every receipt voucher raised over a day or a month — money taken in, and who took it.'
: 'Every payment voucher raised over a day or a month — money paid out, and who paid it.';
}
/** The word for one row on this side, for counts and the empty state. */
get noun(): string {
return this.isReceived ? 'receipt' : 'payment';
}
load(): void {
this.loading = true;
this.error = null;
const { from, to } = this.range();
this.service
.voucherReport(from, to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined)
.voucherReport(
from,
to,
this.usernameFilter.trim() || undefined,
this.typeFilter.trim() || undefined,
this.cashOnly
)
.pipe(
catchError((err: any) => {
this.error =
@@ -125,9 +188,95 @@ export class VoucherReportComponent implements OnInit {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
/**
* The rows belonging to this screen's side.
*
* The side is read off the first segment of the voucher number — `CR/001/2608/0003` — which the
* server stamps when it raises the voucher: a deposit credits the member and gets CR, everything
* that takes money out gets DR. That prefix is the authoritative record of the side, so it is
* matched directly rather than re-deriving the side from the voucher type here — a new type added
* on the server then lands on the correct report with no change needed on this screen.
*/
get rows(): VoucherRow[] {
const all = this.report?.vouchers ?? [];
return all.filter((row) => this.sideOf(row) === this.side);
}
/**
* Whether a voucher has its two sides recorded against it.
*
* The registers raise a voucher when the request is made and only attach its sides once it is
* approved and posted, so a voucher raised before that step existed carries a number but no
* lines. Those rows are labelled rather than shown as an empty dash, which reads like the money
* went nowhere.
*/
hasSides(row: VoucherRow): boolean {
return !!(row.debitParty || row.creditParty);
}
/** How many rows on this side are missing their debit/credit lines. */
get missingSidesCount(): number {
return this.rows.filter((row) => !this.hasSides(row)).length;
}
/** A voucher's side, from its number's prefix. Null when the number is missing or unrecognised. */
private sideOf(row: VoucherRow): VoucherSide | null {
const no = (row.voucherNo ?? '').toUpperCase();
if (no.startsWith('CR/')) {
return 'CR';
}
if (no.startsWith('DR/')) {
return 'DR';
}
return null;
}
/** How many rows on this side — not the server's count, which spans both. */
get count(): number {
return this.rows.length;
}
/** Total for this side only. */
get totalAmount(): number {
return this.rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
}
/**
* The part of this side's total whose notes and coins were actually counted.
*
* Always at most `totalAmount`: a voucher can be real cash and still be missing its breakdown,
* because the register that raised it records none. So this is the confirmed part of the cash,
* not the whole of it, and the gap is reported rather than treated as book entries.
*/
get countedTotal(): number {
return this.rows.filter((row) => row.denominationCounted).reduce((sum, row) => sum + (row.amount ?? 0), 0);
}
/** Rows carrying no denomination breakdown — unconfirmed against the drawer, not non-cash. */
get uncountedCount(): number {
return this.rows.filter((row) => !row.denominationCounted).length;
}
/** How much of this side's total has no note-and-coin breakdown behind it. */
get uncountedTotal(): number {
return this.totalAmount - this.countedTotal;
}
/**
* Vouchers whose number carries neither prefix, so they appear on neither report.
*
* Counted and shown rather than silently dropped: a voucher that vanishes from both sides of the
* register is exactly the kind of thing a cashier must be told about, not left to discover when
* the two reports do not add up to the day.
*/
get unclassifiedCount(): number {
const all = this.report?.vouchers ?? [];
return all.filter((row) => this.sideOf(row) === null).length;
}
/** Totals per user, for the summary a manager reads before the rows. */
get byUser(): { username: string; count: number; total: number }[] {
const rows = this.report?.vouchers ?? [];
const rows = this.rows;
const tally = new Map<string, { username: string; count: number; total: number }>();
for (const row of rows) {
const key = row.username ?? '—';
@@ -141,7 +290,7 @@ export class VoucherReportComponent implements OnInit {
/** Totals per voucher type — deposits against withdrawals against journals. */
get byType(): { type: string; count: number; total: number }[] {
const rows = this.report?.vouchers ?? [];
const rows = this.rows;
const tally = new Map<string, { type: string; count: number; total: number }>();
for (const row of rows) {
const key = row.voucherType ?? '—';