feat(cashier): parallelize account fetch, add CA maker-checker deposit and withdrawal routing
- Optimize loadAccountDetails: extract clientId from search result and pass as hint - fetchAllDetails fast path: parallel forkJoin for account+client calls when clientId known - openConfirmDialog/confirmSubmit: extend deposit maker-checker to CA and CB account types - buildDepositPayload: send accountType CURRENT for CA/CB accounts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
e5d6cd32dc
commit
8e64f82c0a
@@ -1193,7 +1193,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
: null;
|
||||
const match = exactMatch ?? partialMatch ?? null;
|
||||
if (match?.id) {
|
||||
this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp);
|
||||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||||
this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp, clientIdFromSearch);
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = false;
|
||||
@@ -1359,142 +1360,143 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
detailParams?: string;
|
||||
},
|
||||
token: number,
|
||||
acTyp: string = ''
|
||||
acTyp: string = '',
|
||||
clientIdHint: string | null = null
|
||||
): void {
|
||||
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
|
||||
const todayStr = (() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
})();
|
||||
|
||||
// Shared: apply account-level fields after fetch
|
||||
const applyAccountData = (acc: any): boolean => {
|
||||
if (!acc) {
|
||||
this.loadingAccount = false;
|
||||
this.accountError = 'Could not load account details.';
|
||||
return false;
|
||||
}
|
||||
const returnedId = acc.glCode ?? acc.accountNo;
|
||||
if (returnedId && returnedId !== acNo) {
|
||||
this.loadingAccount = false;
|
||||
this.accountError = 'Account mismatch. Please re-enter account number and try again.';
|
||||
return false;
|
||||
}
|
||||
this.accountDetails = acc;
|
||||
this.jointHolders = acc.jointHolders ?? [];
|
||||
if (this.selectedRow) {
|
||||
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
|
||||
}
|
||||
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
||||
this.computeSameDayNeftRtgs(acc.transactions);
|
||||
this.computeSameDayChequeClearing(acc.transactions);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Shared: apply client-level fields after fetch
|
||||
const applyClientData = (
|
||||
clientId: string,
|
||||
r: {
|
||||
client: any;
|
||||
kyc: any;
|
||||
nominees: any;
|
||||
addresses: any;
|
||||
family: any;
|
||||
photo: any;
|
||||
documents: any;
|
||||
notes: any;
|
||||
linkedAcc: any;
|
||||
si: any;
|
||||
todayTxns: any;
|
||||
}
|
||||
): void => {
|
||||
this.loadingAccount = false;
|
||||
this.clientDetails = { ...r.client, familyMembers: Array.isArray(r.family) ? r.family : [] };
|
||||
const kycArr: any[] = Array.isArray(r.kyc) ? r.kyc : r.kyc ? [r.kyc] : [];
|
||||
this.kycDetails = kycArr[0] ?? null;
|
||||
this.nominees = Array.isArray(r.nominees) ? r.nominees : [];
|
||||
this.nonPersonDetails = r.client?.clientNonPersonDetails ?? null;
|
||||
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
|
||||
this.clientPhotoUrl = r.photo ?? null;
|
||||
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
|
||||
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
|
||||
if (sigDoc) {
|
||||
this.http
|
||||
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((blob: Blob | null) => {
|
||||
if (blob) {
|
||||
this.signatureUrl = URL.createObjectURL(blob);
|
||||
this.persistClerkState();
|
||||
}
|
||||
});
|
||||
}
|
||||
this.accountNotes = Array.isArray(r.notes) ? r.notes : [];
|
||||
this.linkedAccounts = r.linkedAcc;
|
||||
this.standingInstructions = r.si?.pageItems ?? (Array.isArray(r.si) ? r.si : []);
|
||||
this.todayTransactions =
|
||||
r.todayTxns?.pageItems ?? r.todayTxns?.content ?? (Array.isArray(r.todayTxns) ? r.todayTxns : []);
|
||||
this.persistClerkState();
|
||||
};
|
||||
|
||||
// Build all client-side observable calls (reused in both paths)
|
||||
const buildClientCalls = (clientId: string) => ({
|
||||
client: this.http
|
||||
.get<any>(`/clients/${clientId}?associations=clientNonPersonDetails`)
|
||||
.pipe(catchError(() => of(null))),
|
||||
kyc: this.http.get<any>(`/clients/${clientId}/kyc`).pipe(catchError(() => of([]))),
|
||||
nominees: this.http
|
||||
.get<any>(`/clients/${clientId}/nominees?accountNo=${acNo}&accountType=${cfg.nominee}`)
|
||||
.pipe(catchError(() => of([]))),
|
||||
addresses: this.http.get<any>(`/client/${clientId}/addresses`).pipe(catchError(() => of([]))),
|
||||
family: this.http.get<any>(`/clients/${clientId}/familymembers`).pipe(catchError(() => of([]))),
|
||||
photo: this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null))),
|
||||
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
||||
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
||||
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
||||
si: this.http
|
||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||
todayTxns: cfg.txnSearchSupported
|
||||
? this.http
|
||||
.get<any>(
|
||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||
)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
: this.http
|
||||
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
});
|
||||
|
||||
if (clientIdHint) {
|
||||
// FAST PATH: account detail + all client calls fire simultaneously (1 round-trip saved)
|
||||
forkJoin({
|
||||
account: this.http.get<any>(accountUrl).pipe(catchError(() => of(null))),
|
||||
...buildClientCalls(clientIdHint)
|
||||
}).subscribe((result: any) => {
|
||||
if (token !== this.searchToken) return;
|
||||
const { account, ...clientData } = result;
|
||||
if (!applyAccountData(account)) return;
|
||||
applyClientData(clientIdHint, clientData);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SEQUENTIAL PATH: for GL accounts (no clientId in search result)
|
||||
this.http
|
||||
.get<any>(`${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`)
|
||||
.get<any>(accountUrl)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((acc: any) => {
|
||||
if (token !== this.searchToken) return; // stale response — newer search active
|
||||
if (!acc) {
|
||||
this.loadingAccount = false;
|
||||
this.accountError = 'Could not load account details.';
|
||||
return;
|
||||
}
|
||||
// Cross-check: returned identifier must match what cashier searched
|
||||
const returnedId = acc.glCode ?? acc.accountNo;
|
||||
if (returnedId && returnedId !== acNo) {
|
||||
this.loadingAccount = false;
|
||||
this.accountError = `Account mismatch. Please re-enter account number and try again.`;
|
||||
return;
|
||||
}
|
||||
this.accountDetails = acc;
|
||||
this.jointHolders = acc.jointHolders ?? [];
|
||||
// Auto-fill ECS A/C NO.: GL → glCode, SHR → savingsAccountNumber, others → accountNo
|
||||
if (this.selectedRow) {
|
||||
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
|
||||
}
|
||||
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
||||
// Compute same-day compliance indicators from included transactions
|
||||
this.computeSameDayNeftRtgs(acc.transactions);
|
||||
this.computeSameDayChequeClearing(acc.transactions);
|
||||
|
||||
if (token !== this.searchToken) return;
|
||||
if (!applyAccountData(acc)) return;
|
||||
const clientId = acc.clientId?.toString();
|
||||
if (!clientId) {
|
||||
this.loadingAccount = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const acType = cfg.nominee;
|
||||
|
||||
// Today's date for transaction filter
|
||||
const todayStr = (() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
})();
|
||||
|
||||
// Parallel: all cashier-relevant data
|
||||
forkJoin({
|
||||
client: this.http
|
||||
.get<any>(`/clients/${clientId}?associations=clientNonPersonDetails`)
|
||||
.pipe(catchError(() => of(null))),
|
||||
kyc: this.http.get<any>(`/clients/${clientId}/kyc`).pipe(catchError(() => of([]))),
|
||||
nominees: this.http
|
||||
.get<any>(`/clients/${clientId}/nominees?accountNo=${acNo}&accountType=${acType}`)
|
||||
.pipe(catchError(() => of([]))),
|
||||
addresses: this.http.get<any>(`/client/${clientId}/addresses`).pipe(catchError(() => of([]))),
|
||||
family: this.http.get<any>(`/clients/${clientId}/familymembers`).pipe(catchError(() => of([]))),
|
||||
photo: this.http
|
||||
.get(`/clients/${clientId}/images`, { responseType: 'text' })
|
||||
.pipe(catchError(() => of(null))),
|
||||
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
||||
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
||||
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
||||
si: this.http
|
||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||
nonPerson: of(null), // loaded from client associations
|
||||
todayTxns: cfg.txnSearchSupported
|
||||
? this.http
|
||||
.get<any>(
|
||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||
)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
: this.http
|
||||
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
}).subscribe(
|
||||
({
|
||||
client,
|
||||
kyc,
|
||||
nominees,
|
||||
addresses,
|
||||
family,
|
||||
photo,
|
||||
documents,
|
||||
notes,
|
||||
linkedAcc,
|
||||
si,
|
||||
nonPerson,
|
||||
todayTxns
|
||||
}) => {
|
||||
this.loadingAccount = false;
|
||||
this.clientDetails = { ...client, familyMembers: Array.isArray(family) ? family : [] };
|
||||
const kycArr: any[] = Array.isArray(kyc) ? kyc : kyc ? [kyc] : [];
|
||||
this.kycDetails = kycArr[0] ?? null;
|
||||
this.nominees = Array.isArray(nominees) ? nominees : [];
|
||||
this.nonPersonDetails = client?.clientNonPersonDetails ?? null;
|
||||
this.clientAddresses = Array.isArray(addresses) ? addresses : [];
|
||||
|
||||
// Photo
|
||||
this.clientPhotoUrl = photo ?? null;
|
||||
|
||||
// Signature — fetch as blob via HttpClient (which adds auth headers)
|
||||
const docs: any[] = Array.isArray(documents) ? documents : [];
|
||||
const sigDoc = docs.find(
|
||||
(d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature')
|
||||
);
|
||||
if (sigDoc) {
|
||||
this.http
|
||||
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((blob: Blob | null) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.signatureUrl = url;
|
||||
// Signature arrives after the main load — refresh shared state so embedded views get it
|
||||
this.persistClerkState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notes/alerts — /savings/{id}/notes returns array directly
|
||||
this.accountNotes = Array.isArray(notes) ? notes : [];
|
||||
|
||||
// Linked accounts
|
||||
this.linkedAccounts = linkedAcc;
|
||||
|
||||
// Standing Instructions
|
||||
this.standingInstructions = si?.pageItems ?? (Array.isArray(si) ? si : []);
|
||||
|
||||
// Today's transactions — savings uses "content", CA uses "pageItems"
|
||||
this.todayTransactions =
|
||||
todayTxns?.pageItems ?? todayTxns?.content ?? (Array.isArray(todayTxns) ? todayTxns : []);
|
||||
|
||||
// Push full state so embedded facility/photo/SI/activity views (e.g. manager review) can read it
|
||||
this.persistClerkState();
|
||||
}
|
||||
);
|
||||
forkJoin(buildClientCalls(clientId)).subscribe((r: any) => {
|
||||
applyClientData(clientId, r);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2279,8 +2281,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
|
||||
// Token-based cashier disbursement: balance was already deducted at manager approval
|
||||
const isDisbursement = !isDeposit && !this.isClerk && !!this.activeWithdrawalRequest;
|
||||
// Cashier savings deposit goes via maker-checker: balance credits only after manager approval
|
||||
const isDepositRequest = isDeposit && !this.isClerk && row.acTyp === 'SD';
|
||||
// Cashier deposit (savings/current) goes via maker-checker: balance credits only after manager approval
|
||||
const isDepositRequest =
|
||||
isDeposit && !this.isClerk && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
|
||||
const balanceAfterTxn =
|
||||
isDisbursement || isDepositRequest
|
||||
? this.currentBalance // no immediate balance change
|
||||
@@ -2332,9 +2335,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cashier savings deposit → route through maker-checker (create PENDING request, no direct credit)
|
||||
// Cashier deposit (savings/current) → route through maker-checker (create PENDING request, no direct credit)
|
||||
// Use the dialog-confirmed amount — for cash that is the denomination total
|
||||
if (!this.isClerk && !this.reviewMode && isDeposit && row.acTyp === 'SD') {
|
||||
if (
|
||||
!this.isClerk &&
|
||||
!this.reviewMode &&
|
||||
isDeposit &&
|
||||
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB')
|
||||
) {
|
||||
this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
|
||||
return;
|
||||
}
|
||||
@@ -2694,7 +2702,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
return {
|
||||
accountNo: this.accountDetails.accountNo,
|
||||
accountType: 'SAVINGS',
|
||||
accountType: row.acTyp === 'CA' || row.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
|
||||
savingsAccountId: this.accountDetails.id,
|
||||
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
|
||||
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
|
||||
|
||||
Reference in New Issue
Block a user