diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts index 1a2854be2..1ac9dcc54 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts @@ -52,16 +52,29 @@ export class ProductEditDiffDialogComponent implements OnInit { productName = ''; rows: DiffRow[] = []; - /** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews. */ + /** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews. + * otherGlMappings is here too -- it is a nested {assets,liabilities,expenses,income} document, not + * a scalar, so it is handled separately by namedGlMappingRows() rather than the generic loop below + * (which would otherwise dump it as a raw, unreadable JSON blob). */ private static readonly IGNORED_KEYS = new Set([ 'locale', 'dateFormat', 'monthDayFormat', 'id', 'productId', - 'savingsProductId' + 'savingsProductId', + 'otherGlMappings' ]); + /** The two reserved otherGlMappings entries a checker actually needs to see by name -- see + * AccountingProcessorHelper.SAVINGS_INTEREST_PAYABLE_MAPPING / SAVINGS_INTEREST_PAID_MAPPING. Any + * other entry in the assets/liabilities/expenses/income lists is a free-form "Other" row with no + * fixed identity to diff against by name, so those are left out of this dialog same as before. */ + private static readonly NAMED_GL_MAPPINGS = [ + 'Savings Interest Payable', + 'Savings Interest Paid' + ]; + /** * Payload fields the stored product exposes under a different path, so the "current" side has * something to compare against instead of always reading as newly set. @@ -115,6 +128,9 @@ export class ProductEditDiffDialogComponent implements OnInit { }; private glAccountNamesById = new Map(); + /** Same pool as glAccountNamesById, keyed by GL code instead of id -- otherGlMappings entries (see + * namedGlMappingRows) identify an account by its code, not its id. */ + private glAccountNamesByCode = new Map(); ngOnInit(): void { this.productName = this.data.productName ?? ''; @@ -131,10 +147,11 @@ export class ProductEditDiffDialogComponent implements OnInit { original: this.displayValue(originalValue, key, original), proposed: this.displayValue(proposed[key], key, original) })) + .concat(this.namedGlMappingRows(original, proposed)) .sort((a, b) => a.label.localeCompare(b.label)); } - /** Pools every GL account the template offers so an account id can be shown as its name. */ + /** Pools every GL account the template offers so an account id/code can be shown as its name. */ private buildGlAccountLookup(template: any): void { const optionGroups = template?.accountingMappingOptions ?? {}; for (const group of Object.values(optionGroups)) { @@ -146,13 +163,68 @@ export class ProductEditDiffDialogComponent implements OnInit { continue; } const label = account.name ?? account.glCode ?? null; - if (label) { - this.glAccountNamesById.set(String(account.id), account.glCode ? `${label} (${account.glCode})` : label); + if (!label) { + continue; + } + const display = account.glCode ? `${label} (${account.glCode})` : label; + this.glAccountNamesById.set(String(account.id), display); + if (account.glCode) { + this.glAccountNamesByCode.set(String(account.glCode), display); } } } } + /** + * Diffs the two reserved otherGlMappings entries by name across every category (assets/liabilities/ + * expenses/income) rather than the generic key loop above, since otherGlMappings is a nested + * {assets,liabilities,expenses,income} document whose entries move category if the maker changes + * which account type they picked -- comparing category-by-category would miss that. `original` + * carries this document as otherGlMappingsMap (falling back to the older otherGlMappings key for a + * template response that hasn't been re-fetched since), `proposed` as otherGlMappings. + */ + private namedGlMappingRows(original: any, proposed: any): DiffRow[] { + const proposedOther = proposed?.otherGlMappings; + if (proposedOther === undefined) { + // Not part of this edit at all -- nothing to compare, same as any other field the maker didn't touch. + return []; + } + const originalOther = original?.otherGlMappingsMap ?? original?.otherGlMappings; + + const findByName = (doc: any, accountName: string): { glCode?: string } | null => { + const categories: any[] = doc ? Object.values(doc) : []; + for (const category of categories) { + if (!Array.isArray(category)) { + continue; + } + const match = category.find((entry: any) => entry?.accountName === accountName); + if (match) { + return match; + } + } + return null; + }; + + const resolve = (entry: { glCode?: string } | null): string => { + if (!entry?.glCode) { + return '—'; + } + return this.glAccountNamesByCode.get(String(entry.glCode)) ?? entry.glCode; + }; + + return ProductEditDiffDialogComponent.NAMED_GL_MAPPINGS.map((accountName) => { + const before = findByName(originalOther, accountName); + const after = findByName(proposedOther, accountName); + return { accountName, before, after }; + }) + .filter(({ before, after }) => (before?.glCode ?? null) !== (after?.glCode ?? null)) + .map(({ accountName, before, after }) => ({ + label: accountName, + original: resolve(before), + proposed: resolve(after) + })); + } + /** Reads the current value for a payload field, following an alias or GL mapping when needed. */ private originalValueFor(key: string, original: any): any { const glMappingKey = ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[key];