This commit is contained in:
2026-09-23 15:51:10 +05:30
parent cda7c52d11
commit 08a427467a
123 changed files with 20687 additions and 4156 deletions
@@ -10,6 +10,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
@@ -38,12 +39,14 @@ import {
RiskLevel,
channelLabel,
mapLiveAlert,
riskFromCategory
riskFromCategory,
riskRank
} from './risk-review-data';
import {
DateRange,
RatingDirection,
RiskAlertView,
BehaviourWindowByClient,
RiskCustomerRow,
RiskReviewFilter,
RiskReviewModel,
@@ -53,6 +56,7 @@ import {
monthRange,
weekRange
} from './risk-review.compute';
import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component';
type PeriodMode = 'day' | 'week' | 'month' | 'range';
type ViewMode = 'customer' | 'alert' | 'txn';
@@ -109,6 +113,7 @@ export class RiskReviewReportComponent implements OnInit {
private clients = inject(ClientsService);
private scope = inject(BranchScopeService);
private router = inject(Router);
private dialog = inject(MatDialog);
readonly period = GL_PERIOD;
readonly branches = GL_BRANCHES;
@@ -181,21 +186,37 @@ export class RiskReviewReportComponent implements OnInit {
// ── Loading ────────────────────────────────────────────────────────────────
/**
* The nightly job's verdict per customer, keyed on client id.
*
* Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to
* its own per-alert reckoning rather than showing nothing.
*/
private behaviourWindows: BehaviourWindowByClient = {};
load(): void {
this.loading = true;
this.aml
.getAlerts()
.pipe(catchError(() => of([] as LiveAmlAlert[])))
.subscribe((live) => {
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
this.liveAlerts = rows;
this.compose();
if (!rows.length) {
this.loading = false;
return;
// Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a
// recommendation the screen then silently replaced.
forkJoin({
alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))),
windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[])))
}).subscribe(({ alerts: live, windows }) => {
this.behaviourWindows = {};
for (const w of windows || []) {
if (w?.clientId != null) {
this.behaviourWindows[w.clientId] = w;
}
this.enrichCurrentRisk(rows);
});
}
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
this.liveAlerts = rows;
this.compose();
if (!rows.length) {
this.loading = false;
return;
}
this.enrichCurrentRisk(rows);
});
}
/**
@@ -341,9 +362,11 @@ export class RiskReviewReportComponent implements OnInit {
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
private get model(): RiskReviewModel {
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
// The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same
// load as the alerts. Keying on the alerts alone would serve the memo computed before they landed.
const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`;
if (key !== this.memoKey) {
this.memo = buildRiskReview(this.alerts, this.filter);
this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows);
this.memoKey = key;
}
return this.memo;
@@ -369,10 +392,63 @@ export class RiskReviewReportComponent implements OnInit {
* a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is
* only a correction, and matching ratings need nothing at all.
*/
/**
* A report row for a customer the behaviour review named but no alert brought onto the report.
*
* <p>
* The alert-derived fields are empty because there are none in the window, and that is the point being shown: the
* case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on
* behaviourWindow, so the row can still explain itself.
*/
private rowFromWindow(w: any): RiskCustomerRow {
const RANK: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
const current = RANK[w.riskCategoryAtReview] ?? null;
const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW';
return {
clientId: w.clientId,
customerName: w.clientName || `Client ${w.clientId}`,
accountNo: w.accountNo || '',
branchCode: w.officeName || '',
currentRisk: current,
alerts: [],
alertCount: 0,
totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0),
largestAmount: Number(w.largestTxn || 0),
proposedRisk: recommended,
recommendedRisk: recommended,
direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same',
directionReason: w.suggestionReason || '',
ruleLabels: [],
firstAlertDate: '',
lastAlertDate: '',
openCount: 0,
escalated: false,
needsUpgrade: w.suggestedDirection === 'UP',
needsDowngrade: w.suggestedDirection === 'DOWN',
behaviourWindow: w
};
}
get customerGroups(): RiskCustomerGroup[] {
const rows = this.customerRows;
const escalated = rows.filter((c) => c.escalated);
const rest = rows.filter((c) => !c.escalated);
// An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting
// on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change
// somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under
// the Principal Officer with nothing to do.
const escalated = rows.filter((c) => c.escalated && c.direction === 'same');
const rest = rows.filter((c) => !(c.escalated && c.direction === 'same'));
// Customers the nightly review says to move who raised no alert inside the filter window.
//
// The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the
// wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the
// customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they
// would sit in the behaviour table with a verdict nobody ever sees.
const onReport = new Set(rows.map((r) => r.clientId));
const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows)
.filter((w: any) => w?.clientId != null && !onReport.has(w.clientId))
.filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN')
.map((w: any) => this.rowFromWindow(w));
const groups: RiskCustomerGroup[] = [
{
@@ -383,25 +459,42 @@ export class RiskReviewReportComponent implements OnInit {
rows: escalated
},
{
key: 'up',
title: 'Rating needs raising',
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
// Raising and lowering are one section, not two. The heading answers "is there anything to do about this
// customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason.
// Splitting them put the same answer in two places and left an officer reading two lists to find one day's
// work. Raises sort first within the section, because an under-rated customer is the more urgent gap.
key: 'change',
title: 'Change needed',
hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.',
tone: 'up',
rows: rest.filter((c) => c.direction === 'up')
rows: [
...rest.filter((c) => c.currentRisk && c.direction === 'up'),
...offReport.filter((c) => c.direction === 'up'),
...rest.filter((c) => c.currentRisk && c.direction === 'down'),
...offReport.filter((c) => c.direction === 'down')
]
},
{
key: 'down',
title: 'Rating can be lowered',
hint: 'Activity no longer supports the rating on file.',
tone: 'down',
rows: rest.filter((c) => c.direction === 'down')
// Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose
// money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it
// is in keeping - the work is to give them one, and grouping them with the customers nothing need be done
// about buried the one action the report was asking for.
key: 'unrated',
title: 'Update risk',
hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.',
tone: 'up',
rows: rest.filter((c) => !c.currentRisk)
},
{
// Everything an alert put on the report that the policy will not move yet. "No change needed" read as though
// these customers were fine, which is not what it means: a customer with four open alerts sits here, and the
// reason is that the period does not yet carry enough to act on - not that there is nothing to look at.
// "Watching" says what the section is for, and the reason on each row says which condition fell short.
key: 'same',
title: 'No change needed',
hint: 'The rating on file already matches the behaviour. Listed for the record.',
title: 'Watching — not enough to act on',
hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.',
tone: 'hold',
rows: rest.filter((c) => c.direction === 'same')
rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
}
];
@@ -580,6 +673,67 @@ export class RiskReviewReportComponent implements OnInit {
]);
}
/**
* Sends a case that is with the Principal Officer to the STR queue.
*
* The rating cannot be moved while an STR is open, so the useful thing to offer is the case
* itself. Sending the reviewer to the customer's KYC record instead made them land on a screen
* where the one action they might take is the one thing they must not do yet.
*/
openStrCase(c: RiskCustomerRow, event: Event): void {
event.stopPropagation();
this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } });
}
/**
* Whether this row's rating can be moved from here.
*
* A case with the Principal Officer is deliberately excluded: the rating is held where it is
* until the STR concludes, which is what the section heading on that group says. Offering the
* button there would invite a change nobody is in a position to make yet.
*/
canChangeRisk(c: RiskCustomerRow): boolean {
return !c.escalated;
}
/**
* Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make
* it themselves.
*
* The button used to navigate away: the reviewer left the report, found the customer, edited the
* record, and the change took effect on their say-so alone with no second pair of eyes and no
* record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a
* checker like every other decision of that weight.
*/
changeRisk(c: RiskCustomerRow, event: Event): void {
event.stopPropagation();
if (!this.canChangeRisk(c)) {
return;
}
this.dialog
.open(RiskChangeDialogComponent, {
width: '520px',
maxWidth: '95vw',
disableClose: true,
data: {
clientId: c.clientId,
customerName: c.customerName,
currentCategory: riskRank(c.currentRisk) || null,
recommendedCategory: riskRank(c.recommendedRisk) || null,
recommendationReason: c.directionReason
} as RiskChangeDialogData
})
.afterClosed()
.subscribe((sent) => {
if (sent) {
// The rating has not moved yet - only a request exists - so the figures on screen are
// still correct. Reloaded anyway so a second click cannot raise a duplicate request the
// server would refuse.
this.load();
}
});
}
/**
* What the reviewer is going to the KYC screen to do.
*
@@ -594,25 +748,41 @@ export class RiskReviewReportComponent implements OnInit {
* change nobody is authorised to make yet.
*/
directionText(c: RiskCustomerRow): string {
if (c.escalated) {
// Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one
// filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports
// the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing
// needed doing.
if (c.escalated && c.direction === 'same') {
return 'Held — with Principal Officer';
}
if (c.direction === 'up') {
return `Raise to ${RISK_LABEL[c.recommendedRisk]}`;
// Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the
// officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in
// one glance, and on an exported sheet it still says what happened without the column beside it.
if (c.direction === 'up' || c.direction === 'down') {
const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated';
return `${from} → ${RISK_LABEL[c.recommendedRisk]}`;
}
if (c.direction === 'down') {
return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
}
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record';
// An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops
// there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most.
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer';
}
directionIcon(c: RiskCustomerRow): string {
if (c.escalated) {
// Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the
// rating still has to be moved.
if (c.escalated && c.direction === 'same') {
return 'lock';
}
// 'check' rather than an equals sign: faEquals is not in the shared library, and an
// unregistered icon throws at render and takes the whole row down with it.
return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
if (c.direction === 'up') {
return 'arrow-up';
}
if (c.direction === 'down') {
return 'arrow-down';
}
// A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question
// rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered
// names resolve, and an unregistered icon throws at render and takes the whole row down with it.
return c.currentRisk ? 'check' : 'exclamation-circle';
}
/**