feat(cashier): integrate expected balance, settlement verification, and reconciliation checks
This commit is contained in:
@@ -9,31 +9,48 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { of } from 'rxjs';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
interface Denom {
|
||||
id: number;
|
||||
value: number;
|
||||
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
|
||||
|
||||
interface SettleDenomRow {
|
||||
denominationId: number;
|
||||
denominationValue: number;
|
||||
kind: 'note' | 'coin';
|
||||
label: string;
|
||||
expectedCount: number;
|
||||
physicalCount: number;
|
||||
}
|
||||
|
||||
/** Cashier hands back denomination-wise cash to teller/vault (closing settlement). */
|
||||
/**
|
||||
* End-of-day cash settlement with full validation:
|
||||
* - Auto-detects logged-in cashier via /cashreports/cashier-position/me
|
||||
* - Supervisor/admin mode: manual teller + cashier selection when not a cashier
|
||||
* - Blocks if reconciliation not done today
|
||||
* - Blocks if already settled today
|
||||
* - Pre-fills physical counts from system expected balance
|
||||
* - Requires variance reason when physical ≠ expected
|
||||
* - Backend enforces reconciliation-before-settlement and no duplicate per day
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-cash-settlement',
|
||||
templateUrl: './cash-settlement.component.html',
|
||||
styleUrls: ['../cashier-shared.scss'],
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./cash-settlement.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
@@ -44,102 +61,171 @@ interface Denom {
|
||||
export class CashSettlementComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private router = inject(Router);
|
||||
|
||||
notes: Denom[] = [];
|
||||
coins: Denom[] = [];
|
||||
counts: Record<number, number> = {};
|
||||
settlementDate = '';
|
||||
note = '';
|
||||
saving = false;
|
||||
pageState: PageState = 'loading';
|
||||
isSupervisor = false;
|
||||
|
||||
tellers: { id: number; name: string }[] = [];
|
||||
cashiers: { id: number; label: string }[] = [];
|
||||
selectedTellerId: number | null = null;
|
||||
fromCashierId: number | null = null;
|
||||
fromParty = '';
|
||||
toParty = '';
|
||||
// Cashier being settled (auto-detected for cashier, manually selected for admin)
|
||||
myCashierId: number | null = null;
|
||||
myCashierName = '';
|
||||
|
||||
// Supervisor mode: teller selector to filter cashiers
|
||||
supervisorTellerId: number | null = null;
|
||||
supervisorCashiers: { id: number; label: string }[] = [];
|
||||
loadingCashiers = false;
|
||||
supervisorChecking = false;
|
||||
|
||||
get denoms(): Denom[] {
|
||||
return [
|
||||
...this.notes,
|
||||
...this.coins
|
||||
];
|
||||
}
|
||||
rows: SettleDenomRow[] = [];
|
||||
|
||||
settlementDate = '';
|
||||
// Vault teller to deposit TO
|
||||
tellers: { id: number; name: string }[] = [];
|
||||
selectedTellerId: number | null = null;
|
||||
toParty = '';
|
||||
note = '';
|
||||
varianceReason = '';
|
||||
saving = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
const d = new Date();
|
||||
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
this.service.getActiveDenominations().subscribe({
|
||||
next: (list) => {
|
||||
const rows: Denom[] = (list || []).map((x: any) => ({
|
||||
id: x.id,
|
||||
value: x.denomination,
|
||||
kind: x.kind,
|
||||
label: '₹' + x.denomination
|
||||
}));
|
||||
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
|
||||
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
|
||||
},
|
||||
error: () => {
|
||||
this.notes = [];
|
||||
this.coins = [];
|
||||
}
|
||||
});
|
||||
this.service
|
||||
.getTellers()
|
||||
.pipe(catchError(() => of([] as any[])))
|
||||
.subscribe((list) => {
|
||||
.subscribe((list: any[]) => {
|
||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||
});
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
loadData(): void {
|
||||
this.pageState = 'loading';
|
||||
this.rows = [];
|
||||
this.service
|
||||
.getCashierPositionForMe(this.settlementDate)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((pos: any) => {
|
||||
if (!pos?.cashierId) {
|
||||
// Not a cashier — supervisor/admin mode
|
||||
this.isSupervisor = true;
|
||||
this.pageState = 'no-cashier';
|
||||
return;
|
||||
}
|
||||
this.isSupervisor = false;
|
||||
this.myCashierId = pos.cashierId;
|
||||
this.myCashierName = pos.cashierName || '';
|
||||
this.loadCashierChecks(pos.cashierId);
|
||||
});
|
||||
}
|
||||
|
||||
onTellerSelect(tellerId: number): void {
|
||||
this.selectedTellerId = tellerId;
|
||||
const teller = this.tellers.find((t) => t.id === tellerId);
|
||||
this.toParty = teller ? teller.name : '';
|
||||
this.fromCashierId = null;
|
||||
this.fromParty = '';
|
||||
this.cashiers = [];
|
||||
// Called after cashier is known (auto or supervisor-selected)
|
||||
private loadCashierChecks(cashierId: number): void {
|
||||
forkJoin({
|
||||
expected: this.service.getExpectedBalance(cashierId, this.settlementDate).pipe(catchError(() => of(null))),
|
||||
check: this.service
|
||||
.checkSettlement(cashierId, this.settlementDate)
|
||||
.pipe(catchError(() => of({ alreadySettled: false }))),
|
||||
recon: this.service
|
||||
.checkReconciliation(cashierId, this.settlementDate)
|
||||
.pipe(catchError(() => of({ reconciled: false })))
|
||||
}).subscribe(({ expected, check, recon }) => {
|
||||
this.supervisorChecking = false;
|
||||
if (check?.alreadySettled) {
|
||||
this.pageState = 'already-settled';
|
||||
return;
|
||||
}
|
||||
if (!recon?.reconciled) {
|
||||
this.pageState = 'needs-reconciliation';
|
||||
return;
|
||||
}
|
||||
this.rows = ((expected?.denominations as any[]) || [])
|
||||
.filter((d: any) => (d.expectedCount ?? 0) > 0)
|
||||
.sort((a: any, b: any) => b.denominationValue - a.denominationValue)
|
||||
.map((d: any) => ({
|
||||
denominationId: d.denominationId,
|
||||
denominationValue: d.denominationValue,
|
||||
kind: (d.kind ?? 'note') as 'note' | 'coin',
|
||||
expectedCount: d.expectedCount,
|
||||
physicalCount: d.expectedCount
|
||||
}));
|
||||
this.pageState = 'form';
|
||||
});
|
||||
}
|
||||
|
||||
// Supervisor: teller selected → load cashiers for that teller
|
||||
onSupervisorTellerSelect(tellerId: number): void {
|
||||
this.supervisorTellerId = tellerId;
|
||||
this.supervisorCashiers = [];
|
||||
this.myCashierId = null;
|
||||
this.myCashierName = '';
|
||||
this.rows = [];
|
||||
if (!tellerId) return;
|
||||
this.loadingCashiers = true;
|
||||
this.service
|
||||
.getCashiersForTeller(tellerId)
|
||||
.pipe(catchError(() => of({ cashiers: [] })))
|
||||
.subscribe((r: any) => {
|
||||
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
||||
this.loadingCashiers = false;
|
||||
this.supervisorCashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
||||
});
|
||||
}
|
||||
|
||||
onCashierSelect(cashierId: number): void {
|
||||
this.fromCashierId = cashierId;
|
||||
const c = this.cashiers.find((x) => x.id === cashierId);
|
||||
this.fromParty = c ? c.label : '';
|
||||
// Supervisor: cashier selected → run checks
|
||||
onSupervisorCashierSelect(cashierId: number): void {
|
||||
const c = this.supervisorCashiers.find((x) => x.id === cashierId);
|
||||
this.myCashierId = cashierId;
|
||||
this.myCashierName = c ? c.label : '';
|
||||
this.rows = [];
|
||||
this.supervisorChecking = true;
|
||||
this.loadCashierChecks(cashierId);
|
||||
}
|
||||
|
||||
countFor(d: Denom): number {
|
||||
return this.counts[d.id] || 0;
|
||||
get noteRows(): SettleDenomRow[] {
|
||||
return this.rows.filter((r) => r.kind !== 'coin');
|
||||
}
|
||||
subtotal(d: Denom): number {
|
||||
return this.countFor(d) * d.value;
|
||||
}
|
||||
onChange(d: Denom, raw: any): void {
|
||||
this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
|
||||
}
|
||||
bump(d: Denom, delta: number): void {
|
||||
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
|
||||
get coinRows(): SettleDenomRow[] {
|
||||
return this.rows.filter((r) => r.kind === 'coin');
|
||||
}
|
||||
|
||||
get total(): number {
|
||||
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
|
||||
varianceFor(row: SettleDenomRow): number {
|
||||
return row.physicalCount - row.expectedCount;
|
||||
}
|
||||
|
||||
get totalExpected(): number {
|
||||
return this.rows.reduce((s, r) => s + r.denominationValue * r.expectedCount, 0);
|
||||
}
|
||||
get totalPhysical(): number {
|
||||
return this.rows.reduce((s, r) => s + r.denominationValue * r.physicalCount, 0);
|
||||
}
|
||||
get totalVariance(): number {
|
||||
return this.totalPhysical - this.totalExpected;
|
||||
}
|
||||
get hasVariance(): boolean {
|
||||
return this.totalVariance !== 0;
|
||||
}
|
||||
|
||||
updatePhysical(row: SettleDenomRow, raw: any): void {
|
||||
row.physicalCount = Math.max(0, Math.floor(Number(raw) || 0));
|
||||
}
|
||||
bump(row: SettleDenomRow, delta: number): void {
|
||||
row.physicalCount = Math.max(0, row.physicalCount + delta);
|
||||
}
|
||||
|
||||
goToReconciliation(): void {
|
||||
this.router.navigate(['/cashier/reconciliation']);
|
||||
}
|
||||
|
||||
onTellerSelect(id: number): void {
|
||||
this.selectedTellerId = id;
|
||||
const t = this.tellers.find((x) => x.id === id);
|
||||
this.toParty = t ? t.name : '';
|
||||
}
|
||||
|
||||
get valid(): boolean {
|
||||
return this.total > 0 && !!this.fromCashierId && !!this.selectedTellerId;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.counts = {};
|
||||
return (
|
||||
!!this.selectedTellerId && this.totalPhysical > 0 && (!this.hasVariance || this.varianceReason.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
@@ -147,28 +233,41 @@ export class CashSettlementComponent implements OnInit {
|
||||
this.saving = true;
|
||||
const payload = {
|
||||
settlementDate: this.settlementDate,
|
||||
fromCashierId: this.fromCashierId,
|
||||
fromCashierId: this.myCashierId,
|
||||
toTellerId: this.selectedTellerId,
|
||||
fromParty: this.fromParty.trim(),
|
||||
toParty: this.toParty.trim(),
|
||||
note: this.note || null,
|
||||
denominations: this.denoms
|
||||
.filter((d) => this.countFor(d) > 0)
|
||||
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
|
||||
fromParty: this.myCashierName,
|
||||
toParty: this.toParty,
|
||||
note:
|
||||
[
|
||||
this.note,
|
||||
this.hasVariance ? `Variance reason: ${this.varianceReason}` : null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ') || null,
|
||||
denominations: this.rows
|
||||
.filter((r) => r.physicalCount > 0)
|
||||
.map((r) => ({
|
||||
denominationId: r.denominationId,
|
||||
denominationValue: r.denominationValue,
|
||||
count: r.physicalCount
|
||||
}))
|
||||
};
|
||||
this.service.createCashSettlement(payload).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Cash settlement recorded', 'Close', { duration: 3000 });
|
||||
this.clear();
|
||||
this.note = '';
|
||||
this.fromCashierId = null;
|
||||
this.fromParty = '';
|
||||
this.selectedTellerId = null;
|
||||
this.toParty = '';
|
||||
this.cashiers = [];
|
||||
next: (res: any) => {
|
||||
if (res?.glWarning) {
|
||||
this.snackBar.open(`Settlement saved (GL warning: ${res.glWarning})`, 'Close', { duration: 6000 });
|
||||
} else {
|
||||
this.snackBar.open('Cash settlement recorded successfully', 'Close', { duration: 3000 });
|
||||
}
|
||||
this.pageState = 'already-settled';
|
||||
this.saving = false;
|
||||
},
|
||||
error: () => {
|
||||
error: (err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
'Settlement failed. Please try again.';
|
||||
this.snackBar.open(msg, 'Close', { duration: 6000 });
|
||||
this.saving = false;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user