feat: implement product approval workflow updates

This commit is contained in:
2026-09-09 11:32:37 +05:30
parent abc966d507
commit 227949f8b2
31 changed files with 1472 additions and 277 deletions
@@ -11,15 +11,27 @@ import { Resolve } from '@angular/router';
import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ProductsService } from 'app/products/products.service';
import { TasksService } from '../tasks.service';
import { isProductEntity, isTempTableProductEdit, PRODUCT_ENTITY_TYPE_LABEL } from '../product-entities';
/**
* Merges pending edits across all product types sharing the m_savings_product_temp backing table -
* Savings, Current Account, Share, Fixed Deposit, and Recurring Deposit products - into one list for
* the Pending Product Edits screen.
* Builds the Approvals -> Product Approval list from the two pending-request sources the platform
* already has. No new workflow or table is involved; both feeds are read as-is.
*
* 1. Pending product edits (m_saving_product_temp), via /{producttype}/pending. UPDATE only, for the
* five product types whose PUT handler stages the change instead of logging a command.
* 2. Native maker-checker entries (m_portfolio_command_source), via /makercheckers. This is where
* product CREATE and DELETE land - and LOANPRODUCT/CHARGE requests of every action, since those
* were never diverted to the staging table.
*
* Rows staged in (1) also have an audit-only command-source row that surfaces in (2), so the
* maker-checker feed drops them - see isTempTableProductEdit. Each source keeps its own approve/reject
* path in the component; the merge here is presentational only.
*/
@Injectable()
export class GetPendingSavingsProducts implements Resolve<any> {
private productsService = inject(ProductsService);
private tasksService = inject(TasksService);
resolve(): Observable<any> {
return forkJoin({
@@ -27,15 +39,73 @@ export class GetPendingSavingsProducts implements Resolve<any> {
current: this.productsService.getPendingCurrentProductEdits().pipe(catchError(() => of([]))),
share: this.productsService.getPendingShareProductEdits().pipe(catchError(() => of([]))),
fd: this.productsService.getPendingFixedDepositProductEdits().pipe(catchError(() => of([]))),
rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([])))
rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([]))),
// includeJson so a CREATE row - which has no resourceId, and therefore no product to look up -
// can still show the submitted product name.
makerChecker: this.tasksService.getMakerCheckerData({ includeJson: true }).pipe(catchError(() => of([])))
}).pipe(
map((results) => [
...(results.savings || []).map((e: any) => ({ ...e, productType: 'SAVINGS' })),
...(results.current || []).map((e: any) => ({ ...e, productType: 'CURRENT' })),
...(results.share || []).map((e: any) => ({ ...e, productType: 'SHARE' })),
...(results.fd || []).map((e: any) => ({ ...e, productType: 'FD' })),
...(results.rd || []).map((e: any) => ({ ...e, productType: 'RD' }))
...this.asEditRows(results.savings, 'SAVINGS'),
...this.asEditRows(results.current, 'CURRENT'),
...this.asEditRows(results.share, 'SHARE'),
...this.asEditRows(results.fd, 'FD'),
...this.asEditRows(results.rd, 'RD'),
...this.asMakerCheckerRows(results.makerChecker)
])
);
}
/** Staged product edits, unchanged apart from the source/action tags the merged table needs. */
private asEditRows(edits: any[], productType: string): any[] {
return (edits || []).map((edit: any) => ({
...edit,
productType,
source: 'PENDING_EDIT',
actionName: 'UPDATE'
}));
}
/**
* Normalises maker-checker entries onto the same field names the pending-edit table already renders,
* so the existing template keeps working for both sources.
*/
private asMakerCheckerRows(entries: any): any[] {
const items = Array.isArray(entries) ? entries : (entries?.pageItems ?? []);
return items
.filter((entry: any) => isProductEntity(entry?.entityName))
.filter((entry: any) => !isTempTableProductEdit(entry?.entityName, entry?.actionName))
.map((entry: any) => ({
id: entry.id,
source: 'MAKER_CHECKER',
entityName: entry.entityName,
actionName: (entry.actionName ?? '').toUpperCase(),
productType: PRODUCT_ENTITY_TYPE_LABEL[(entry.entityName ?? '').toUpperCase()] ?? entry.entityName,
savingsProductId: entry.resourceId ?? null,
savingsProductName: this.resolveName(entry),
makerUsername: entry.maker,
// /makercheckers returns madeOnDate as a Unix timestamp in seconds.
// new Date(seconds) misreads it as milliseconds → January 1970.
// Normalise to milliseconds here, matching DatetimeFormatPipe's unix() branch.
createdOn:
typeof entry.madeOnDate === 'number' && entry.madeOnDate < 1e12 ? entry.madeOnDate * 1000 : entry.madeOnDate,
status: 'PENDING'
}));
}
/**
* A CREATE has no resourceId - the product does not exist until approval - so the name comes from
* the submitted command payload. Falls back to the entity/id when the payload is absent (a DELETE
* carries none) or unparseable.
*/
private resolveName(entry: any): string {
try {
const payload = entry?.commandAsJson ? JSON.parse(entry.commandAsJson) : null;
if (payload?.name) {
return payload.name;
}
} catch {
/* fall through to the identifier below */
}
return entry?.resourceId != null ? `${entry.entityName} #${entry.resourceId}` : entry.entityName;
}
}