The Daily Transaction Entry screen no longer composes a Party Tran. An agent records their own round elsewhere; this screen reads back what they wrote down, shows what it adds up to, and settles the day once a clerk has counted the cash against it. Nothing is typed here and nothing is edited - what settles is what the entries say, to the rupee. Agent Master now asks which ledger account the agent's collections pass through, one agent to an account, and offers only the accounts that can carry them. The DDS scheme product is kept out of the agent's own savings list for the same reason the server refuses it: debits are blocked on those accounts, so commission paid into one would go in and never come out. The DDS product carries a Maturity Auto Close setting, asked as Yes/No to match the Auto Interest Post question directly above it, and Batch Processing offers the run that applies it. Also: the report viewer stops printing two money columns ungrouped and stops totalling a column that holds a word, and an agent's birth date cannot be picked later than eighteen years ago. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
389 lines
14 KiB
TypeScript
389 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';
|
|
|
|
/**
|
|
* 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(() => {
|
|
this.generatedRun = null;
|
|
this.previewRows = [];
|
|
this.loadRecentRuns();
|
|
|
|
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']);
|
|
}
|
|
}
|