401 lines
14 KiB
TypeScript
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']);
|
|
}
|
|
}
|