A
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user