Files
cbsnexora/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts
T

401 lines
14 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/**
* DDS Interest & Commission batch processing — the legacy Interest Part menu.
*
* Eight menu entries, which are four Calculate/Post pairs. A *calculation* job works out what has
* been earned since the last one and parks it: interest against the customer's account, commission
* against the agent. Neither is spendable yet. The matching *posting* job then moves that parked
* figure where it belongs — into the customer's balance, or out to the agent.
*
* Every job is itself a two-step Generate then Post. Generate saves what it worked out and changes
* nothing else, so a figure that looks wrong is discarded by simply not posting it.
*/
@Component({
selector: 'mifosx-dds-batch-processing',
templateUrl: './dds-batch-processing.component.html',
styleUrls: ['./dds-batch-processing.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
// The reason a run failed is too long to sit in the column, and too useful to leave out.
MatTooltip
],
// Day-first dates, as the legacy screen showed them. Declared here rather than application-wide:
// a @Component's providers reach only this component, so no other screen's datepicker changes.
providers: [
{ provide: DateAdapter, useClass: DdsDateAdapter },
{ provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS }
]
})
export class DdsBatchProcessingComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private alertService = inject(AlertService);
private dialog = inject(MatDialog);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
/**
* The menu as the legacy system lists it, paired with what the API calls each job. Ordered as
* calculate-then-post within each pair, matching the numbering on the legacy screen.
*/
jobTypes: Array<{ label: string; code: string }> = [
{ label: 'Interest Calculation', code: 'INTEREST_CALCULATION' },
{ label: 'Interest Posting', code: 'INTEREST_POSTING' },
{ label: 'OD Interest Calculation', code: 'OD_INTEREST_CALCULATION' },
{ label: 'OD Interest Posting', code: 'OD_INTEREST_POSTING' },
{ label: 'Commission Calculation', code: 'COMMISSION_CALCULATION' },
{ label: 'Commission Posting', code: 'COMMISSION_POSTING' },
// Last, and on its own: the others move money onto accounts that stay open, while this one pays
// an account out and shuts it. Listing it beside a calculate/post pair would suggest it is the
// second half of one.
{ label: 'Maturity Auto Close', code: 'MATURITY_AUTO_CLOSE' }
];
branchScopes = [
'All',
'Selected'
];
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
offices: any[] = [];
selectedOfficeNames = new Set<string>();
/** The run currently previewed on screen, or null once it has been posted or discarded. */
generatedRun: any = null;
previewRows: any[] = [];
recentRuns: any[] = [];
batchForm: UntypedFormGroup = this.formBuilder.group({
jobType: [
'INTEREST_CALCULATION',
Validators.required
],
branchScope: [
'All',
Validators.required
],
jobDate: [
new Date(),
Validators.required
],
agentFrom: [
1,
Validators.required
],
agentTo: [
99999,
Validators.required
],
partyFrom: [
1,
Validators.required
],
partyTo: [
99999999,
Validators.required
]
});
get jobType(): string {
return this.batchForm.get('jobType').value;
}
get jobTypeLabel(): string {
return this.jobTypes.find((type) => type.code === this.jobType)?.label ?? this.jobType;
}
/** Commission is per agent, so the party range does not narrow it. */
get isCommissionJob(): boolean {
return this.jobType.startsWith('COMMISSION');
}
get isPostingJob(): boolean {
return this.jobType.endsWith('_POSTING');
}
get isBranchSelected(): boolean {
return this.batchForm.get('branchScope')?.value === 'Selected';
}
/**
* Only a commission calculation has a breakdown worth showing: it is the one job where the figure
* applied is not the figure worked out, because TDS and the standing deductions come off it.
*/
get showsCommissionBreakdown(): boolean {
return this.jobType === 'COMMISSION_CALCULATION';
}
/** What Generate found, in words — the same figure the Post button is about to apply. */
get previewTotal(): number {
return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0);
}
ngOnInit(): void {
this.organizationService.getOffices().subscribe((offices: any[]) => {
this.offices = offices || [];
});
this.loadRecentRuns();
}
money(value: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
return DdsBatchProcessingComponent.MONEY.format(Number(value));
}
labelOf(code: string): string {
return this.jobTypes.find((type) => type.code === code)?.label ?? code;
}
toggleOffice(officeName: string): void {
if (this.selectedOfficeNames.has(officeName)) {
this.selectedOfficeNames.delete(officeName);
} else {
this.selectedOfficeNames.add(officeName);
}
}
/**
* What every past run did and whether it was applied. Without this a generated-but-unposted run is
* invisible the moment the page is left, and there is no way to tell whether a month's interest
* was ever actually posted.
*/
private loadRecentRuns(): void {
this.organizationService.getDdsBatchRuns().subscribe((runs: any[]) => {
this.recentRuns = runs || [];
});
}
generate(): void {
const raw = this.batchForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const payload: any = {
jobType: raw.jobType,
branchScope: raw.branchScope,
jobDate: this.dateUtils.formatDate(raw.jobDate, dateFormat),
agentFrom: raw.agentFrom,
agentTo: raw.agentTo,
dateFormat,
locale: this.settingsService.language.code
};
// The party range narrows an interest job to particular accounts; commission is worked out per
// agent on their whole collection, so sending a range there would only imply a filter that has
// no effect.
if (!this.isCommissionJob) {
payload.partyFrom = raw.partyFrom;
payload.partyTo = raw.partyTo;
}
if (raw.branchScope === 'Selected') {
payload.officeNames = Array.from(this.selectedOfficeNames);
}
this.organizationService.generateDdsBatchRun(payload).subscribe((result: any) => {
this.organizationService.getDdsBatchRun(result.resourceId).subscribe((batchRun: any) => {
this.generatedRun = batchRun;
this.previewRows = batchRun.rows || [];
// A run that found nothing is the normal outcome of a period already processed, not a
// failure — but it has to say so, or an empty table reads as the screen being broken.
if (this.previewRows.length === 0) {
this.alertService.alert({
type: 'Information',
message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.`
});
} else {
this.alertService.alert({
type: 'Success',
message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.`
});
}
this.loadRecentRuns();
});
});
}
post(): void {
if (!this.generatedRun || this.previewRows.length === 0) {
return;
}
const count = this.previewRows.length;
const total = this.money(this.previewTotal);
const isPosting = this.isPostingJob;
// Post is the step that moves money, and it cannot be undone from this screen — so it names
// exactly what is about to change rather than asking a generic "are you sure".
this.dialog
.open(ConfirmationDialogComponent, {
data: {
type: 'warn',
heading: `Post ${this.jobTypeLabel}?`,
dialogContext:
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` +
(isPosting
? 'This credits the customers or pays the agents, and appears on the ledger. '
: 'This records what has been earned against each account. ') +
'It cannot be undone from this screen.'
}
})
.afterClosed()
.subscribe((response: any) => {
if (response?.confirm) {
this.applyRun(count, total);
}
});
}
/**
* Applies the run, and says what actually happened rather than assuming it finished.
*
* A crediting run costs what a savings deposit costs on every account it touches, so the server
* takes it off the request and does it in the background - the reply comes back in a moment while
* the work is still going on. Reporting that as "posted" would tell the clerk a run had finished
* seconds after it began, and they would go looking for credits that were still being written.
*
* So the run is followed until it settles. A calculation run is done by the time the reply lands
* and says so at once; a crediting run says it is under way, and the list beneath refreshes itself
* until the server marks it posted - or failed, with the reason the server recorded.
*/
private applyRun(count: number, total: string): void {
const label = this.jobTypeLabel;
const runId = this.generatedRun.id;
const crediting = this.isPostingJob;
this.organizationService.postDdsBatchRun(runId).subscribe((response: any) => {
this.generatedRun = null;
this.previewRows = [];
this.loadRecentRuns();
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`,
this.organizationService,
this.alertService
);
return;
}
if (!crediting) {
this.alertService.alert({
type: 'Success',
message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.`
});
return;
}
this.postingRunId = runId;
this.alertService.alert({
type: 'Posting',
message:
`${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` +
'It continues in the background; this list will update when it is done.'
});
this.followRun(runId, label);
});
}
/** The crediting run being followed, if one is. The screen reads this to show it is under way. */
postingRunId: number | null = null;
/**
* Watches a background run until the server marks it settled.
*
* Polled rather than pushed: a batch that takes minutes needs no faster news than this, and a poll
* costs nothing when the answer is one row. It gives up after a while rather than asking forever -
* the run is still on the list either way, and a clerk can read its status there.
*/
private followRun(runId: number, label: string): void {
let checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS;
const check = () => {
this.organizationService.getDdsBatchRuns().subscribe({
next: (runs: any[]) => {
const run = (runs || []).find((each: any) => each.id === runId);
const status = run?.status;
if (status === 'POSTED' || status === 'FAILED') {
this.postingRunId = null;
this.loadRecentRuns();
this.alertService.alert(
status === 'POSTED'
? { type: 'Success', message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.` }
: { type: 'Error', message: `${label} failed: ${run.failureReason || 'see the run for details'}` }
);
return;
}
if (--checksLeft > 0) {
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
} else {
// Still going after the wait. Left on the list rather than reported either way - saying
// "failed" for a run that is merely long would be worse than saying nothing.
this.postingRunId = null;
this.loadRecentRuns();
}
},
// A failed status check says nothing about the run itself, which carries on regardless.
error: () => {
if (--checksLeft > 0) {
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
} else {
this.postingRunId = null;
}
}
});
};
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
}
private static readonly STATUS_CHECK_INTERVAL_MS = 4000;
/** Twenty minutes of checking, which is longer than any run measured here has taken. */
private static readonly MAX_STATUS_CHECKS = 300;
/** Drops the preview without applying it — the safe way out of a calculation that looks wrong. */
discard(): void {
this.generatedRun = null;
this.previewRows = [];
}
exit(): void {
this.router.navigate(['/organization']);
}
}