feat(cashier): carry forward yesterday's denomination count into Day Open, warn on a mismatch
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

The Day Open form's note/coin table started at zero even though the
branch's own closing count from the day before was already on record --
whoever opened the day had to re-key numbers that were already known.
It now auto-fills from that closing count, flags any row a manual edit
moves away from it, and shows a live Tallied/Short/Excess comparison
against it -- so a short or excess opening count is visible before the
day is opened, not discovered later. A reason is required once a mismatch
exists, matching the new backend enforcement.

Also fixes the register table's date chip wrapping onto two lines when
the column narrows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 11:03:24 +05:30
co-authored by Claude Sonnet 5
parent da1f57c3b6
commit fe81d159f1
5 changed files with 221 additions and 14 deletions
@@ -135,11 +135,32 @@
</label> </label>
</div> </div>
<p class="bd-hint"> <p class="bd-hint">
@if (hasAutoFilledOpenCounts) {
Auto-filled below from yesterday's own closing count (₹{{ carriedForwardTotal | number: '1.2-2' }}) — correct
only the rows where today's physical count differs before opening.
} @else {
Left blank with no notes/coins counted below, yesterday's closing figure carries forward automatically — which Left blank with no notes/coins counted below, yesterday's closing figure carries forward automatically — which
is what keeps the opening balance from drifting. Counting the drawer below is what actually records the is what keeps the opening balance from drifting. Counting the drawer below is what actually records the
note/coin stock the branch can check a vault handout against later. note/coin stock the branch can check a vault handout against later.
}
</p> </p>
@if (hasAutoFilledOpenCounts && openLiveDifference !== null) {
@if (openLiveDifference === 0) {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">vs. yesterday's close</span>
<span class="db-cmp__value">Tallied</span>
</div>
} @else {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">vs. yesterday's close</span>
<span class="db-cmp__value">
{{ openLiveDifference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(openLiveDifference) | number: '1.2-2' }}
</span>
</div>
}
}
<div class="pg-card bd-denom"> <div class="pg-card bd-denom">
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
@@ -160,7 +181,7 @@
<td> <td>
<span class="pg-chip">{{ d.label }}</span> <span class="pg-chip">{{ d.label }}</span>
</td> </td>
<td style="display: flex; justify-content: center"> <td style="display: flex; flex-direction: column; align-items: center">
<div class="pg-step"> <div class="pg-step">
<button <button
type="button" type="button"
@@ -177,6 +198,9 @@
/> />
<button type="button" (click)="bumpCount(openCounts, d, 1)">+</button> <button type="button" (click)="bumpCount(openCounts, d, 1)">+</button>
</div> </div>
@if (hasAutoFilledOpenCounts && countFor(openCounts, d) !== autoOpenCountFor(d)) {
<span class="db-muted bd-denom__auto">yesterday closed {{ autoOpenCountFor(d) }}</span>
}
</td> </td>
<td class="num">₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}</td> <td class="num">₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}</td>
</tr> </tr>
@@ -191,7 +215,7 @@
<td> <td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span> <span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td> </td>
<td style="display: flex; justify-content: center"> <td style="display: flex; flex-direction: column; align-items: center">
<div class="pg-step"> <div class="pg-step">
<button <button
type="button" type="button"
@@ -208,6 +232,9 @@
/> />
<button type="button" (click)="bumpCount(openCounts, d, 1)">+</button> <button type="button" (click)="bumpCount(openCounts, d, 1)">+</button>
</div> </div>
@if (hasAutoFilledOpenCounts && countFor(openCounts, d) !== autoOpenCountFor(d)) {
<span class="db-muted bd-denom__auto">yesterday closed {{ autoOpenCountFor(d) }}</span>
}
</td> </td>
<td class="num">₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}</td> <td class="num">₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}</td>
</tr> </tr>
@@ -218,11 +245,27 @@
@if (openDenomTotal > 0) { @if (openDenomTotal > 0) {
<p class="bd-hint"> <p class="bd-hint">
Denomination total: <strong>₹{{ openDenomTotal | number: '1.2-2' }}</strong> Denomination total: <strong>₹{{ openDenomTotal | number: '1.2-2' }}</strong>
@if (hasAutoFilledOpenCounts) {
<button type="button" class="pg-btn bd-denom__reset" (click)="resetOpenToAutoFill()">
Reset to yesterday's count
</button>
}
</p> </p>
} }
@if (openLiveDifference !== null && openLiveDifference !== 0) {
<label class="pg-field bd-panel__wide">
<span>Why does this differ from yesterday's closing count? (required)</span>
<input
type="text"
[(ngModel)]="openDifferenceReason"
placeholder="e.g. notes exchanged for smaller denominations, extra cash added from head office"
/>
</label>
}
<div class="db-panel__actions"> <div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitOpen()" [disabled]="opening || !openDate"> <button type="button" class="pg-btn pg-btn--primary" (click)="submitOpen()" [disabled]="!canSubmitOpen">
Open Day Open Day
</button> </button>
<button type="button" class="pg-btn" (click)="cancelOpen()">Cancel</button> <button type="button" class="pg-btn" (click)="cancelOpen()">Cancel</button>
@@ -414,9 +457,14 @@
</div> </div>
<p class="bd-hint"> <p class="bd-hint">
@if (hasAutoFilledCounts) {
Auto-filled below from the vault and every cashier's position shown above — correct only the rows where the
physical count differs, note by note.
} @else {
Pre-filled with what the books expect — change it only if the drawer counted something different, and that Pre-filled with what the books expect — change it only if the drawer counted something different, and that
difference is then recorded against the day. Counting the denominations below overrides the typed figure with difference is then recorded against the day. Counting the denominations below overrides the typed figure with
what was actually counted, note by note. what was actually counted, note by note.
}
</p> </p>
<div class="pg-card bd-denom"> <div class="pg-card bd-denom">
@@ -439,7 +487,7 @@
<td> <td>
<span class="pg-chip">{{ d.label }}</span> <span class="pg-chip">{{ d.label }}</span>
</td> </td>
<td style="display: flex; justify-content: center"> <td style="display: flex; flex-direction: column; align-items: center">
<div class="pg-step"> <div class="pg-step">
<button <button
type="button" type="button"
@@ -456,6 +504,9 @@
/> />
<button type="button" (click)="bumpCount(closeCounts, d, 1)">+</button> <button type="button" (click)="bumpCount(closeCounts, d, 1)">+</button>
</div> </div>
@if (hasAutoFilledCounts && countFor(closeCounts, d) !== autoCountFor(d)) {
<span class="db-muted bd-denom__auto">system counted {{ autoCountFor(d) }}</span>
}
</td> </td>
<td class="num">₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}</td> <td class="num">₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}</td>
</tr> </tr>
@@ -470,7 +521,7 @@
<td> <td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span> <span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td> </td>
<td style="display: flex; justify-content: center"> <td style="display: flex; flex-direction: column; align-items: center">
<div class="pg-step"> <div class="pg-step">
<button <button
type="button" type="button"
@@ -487,6 +538,9 @@
/> />
<button type="button" (click)="bumpCount(closeCounts, d, 1)">+</button> <button type="button" (click)="bumpCount(closeCounts, d, 1)">+</button>
</div> </div>
@if (hasAutoFilledCounts && countFor(closeCounts, d) !== autoCountFor(d)) {
<span class="db-muted bd-denom__auto">system counted {{ autoCountFor(d) }}</span>
}
</td> </td>
<td class="num">₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}</td> <td class="num">₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}</td>
</tr> </tr>
@@ -497,6 +551,11 @@
@if (closeDenomTotal > 0) { @if (closeDenomTotal > 0) {
<p class="bd-hint"> <p class="bd-hint">
Denomination total: <strong>₹{{ closeDenomTotal | number: '1.2-2' }}</strong> Denomination total: <strong>₹{{ closeDenomTotal | number: '1.2-2' }}</strong>
@if (hasAutoFilledCounts) {
<button type="button" class="pg-btn bd-denom__reset" (click)="resetToAutoFill()">
Reset to auto-filled count
</button>
}
</p> </p>
} }
@@ -184,6 +184,21 @@
.bd-denom { .bd-denom {
margin: 4px 0 0; margin: 4px 0 0;
max-width: 520px; max-width: 520px;
/* Flags a row where the hand-count no longer matches what the vault/cashier positions reported. */
&__auto {
display: block;
margin-top: 2px;
font-size: 10px;
white-space: nowrap;
}
&__reset {
margin-left: 10px;
height: 24px;
padding: 0 10px;
font-size: 11px;
}
} }
/* Auto-fetched EOD summary: what the vault and every cashier under it currently hold. */ /* Auto-fetched EOD summary: what the vault and every cashier under it currently hold. */
@@ -90,6 +90,9 @@ export class BranchDayComponent implements OnInit {
showOpenForm = false; showOpenForm = false;
openDate = ''; openDate = '';
openingCash: number | null = null; openingCash: number | null = null;
/** Required only when the counted/typed opening figure differs from yesterday's own closing
* figure — the backend rejects the open without it in that case. */
openDifferenceReason = '';
opening = false; opening = false;
// Close-day form. The date is fixed by which day is being closed, never typed. // Close-day form. The date is fixed by which day is being closed, never typed.
@@ -106,6 +109,15 @@ export class BranchDayComponent implements OnInit {
coins: Denom[] = []; coins: Denom[] = [];
openCounts: Record<number, number> = {}; openCounts: Record<number, number> = {};
closeCounts: Record<number, number> = {}; closeCounts: Record<number, number> = {};
/** What yesterday's own close counted, note by note — the open form starts from this instead of
* zero. The backend already carries this figure forward on its own when nothing is submitted for
* the day being opened; showing it here is only about letting whoever opens the day see and
* correct it, not a second place the carry-forward logic lives. */
openCountsAuto: Record<number, number> = {};
/** What the vault + every cashier's live position added up to when the close form opened — the
* physical-count table starts from this instead of zero, since the EOD summary already fetched
* it. Kept separately from closeCounts so a manual recount can be told apart from it and undone. */
closeCountsAuto: Record<number, number> = {};
get allDenoms(): Denom[] { get allDenoms(): Denom[] {
return [ return [
@@ -212,21 +224,98 @@ export class BranchDayComponent implements OnInit {
// over it is how a carried balance drifts. // over it is how a carried balance drifts.
this.openingCash = null; this.openingCash = null;
this.openCounts = {}; this.openCounts = {};
this.openCountsAuto = {};
this.openDifferenceReason = '';
this.successMsg = ''; this.successMsg = '';
this.errorMsg = ''; this.errorMsg = '';
this.loadCarriedForwardDenominations();
} }
cancelOpen(): void { cancelOpen(): void {
this.showOpenForm = false; this.showOpenForm = false;
this.openCountsAuto = {};
}
/**
* Pulls yesterday's own closing note/coin count so the open form shows what is about to be
* carried forward instead of a blank table the caller has to trust blindly. Only the most
* recently closed day is used — the same day the backend itself falls back to when the caller
* submits no denominations and no lump sum.
*/
private loadCarriedForwardDenominations(): void {
const lastClosed = this.days.find((d) => d.status === 'CLOSED' && d.closingCash != null);
if (!lastClosed) return;
this.service
.branchDayDenominations(lastClosed.officeId, lastClosed.businessDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
const closing = res?.closing || [];
if (!closing.length) return;
const totals: Record<number, number> = {};
for (const d of closing) {
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
}
this.openCountsAuto = totals;
// Only overwrite the table if nothing has been typed/counted since the form opened.
if (this.openDenomTotal === 0 && this.openingCash == null) {
this.openCounts = { ...totals };
}
});
}
get hasAutoFilledOpenCounts(): boolean {
return Object.keys(this.openCountsAuto).length > 0;
}
autoOpenCountFor(d: Denom): number {
return this.openCountsAuto[d.id] || 0;
}
resetOpenToAutoFill(): void {
this.openCounts = { ...this.openCountsAuto };
}
get carriedForwardTotal(): number {
return this.totalOf(this.openCountsAuto);
}
/** The figure the form would actually submit right now — the denomination total when any note/coin
* has been counted, otherwise the plain typed-in amount. Mirrors {@link effectiveClosingFigure}. */
get effectiveOpeningFigure(): number | null {
return this.openDenomTotal > 0 ? this.openDenomTotal : this.openingCash;
}
/** Difference between what is about to be opened with and what yesterday actually closed with —
* visible before saving, the same way the close form shows a live difference against the book
* figure, so a short/excess opening count isn't discovered only after the day is already running. */
get openLiveDifference(): number | null {
if (!this.hasAutoFilledOpenCounts || this.effectiveOpeningFigure == null) return null;
return this.effectiveOpeningFigure - this.carriedForwardTotal;
}
/** True once a reason is needed but hasn't been typed — the same "explain the gap before it's
* allowed" gate the close form already applies to a late close. */
get needsOpenDifferenceReason(): boolean {
return this.openLiveDifference != null && this.openLiveDifference !== 0 && !this.openDifferenceReason.trim();
}
get canSubmitOpen(): boolean {
return !!this.openDate && !this.opening && !this.needsOpenDifferenceReason;
} }
submitOpen(): void { submitOpen(): void {
if (!this.openDate) return; if (!this.openDate || this.needsOpenDifferenceReason) return;
this.opening = true; this.opening = true;
this.errorMsg = ''; this.errorMsg = '';
const denominations = this.denominationPayload(this.openCounts); const denominations = this.denominationPayload(this.openCounts);
this.service this.service
.openBranchDay(this.openDate, this.openingCash ?? undefined, undefined, denominations) .openBranchDay(
this.openDate,
this.openingCash ?? undefined,
undefined,
denominations,
this.openDifferenceReason.trim() || undefined
)
.pipe( .pipe(
catchError((err: any) => { catchError((err: any) => {
this.errorMsg = this.errorMsg =
@@ -252,6 +341,7 @@ export class BranchDayComponent implements OnInit {
this.closeTarget = day; this.closeTarget = day;
this.closingCash = null; this.closingCash = null;
this.closeCounts = {}; this.closeCounts = {};
this.closeCountsAuto = {};
this.closeRemarks = ''; this.closeRemarks = '';
this.forceReason = ''; this.forceReason = '';
this.successMsg = ''; this.successMsg = '';
@@ -327,9 +417,47 @@ export class BranchDayComponent implements OnInit {
.subscribe((results: any[]) => { .subscribe((results: any[]) => {
this.eodLoading = false; this.eodLoading = false;
this.eodTellers = results; this.eodTellers = results;
this.autoFillCloseCounts();
}); });
} }
/**
* Seeds the physical-count table from what the vault and every cashier are already shown to be
* holding, instead of leaving it at zero and making whoever closes the day re-key numbers that
* are already on screen above. It stays a starting point, not a lock — any row can still be
* corrected against what was actually counted, and {@link resetToAutoFill} undoes a correction.
*/
private autoFillCloseCounts(): void {
const totals: Record<number, number> = {};
for (const t of this.eodTellers) {
for (const d of t.vault?.denominations || []) {
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
}
for (const c of t.cashiers || []) {
for (const d of c.denominations || []) {
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
}
}
}
if (Object.keys(totals).length === 0) return;
this.closeCountsAuto = totals;
this.closeCounts = { ...totals };
}
get hasAutoFilledCounts(): boolean {
return Object.keys(this.closeCountsAuto).length > 0;
}
/** The auto-fetched count for one denomination, so a row that was hand-edited away from it can
* still show what the vault + cashier positions said. */
autoCountFor(d: Denom): number {
return this.closeCountsAuto[d.id] || 0;
}
resetToAutoFill(): void {
this.closeCounts = { ...this.closeCountsAuto };
}
private loadTellerSummary(teller: any, date: string) { private loadTellerSummary(teller: any, date: string) {
return forkJoin({ return forkJoin({
vault: this.service.vaultPosition(teller.id, date).pipe(catchError(() => of(null))), vault: this.service.vaultPosition(teller.id, date).pipe(catchError(() => of(null))),
@@ -403,6 +531,7 @@ export class BranchDayComponent implements OnInit {
cancelClose(): void { cancelClose(): void {
this.closeTarget = null; this.closeTarget = null;
this.eodTellers = []; this.eodTellers = [];
this.closeCountsAuto = {};
} }
/** A date earlier than today is a late close, and has to say why it was left open. */ /** A date earlier than today is a late close, and has to say why it was left open. */
+1
View File
@@ -206,6 +206,7 @@
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
white-space: nowrap;
background: linear-gradient(135deg, #ecfeff, #cffafe); background: linear-gradient(135deg, #ecfeff, #cffafe);
color: #155e75; color: #155e75;
border: 1px solid #a5f3fc; border: 1px solid #a5f3fc;
+4 -1
View File
@@ -229,17 +229,20 @@ export class CashierService {
return this.http.get('/branchday/close-preview', { params }); return this.http.get('/branchday/close-preview', { params });
} }
/** openingDifferenceReason is required when the opening figure differs from yesterday's closing. */
openBranchDay( openBranchDay(
businessDate: string, businessDate: string,
openingCash?: number, openingCash?: number,
officeId?: number, officeId?: number,
denominations?: { denominationId: number; denominationValue: number; count: number }[] denominations?: { denominationId: number; denominationValue: number; count: number }[],
openingDifferenceReason?: string
): Observable<any> { ): Observable<any> {
return this.http.post('/branchday/open', { return this.http.post('/branchday/open', {
businessDate, businessDate,
openingCash: openingCash != null ? String(openingCash) : null, openingCash: openingCash != null ? String(openingCash) : null,
officeId: officeId ?? null, officeId: officeId ?? null,
denominations: denominations?.length ? denominations : null, denominations: denominations?.length ? denominations : null,
openingDifferenceReason: openingDifferenceReason ?? null,
locale: 'en' locale: 'en'
}); });
} }