feat(cashier): cashier-only cash operations, exchange PAN/denomination checks and Branch Cash Position

- Cash Exchange Entry: customer details with PAN (mandatory above Rs 50,000), cashier card,
  denomination-wise validation against the master and real-time impact preview.
- Inter-Cashier Transfer: sender fixed to the logged-in cashier, both sides' details and
  denomination holdings shown, blocked until both are allocated cash.
- Reconciliation, exchange and inter-cashier operate on the logged-in cashier only.
- New Branch Cash Position screen (vault + cashier drawers, denomination-wise).
- Customer search dialog shows the KYC PAN.
- Cashier menu cleanup: Excess/Closing report and Exchange Detail removed, nav compacted to one row.
- Reverse actions removed from cash transfer, settlement, exchange and inter-cashier.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 18:41:24 +05:30
co-authored by Claude Sonnet 5.5
parent 211027b9d8
commit 915deddb74
19 changed files with 2601 additions and 540 deletions
@@ -29,12 +29,27 @@ interface Cashier {
id: number;
label: string;
}
/** One side of the transfer, from GET /cashintercashier/cashier-summary. */
interface CashierSummary {
cashierId: number;
cashierName: string;
tellerName: string;
officeName: string;
cashAllocated: boolean;
allocatedAmount: number;
totalAmount: number;
/** Count held per denomination id. */
held: Record<number, number>;
}
/** Move cash denomination-wise between two cashiers under the same teller. */
@Component({
selector: 'mifosx-inter-cashier-transfer',
templateUrl: './inter-cashier-transfer.component.html',
styleUrls: ['../cashier-shared.scss'],
styleUrls: [
'../cashier-shared.scss',
'./inter-cashier-transfer.component.scss'
],
standalone: true,
imports: [
CommonModule,
@@ -57,7 +72,12 @@ export class InterCashierTransferComponent implements OnInit {
note = '';
saving = false;
tellers: { id: number; name: string }[] = [];
/**
* The sending cashier is always the one logged in: cash is handed over from their own drawer, so there is
* no one else to pick on that side. Only the receiving cashier is chosen.
*/
ownCashierMissing = false;
tellerName = '';
allCashiers: Cashier[] = [];
selectedTellerId: number | null = null;
fromCashierId: number | null = null;
@@ -66,15 +86,19 @@ export class InterCashierTransferComponent implements OnInit {
toParty = '';
loadingCashiers = false;
/** Details and denomination-wise cash of each side, so both are seen before the transfer is recorded. */
fromSummary: CashierSummary | null = null;
toSummary: CashierSummary | null = null;
loadingFrom = false;
loadingTo = false;
get denoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
get availableFrom(): Cashier[] {
return this.allCashiers;
}
/** Everyone under the same teller except the sender. */
get availableTo(): Cashier[] {
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
}
@@ -98,25 +122,20 @@ export class InterCashierTransferComponent implements OnInit {
this.coins = [];
}
});
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
// The sender is the cashier behind this login; their counter then gives the receivers to pick from.
this.service.getCurrentCashierInfo().subscribe((info) => {
if (!info) {
this.ownCashierMissing = true;
return;
}
this.fromCashierId = info.cashierId;
this.fromParty = info.cashierName;
this.loadSummary('from');
});
}
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.fromCashierId = null;
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.allCashiers = [];
if (!tellerId) return;
/** The other cashiers at this counter, one of whom receives the cash. */
private loadReceivers(tellerId: number): void {
this.loadingCashiers = true;
this.service
.getCashiersForTeller(tellerId)
@@ -127,20 +146,90 @@ export class InterCashierTransferComponent implements OnInit {
});
}
onFromSelect(cashierId: number): void {
this.fromCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.fromParty = c ? c.label : '';
if (this.toCashierId === cashierId) {
this.toCashierId = null;
this.toParty = '';
}
}
onToSelect(cashierId: number): void {
this.toCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.toParty = c ? c.label : '';
this.loadSummary('to');
}
onDateChange(value: string): void {
this.transferDate = value;
this.loadSummary('from');
this.loadSummary('to');
}
/** Fetch one side's details, vault allocation and denomination-wise cash for the transfer date. */
private loadSummary(side: 'from' | 'to'): void {
const cashierId = side === 'from' ? this.fromCashierId : this.toCashierId;
if (side === 'from') {
this.fromSummary = null;
} else {
this.toSummary = null;
}
if (!cashierId || !this.transferDate) return;
this.setLoading(side, true);
this.service
.getInterCashierCashierSummary(cashierId, this.transferDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
this.setLoading(side, false);
// A late answer for a cashier no longer selected on that side is dropped.
const current = side === 'from' ? this.fromCashierId : this.toCashierId;
if (!r || current !== cashierId) return;
// The sender's summary also tells which counter they are at, hence who they can hand cash to.
if (side === 'from' && r.tellerId && this.selectedTellerId !== Number(r.tellerId)) {
this.selectedTellerId = Number(r.tellerId);
this.tellerName = r.tellerName || '';
this.loadReceivers(this.selectedTellerId);
}
const held: Record<number, number> = {};
for (const d of r.denominations || []) {
held[d.denominationId] = (held[d.denominationId] || 0) + Number(d.count || 0);
}
const summary: CashierSummary = {
cashierId: r.cashierId,
cashierName: r.cashierName,
tellerName: r.tellerName,
officeName: r.officeName,
cashAllocated: !!r.cashAllocated,
allocatedAmount: Number(r.allocatedAmount || 0),
totalAmount: Number(r.totalAmount || 0),
held
};
if (side === 'from') {
this.fromSummary = summary;
} else {
this.toSummary = summary;
}
});
}
private setLoading(side: 'from' | 'to', loading: boolean): void {
if (side === 'from') {
this.loadingFrom = loading;
} else {
this.loadingTo = loading;
}
}
/** Count of the denomination the cashier holds, or null while their figures are not loaded. */
heldBy(summary: CashierSummary | null, d: Denom): number | null {
return summary ? summary.held[d.id] || 0 : null;
}
/** The sending cashier is asked to hand on more of this denomination than they hold. */
exceedsHeld(d: Denom): boolean {
return !!this.fromSummary && this.countFor(d) > (this.fromSummary.held[d.id] || 0);
}
get anyExceedsHeld(): boolean {
return this.denoms.some((d) => this.exceedsHeld(d));
}
/** Both sides must have been allocated cash from the vault for the day before they can transfer. */
get bothAllocated(): boolean {
return !!this.fromSummary?.cashAllocated && !!this.toSummary?.cashAllocated;
}
countFor(d: Denom): number {
@@ -160,7 +249,14 @@ export class InterCashierTransferComponent implements OnInit {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
}
get valid(): boolean {
return this.total > 0 && !!this.selectedTellerId && !!this.fromCashierId && !!this.toCashierId;
return (
this.total > 0 &&
!!this.selectedTellerId &&
!!this.fromCashierId &&
!!this.toCashierId &&
this.bothAllocated &&
!this.anyExceedsHeld
);
}
clear(): void {
@@ -187,10 +283,12 @@ export class InterCashierTransferComponent implements OnInit {
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
this.clear();
this.note = '';
this.fromCashierId = null;
// The sender stays the logged-in cashier; the receiver is picked afresh, and the sender's holdings
// are read again now that part of them has gone.
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.toSummary = null;
this.loadSummary('from');
this.saving = false;
},
error: () => {