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;
|
: null;
|
||||||
const match = exactMatch ?? partialMatch ?? null;
|
const match = exactMatch ?? partialMatch ?? null;
|
||||||
if (match?.id) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
this.loadingAccount = false;
|
this.loadingAccount = false;
|
||||||
@@ -1359,142 +1360,143 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
detailParams?: string;
|
detailParams?: string;
|
||||||
},
|
},
|
||||||
token: number,
|
token: number,
|
||||||
acTyp: string = ''
|
acTyp: string = '',
|
||||||
|
clientIdHint: string | null = null
|
||||||
): void {
|
): 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
|
this.http
|
||||||
.get<any>(`${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`)
|
.get<any>(accountUrl)
|
||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((acc: any) => {
|
.subscribe((acc: any) => {
|
||||||
if (token !== this.searchToken) return; // stale response — newer search active
|
if (token !== this.searchToken) return;
|
||||||
if (!acc) {
|
if (!applyAccountData(acc)) return;
|
||||||
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);
|
|
||||||
|
|
||||||
const clientId = acc.clientId?.toString();
|
const clientId = acc.clientId?.toString();
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
this.loadingAccount = false;
|
this.loadingAccount = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
forkJoin(buildClientCalls(clientId)).subscribe((r: any) => {
|
||||||
const acType = cfg.nominee;
|
applyClientData(clientId, r);
|
||||||
|
});
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2279,8 +2281,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
|
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
|
||||||
// Token-based cashier disbursement: balance was already deducted at manager approval
|
// Token-based cashier disbursement: balance was already deducted at manager approval
|
||||||
const isDisbursement = !isDeposit && !this.isClerk && !!this.activeWithdrawalRequest;
|
const isDisbursement = !isDeposit && !this.isClerk && !!this.activeWithdrawalRequest;
|
||||||
// Cashier savings deposit goes via maker-checker: balance credits only after manager approval
|
// Cashier deposit (savings/current) goes via maker-checker: balance credits only after manager approval
|
||||||
const isDepositRequest = isDeposit && !this.isClerk && row.acTyp === 'SD';
|
const isDepositRequest =
|
||||||
|
isDeposit && !this.isClerk && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
|
||||||
const balanceAfterTxn =
|
const balanceAfterTxn =
|
||||||
isDisbursement || isDepositRequest
|
isDisbursement || isDepositRequest
|
||||||
? this.currentBalance // no immediate balance change
|
? this.currentBalance // no immediate balance change
|
||||||
@@ -2332,9 +2335,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
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
|
// 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);
|
this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2694,7 +2702,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
accountNo: this.accountDetails.accountNo,
|
accountNo: this.accountDetails.accountNo,
|
||||||
accountType: 'SAVINGS',
|
accountType: row.acTyp === 'CA' || row.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
|
||||||
savingsAccountId: this.accountDetails.id,
|
savingsAccountId: this.accountDetails.id,
|
||||||
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
|
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
|
||||||
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
|
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user