import { Injectable } from '@angular/core'; import { TypeaheadSelectOption } from '@yourcause/common'; import { CurrencyValue } from '@yourcause/common/masking'; import { get } from 'lodash'; import { BehaviorSubject } from 'rxjs'; import { LogicStateService } from '../logic-builder/logic-state.service'; import { FormulaEvaluationType, FormulaForColumn, FormulaRunResult, FormulaState, FormulaStep, FormulaStepValueType, RootFormula } from './formula-builder.typing'; @Injectable({ providedIn: 'root' }) export class FormulaBuilderService { constructor ( private logicStateService: LogicStateService ) { } /** * Build a default formula for a property * * @param rootProperty The property the new formula runs for * @returns A default formula state */ getDefaultRootFormula ( rootProperty: string ): RootFormula { return { property: rootProperty, step: { type: FormulaEvaluationType.Add, values: [{ value: 0, type: FormulaStepValueType.Fixed }, { value: 0, type: FormulaStepValueType.Fixed }] } } as any; } /** * For a given column, looking for any dependency trees that lead to this column (causing an infinite loop). * e.g. formula for `columnA` depends on the value of `columnB` which has a formula that depends on `columnC` which has a formula that depends on `columnA` * * @param column Starting column * @param otherExpressions Other rules in the context (form) to check against */ detectInfiniteLoops ( column: RootFormula, otherExpressions: RootFormula[] ): string[][] { const map: Record> = {}; otherExpressions.forEach((expression) => { map[expression.property as string] = expression; }); map[column.property as string] = column; const infiniteLoops: string[][] = []; const rootProp = column.property as string; const infiniteLoopLoop = ( currentProp: string, currentTree: string[] ) => { const currentEvaluation: RootFormula = map[currentProp as string]; const dependents = currentEvaluation?.step ? this.extractParentKeys(currentEvaluation.step) as string[] : []; dependents.forEach(dependent => { const scopedCurrentTree = [ ...currentTree, dependent ]; const dependentDependsOnRoot = dependent === rootProp; const partOfRelatedInfiniteLoop = currentTree.includes(dependent); if (dependentDependsOnRoot || partOfRelatedInfiniteLoop) { infiniteLoops.push(scopedCurrentTree); } else if (!partOfRelatedInfiniteLoop) { infiniteLoopLoop( dependent, scopedCurrentTree ); } }); }; infiniteLoopLoop(rootProp, [rootProp]); return infiniteLoops as string[][]; } /** * Run all evaluations and prepare the context for re-running logic based on incremental changes * * @param formulas All expressions that run in the current context (form) * @param parentValue Record being evaluated, used for extracting related values */ startRootFormulas ( formulas: RootFormula[], parentValue: T ): FormulaState { const sourceMap = new Map>(); const dependentMap = new Map[]>(); formulas.forEach((expression) => { const { result$, previousResult, dependencies } = this.evaluateRootFormula(expression, parentValue); const formulaForColumn: FormulaForColumn = { result$, dependencies, previousResult, ...expression }; sourceMap.set(expression.property, formulaForColumn); dependencies.forEach((dependency) => { let existing: FormulaForColumn[] = dependentMap.get(dependency) ?? []; existing = [ ...existing, formulaForColumn ]; dependentMap.set(dependency, existing); }); }); return { sourceMap, dependentMap }; } /** * After a set of evaluations have been executed and the state has been set up, re run the relevant rules based on an incremental change * * @param changedColumn Column that changed * @param logicState Previously created state * @param record Record being evaluated, used for extracting related values */ rerunFormulas ( changedColumn: string, logicState: FormulaState, record: T ) { // Get the groups that depend on the value of this column const { dependents } = this.logicStateService.getRecordsFromState(changedColumn, logicState); // execute each one and propagate the result dependents.forEach((dependentGroup) => { const previousResult = dependentGroup.result$.value; const result = this.evaluateFormulaStep(dependentGroup.step, record); dependentGroup.result$.next(result); dependentGroup.previousResult = previousResult; }); // return a new object (primarily to trigger angular change detection) return { ...logicState }; } /** * For a given column, get the current (pre-calculated) value off of the state * * @param state Current state of the evaluation * @param column Column to retrieve value of */ getCurrentValueFromState ( state: FormulaState, column: string ) { return this.logicStateService.getCurrentLogicValueOfColumn(column, state); } private extractParentKeys ( expression: FormulaStep ): string[] { return expression.values.reduce((acc, value) => { switch (value.type) { case FormulaStepValueType.ParentValue: return [ ...acc, value.value ]; case FormulaStepValueType.NestedStep: return [ ...acc, ...(this.extractParentKeys(value.value)) ]; default: return acc; } }, []); } private evaluateRootFormula ( expression: RootFormula, parentValue: T ): FormulaRunResult { const result = this.evaluateFormulaStep(expression.step, parentValue); const dependencies: string[] = this.extractParentKeys(expression.step); return { previousResult: undefined, result$: new BehaviorSubject(result), dependencies }; } /** * * @param record: the record * @param sourceColumn: the source column * @returns the record value */ getRecordValue ( record: T, sourceColumn: string ) { let value = get(record, sourceColumn) ?? 0; if (this.isCurrencyType(value)) { value = value.amountForControl ?? 0; } return +value; } /** * * @param value: the value * @returns if it's a currency type value */ isCurrencyType (value: any): value is CurrencyValue { return value instanceof Object && 'amountForControl' in value && 'currency' in value; } private evaluateFormulaStep ( step: FormulaStep, parentValue: T ): number { const expressions = step.values; const values: number[] = expressions.map((expression) => { switch (expression.type) { case FormulaStepValueType.Fixed: return +expression.value; case FormulaStepValueType.NestedStep: return this.evaluateFormulaStep(expression.value, parentValue); case FormulaStepValueType.ParentValue: return this.getRecordValue(parentValue, expression.value) ?? 0; } }); const accumulatedValue = values.reduce((acc, value) => { switch (step.type) { case FormulaEvaluationType.Add: case FormulaEvaluationType.Average: return acc + value; case FormulaEvaluationType.Divide: // Cannot divide by 0 if (value === 0) { return 0; } return acc / value; case FormulaEvaluationType.Multiply: return acc * value; case FormulaEvaluationType.Subtract: return acc - value; } }); switch (step.type) { case FormulaEvaluationType.Average: return accumulatedValue / values.length; default: return accumulatedValue; } } generateFormulaString ( formula: RootFormula, parentColumnOptions: TypeaheadSelectOption[] ): string { return this.generateFormulaStepString(formula.step, parentColumnOptions); } private generateFormulaStepString ( formulaStep: FormulaStep, parentColumnOptions: TypeaheadSelectOption[] ): string { const pieces = formulaStep.values.map(value => { switch (value.type) { case FormulaStepValueType.Fixed: return '' + value.value; case FormulaStepValueType.ParentValue: return parentColumnOptions.find(option => option.value === value.value)?.label ?? value.value; case FormulaStepValueType.NestedStep: return `(${this.generateFormulaStepString(value.value, parentColumnOptions)})`; } }); const union = this.getUnion(formulaStep); const partialFormula = pieces.join(` ${union} `); if (formulaStep.type === FormulaEvaluationType.Average) { return `(${partialFormula}) ÷ ${pieces.length}`; } return partialFormula; } private getUnion (formulaStep: FormulaStep) { switch (formulaStep.type) { case FormulaEvaluationType.Add: case FormulaEvaluationType.Average: return '+'; case FormulaEvaluationType.Subtract: return '-'; case FormulaEvaluationType.Divide: return '÷'; case FormulaEvaluationType.Multiply: return '•'; } } }