Files
aeroresolve_backend/src/modules/policy-engine/services/rule-evaluation.service.ts
T

692 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PolicyRule } from '../entities/policy-rule.entity';
import { RuleCondition } from '../entities/rule-condition.entity';
import { LogicalOperator } from '../entities/policy.enums';
import { ConditionField } from '../../master-data/entities/condition-field.entity';
import { Operator } from '../../master-data/entities/operator.entity';
import { IncidentContext } from './master-value-resolver.service';
export interface ConditionDiagnostic {
field: string;
actual: any;
expected: any;
operator: string;
matched: boolean;
reason: string;
}
export interface RuleEvaluationResult {
ruleId: string;
category?: string;
priority: number;
matched: boolean;
conditions: ConditionDiagnostic[];
reason?: string;
}
@Injectable()
export class RuleEvaluationService {
private readonly logger = new Logger(RuleEvaluationService.name);
constructor(
@InjectRepository(ConditionField)
private readonly fieldRepo: Repository<ConditionField>,
@InjectRepository(Operator)
private readonly operatorRepo: Repository<Operator>,
) {}
async evaluateRule(
rule: PolicyRule,
ctx: IncidentContext,
): Promise<RuleEvaluationResult> {
this.logger.log(`--- Evaluating Rule ID: ${rule.id} (Priority: ${rule.priority}) ---`);
const fields = await this.fieldRepo.find();
const operators = await this.operatorRepo.find();
const sortedConditions = (rule.conditions || []).sort(
(a, b) => (a.sequence || 0) - (b.sequence || 0),
);
if (sortedConditions.length === 0) {
this.logger.warn(
`>>> [RULE NOT MATCHED ❌] Rule ID: ${rule.id} (Priority: ${rule.priority}) | Reason: No conditions configured for rule.`,
);
return {
ruleId: rule.id,
category: rule.ruleCategoryId,
priority: rule.priority || 1,
matched: false,
conditions: [],
reason: 'Rule has no configured conditions or evaluation criteria.',
};
}
const diagnostics: ConditionDiagnostic[] = [];
let isRuleMatched = true;
for (let i = 0; i < sortedConditions.length; i++) {
const cond = sortedConditions[i];
const fieldDef = fields.find((f) => f.id === cond.fieldId);
const opDef = operators.find((o) => o.id === cond.operatorId);
const fieldCode = (fieldDef?.code || 'unknown').toLowerCase();
const opCode = (opDef?.code || opDef?.symbol || 'equals').toLowerCase();
const actualValue = this.extractActualValue(fieldCode, ctx);
const expectedValue = cond.valueText;
const { matched: isCondMatched, reason } = this.evaluateConditionWithReason(
actualValue,
expectedValue,
opCode,
fieldCode,
);
if (isCondMatched) {
this.logger.log(
` [CONDITION MATCHED ✅] Field: "${fieldCode}" | Operator: "${opCode}" | Actual: ${JSON.stringify(actualValue)} | Expected: ${JSON.stringify(expectedValue)} | REASON: ${reason}`,
);
} else {
this.logger.warn(
` [CONDITION FAILED ❌] Field: "${fieldCode}" | Operator: "${opCode}" | Actual: ${JSON.stringify(actualValue)} | Expected: ${JSON.stringify(expectedValue)} | REASON: ${reason}`,
);
}
diagnostics.push({
field: fieldCode,
actual: actualValue,
expected: expectedValue,
operator: opCode,
matched: isCondMatched,
reason,
});
const logicalOp = cond.logicalOperator || LogicalOperator.AND;
if (i === 0) {
isRuleMatched = isCondMatched;
} else {
if (logicalOp === LogicalOperator.OR) {
isRuleMatched = isRuleMatched || isCondMatched;
} else {
isRuleMatched = isRuleMatched && isCondMatched;
}
}
}
if (isRuleMatched) {
this.logger.log(
`>>> [RULE MATCHED ✅] Rule ID: ${rule.id} (Priority: ${rule.priority}) | Actions Configured: ${rule.actions?.length || 0}`,
);
} else {
const failedConds = diagnostics.filter((d) => !d.matched);
this.logger.warn(
`>>> [RULE NOT MATCHED ❌] Rule ID: ${rule.id} (Priority: ${rule.priority}) | REASON: Failed condition(s): [${failedConds
.map((c) => `Field "${c.field}": ${c.reason}`)
.join('; ')}]`,
);
}
return {
ruleId: rule.id,
category: rule.ruleCategoryId,
priority: rule.priority || 1,
matched: isRuleMatched,
conditions: diagnostics,
reason: isRuleMatched
? 'All required conditions matched successfully.'
: `Failed condition(s): ${diagnostics.filter((d) => !d.matched).map((d) => `${d.field} (${d.reason})`).join(', ')}`,
};
}
private normalizeOperatorCode(opCode: string): string {
const raw = (opCode || '').toLowerCase().trim();
if (!raw) return 'eq';
if (
raw === '=' ||
raw === '==' ||
raw === '===' ||
raw === 'eq' ||
raw === 'equal' ||
raw === 'equals' ||
raw === 'is' ||
raw === 'same'
) {
return 'eq';
}
if (
raw === '!=' ||
raw === '!==' ||
raw === '<>' ||
raw === 'ne' ||
raw === 'neq' ||
raw === 'not_equal' ||
raw === 'not_equals' ||
raw === 'not equal' ||
raw === 'not equals' ||
raw === 'is_not' ||
raw === 'is not'
) {
return 'neq';
}
if (
raw === '>' ||
raw === 'gt' ||
raw === 'greater_than' ||
raw === 'greater than' ||
raw === 'more_than' ||
raw === 'more than' ||
raw === 'above'
) {
return 'gt';
}
if (
raw === '>=' ||
raw === 'gte' ||
raw === 'greater_than_or_equal' ||
raw === 'greater than or equal' ||
raw === 'greater_than_or_equals' ||
raw === 'greater than or equals' ||
raw === 'at_least' ||
raw === 'at least' ||
raw === 'min' ||
raw === 'minimum'
) {
return 'gte';
}
if (
raw === '<' ||
raw === 'lt' ||
raw === 'less_than' ||
raw === 'less than' ||
raw === 'under' ||
raw === 'below' ||
raw === 'fewer_than' ||
raw === 'fewer than'
) {
return 'lt';
}
if (
raw === '<=' ||
raw === 'lte' ||
raw === 'less_than_or_equal' ||
raw === 'less than or equal' ||
raw === 'less_than_or_equals' ||
raw === 'less than or equals' ||
raw === 'at_most' ||
raw === 'at most' ||
raw === 'max' ||
raw === 'maximum'
) {
return 'lte';
}
if (
raw === 'contains' ||
raw === 'contain' ||
raw === 'includes' ||
raw === 'include' ||
raw === 'like' ||
raw === 'ilike' ||
raw === 'has' ||
raw === 'have'
) {
return 'contains';
}
if (
raw === 'not_contains' ||
raw === 'not contains' ||
raw === 'does_not_contain' ||
raw === 'does not contain' ||
raw === 'not_includes' ||
raw === 'not includes' ||
raw === 'excludes' ||
raw === 'exclude' ||
raw === 'without' ||
raw === 'not_like' ||
raw === 'not like'
) {
return 'not_contains';
}
if (
raw === 'in' ||
raw === 'inside' ||
raw === 'any_of' ||
raw === 'any of' ||
raw === 'one_of' ||
raw === 'one of'
) {
return 'in';
}
if (
raw === 'not_in' ||
raw === 'not in' ||
raw === 'not_inside' ||
raw === 'not inside' ||
raw === 'none_of' ||
raw === 'none of'
) {
return 'not_in';
}
if (
raw === 'starts_with' ||
raw === 'starts with' ||
raw === 'startswith' ||
raw === 'beginning_with'
) {
return 'starts_with';
}
if (
raw === 'ends_with' ||
raw === 'ends with' ||
raw === 'endswith' ||
raw === 'ending_with'
) {
return 'ends_with';
}
if (
raw === 'is_empty' ||
raw === 'is empty' ||
raw === 'empty' ||
raw === 'is_null' ||
raw === 'is null' ||
raw === 'null'
) {
return 'is_empty';
}
if (
raw === 'is_not_empty' ||
raw === 'is not empty' ||
raw === 'not_empty' ||
raw === 'not empty' ||
raw === 'is_not_null' ||
raw === 'is not null' ||
raw === 'not_null' ||
raw === 'not null'
) {
return 'is_not_empty';
}
return raw;
}
private evaluateConditionWithReason(
actual: any,
expected: any,
opCode: string,
fieldCode: string,
): { matched: boolean; reason: string } {
const normalizedOp = this.normalizeOperatorCode(opCode);
if (normalizedOp === 'is_empty') {
const isEmpty = actual === undefined || actual === null || String(actual).trim() === '';
return {
matched: isEmpty,
reason: isEmpty
? `Field "${fieldCode}" is empty or null.`
: `Field "${fieldCode}" has value "${actual}" and is NOT empty.`,
};
}
if (normalizedOp === 'is_not_empty') {
const isNotEmpty = actual !== undefined && actual !== null && String(actual).trim() !== '';
return {
matched: isNotEmpty,
reason: isNotEmpty
? `Field "${fieldCode}" has value "${actual}" and is not empty.`
: `Field "${fieldCode}" is empty or null.`,
};
}
if (actual === undefined || actual === null) {
return {
matched: false,
reason: `Incident context property "${fieldCode}" is missing, null, or undefined in the disruption payload.`,
};
}
const matched = this.compareValues(actual, expected, opCode);
if (matched) {
return {
matched: true,
reason: `Actual value "${actual}" satisfies operator "${opCode}" against expected value "${expected}".`,
};
} else {
const actStr = String(actual);
const expStr = String(expected || '');
const actNum = Number(actual);
const expNum = Number(expected);
const isNumeric = !isNaN(actNum) && !isNaN(expNum) && expStr !== '';
if (normalizedOp === 'neq') {
return {
matched: false,
reason: `Actual value "${actStr}" IS EQUAL to expected value "${expStr}", failing "not equal" operator "${opCode}".`,
};
} else if (isNumeric) {
return {
matched: false,
reason: `Actual numeric value (${actNum}) DOES NOT satisfy operator "${opCode}" against expected required numeric value (${expNum}).`,
};
} else {
return {
matched: false,
reason: `Actual text value "${actStr}" DOES NOT match expected value "${expStr}" under operator "${opCode}".`,
};
}
}
}
private extractActualValue(fieldCode: string, ctx: IncidentContext): any {
const fc = (fieldCode || '').toLowerCase().trim();
const raw = ctx.rawIncident || {};
switch (fc) {
// FLIGHT
case 'flight_type':
case 'flighttype':
return (
(raw as any).flightType ||
(ctx.origin && ctx.destination
? ctx.origin === ctx.destination
? 'domestic'
: 'international'
: undefined)
);
case 'origin':
case 'origin_airport':
case 'originairport':
return ctx.origin || (raw as any).origin;
case 'destination':
case 'destination_airport':
case 'destinationairport':
case 'airport':
return ctx.destination || (raw as any).destination;
case 'operating_carrier':
case 'operatingcarrier':
return (raw as any).operatingCarrier || (raw as any).carrier;
case 'marketing_carrier':
case 'marketingcarrier':
return (raw as any).marketingCarrier || (raw as any).carrier;
case 'flight_distance':
case 'flightdistance':
return (raw as any).flightDistance !== undefined
? Number((raw as any).flightDistance)
: undefined;
case 'flight_number':
case 'flightnumber':
return ctx.flightNumber || (raw as any).flightNumber;
// DISRUPTION
case 'delay_duration':
case 'delayduration':
case 'delay':
return ctx.delayDuration !== undefined ? ctx.delayDuration : (raw as any).delayDuration;
case 'delay_reason':
case 'delayreason':
return (raw as any).delayReason || (raw as any).reason;
case 'cancellation_reason':
case 'cancellationreason':
return (raw as any).cancellationReason || (raw as any).reason;
case 'diversion_reason':
case 'diversionreason':
return (raw as any).diversionReason || (raw as any).reason;
case 'weather_condition':
case 'weathercondition':
return (raw as any).weatherCondition;
case 'atc_restriction':
case 'atcrestriction':
return (raw as any).atcRestriction;
case 'technical_fault':
case 'technicalfault':
return (raw as any).technicalFault;
case 'extraordinary_circumstance':
case 'extraordinarycircumstance':
return (raw as any).extraordinaryCircumstance;
case 'airline_responsibility':
case 'airlineresponsibility':
return (raw as any).airlineResponsibility;
// PASSENGER
case 'passenger_type':
case 'passengertype':
return ctx.passengerTypeId || ctx.passengerTypeName || (raw as any).passengerType;
case 'cabin_class':
case 'cabinclass':
return ctx.cabinClassId || ctx.cabinClassName || (raw as any).cabinClass;
case 'membership_tier':
case 'membershiptier':
case 'loyalty_tier':
case 'loyaltytier':
return ctx.loyaltyTierId || ctx.loyaltyTierName || (raw as any).loyaltyTier;
case 'customer_value':
case 'customervalue':
return (raw as any).customerValue;
case 'corporate_customer':
case 'corporatecustomer':
return (raw as any).corporateCustomer;
case 'group_booking':
case 'groupbooking':
return (raw as any).groupBooking;
case 'special_assistance':
case 'specialassistance':
return ctx.specialAssistance || (raw as any).specialAssistance;
// JOURNEY
case 'journey_type':
case 'journeytype':
return (raw as any).journeyType;
case 'protected_connection':
case 'protectedconnection':
return (raw as any).protectedConnection;
case 'self_transfer':
case 'selftransfer':
return (raw as any).selfTransfer;
case 'number_of_segments':
case 'numberofsegments':
return (raw as any).numberOfSegments !== undefined
? Number((raw as any).numberOfSegments)
: undefined;
case 'final_destination_delay':
case 'finaldestinationdelay':
return (raw as any).finalDestinationDelay !== undefined
? Number((raw as any).finalDestinationDelay)
: ctx.delayDuration;
// BOOKING
case 'booking_channel':
case 'bookingchannel':
return (raw as any).bookingChannel;
case 'refundable_ticket':
case 'refundableticket':
return (raw as any).refundableTicket;
case 'fare_flexibility':
case 'fareflexibility':
return (raw as any).fareFlexibility;
case 'ticket_value':
case 'ticketvalue':
return (raw as any).ticketValue !== undefined
? Number((raw as any).ticketValue)
: (raw as any).value;
case 'ancillary_purchased':
case 'ancillarypurchased':
return (raw as any).ancillaryPurchased;
// BAGGAGE
case 'bag_status':
case 'bagstatus':
return (raw as any).bagStatus;
case 'bag_type':
case 'bagtype':
return (raw as any).bagType;
case 'bag_delay':
case 'bagdelay':
return (raw as any).bagDelay !== undefined ? Number((raw as any).bagDelay) : undefined;
case 'bag_value':
case 'bagvalue':
return (raw as any).bagValue !== undefined ? Number((raw as any).bagValue) : undefined;
case 'pir_created':
case 'pircreated':
return (raw as any).pirCreated;
// CABIN
case 'original_cabin':
case 'originalcabin':
return ctx.originalCabinId || ctx.originalCabinName || (raw as any).originalCabin;
case 'assigned_cabin':
case 'assignedcabin':
case 'actual_cabin':
case 'actualcabin':
return ctx.actualCabinId || ctx.actualCabinName || (raw as any).actualCabin;
case 'downgrade_level':
case 'downgradelevel':
return (raw as any).downgradeLevel !== undefined
? Number((raw as any).downgradeLevel)
: undefined;
case 'seat_type':
case 'seattype':
return (raw as any).seatType;
// ANCILLARY
case 'ancillary_type':
case 'ancillarytype':
return (raw as any).ancillaryType;
case 'ancillary_delivered':
case 'ancillarydelivered':
return (raw as any).ancillaryDelivered;
case 'service_value':
case 'servicevalue':
return (raw as any).serviceValue !== undefined
? Number((raw as any).serviceValue)
: undefined;
// GENERAL / COMMON
case 'scenario':
return ctx.scenario || (raw as any).scenario;
case 'category':
return ctx.category || (raw as any).category;
case 'jurisdiction':
return ctx.jurisdictionId || ctx.jurisdictionCode || (raw as any).jurisdiction;
case 'nationality':
return ctx.nationality || (raw as any).nationality;
default:
const camelCase = fc.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
return (
(ctx as any)[fc] ??
(ctx as any)[camelCase] ??
(raw as any)[fc] ??
(raw as any)[camelCase]
);
}
}
private compareValues(actual: any, expected: any, rawOpCode: string): boolean {
const op = this.normalizeOperatorCode(rawOpCode);
if (op === 'is_empty') {
return actual === undefined || actual === null || String(actual).trim() === '';
}
if (op === 'is_not_empty') {
return actual !== undefined && actual !== null && String(actual).trim() !== '';
}
if (actual === undefined || actual === null) return false;
const actStr = String(actual).toLowerCase().trim();
const expStr = String(expected || '').toLowerCase().trim();
// Check if numeric comparison
const actNum = Number(actual);
const expNum = Number(expected);
const isNumeric = !isNaN(actNum) && !isNaN(expNum) && expStr !== '';
if (isNumeric) {
switch (op) {
case 'eq':
return actNum === expNum;
case 'neq':
return actNum !== expNum;
case 'gt':
return actNum > expNum;
case 'gte':
return actNum >= expNum;
case 'lt':
return actNum < expNum;
case 'lte':
return actNum <= expNum;
case 'in':
const numInList = expStr.split(',').map((s) => Number(s.trim())).filter((n) => !isNaN(n));
return numInList.includes(actNum);
case 'not_in':
const numNotInList = expStr.split(',').map((s) => Number(s.trim())).filter((n) => !isNaN(n));
return !numNotInList.includes(actNum);
default:
return actNum === expNum;
}
}
// Smart String Matcher for Airport Codes & Master Data Dropdown Labels (e.g. "JFK" vs "JFK - New York JFK")
const isStringMatch = (a: string, e: string): boolean => {
if (!a && !e) return true;
if (!a || !e) return false;
if (a === e) return true;
if (a.includes(e) || e.includes(a)) return true;
// Extract IATA / token prefix before dash or space (e.g. "JFK - New York JFK" -> "jfk")
const eTokens = e.split(/[\s\-_]+/);
const aTokens = a.split(/[\s\-_]+/);
if (eTokens.some((t) => t && t.length >= 2 && a.includes(t))) return true;
if (aTokens.some((t) => t && t.length >= 2 && e.includes(t))) return true;
return false;
};
switch (op) {
case 'eq':
return isStringMatch(actStr, expStr);
case 'neq':
return !isStringMatch(actStr, expStr);
case 'gt':
return actStr > expStr;
case 'gte':
return actStr >= expStr;
case 'lt':
return actStr < expStr;
case 'lte':
return actStr <= expStr;
case 'contains':
return isStringMatch(actStr, expStr);
case 'not_contains':
return !isStringMatch(actStr, expStr);
case 'starts_with':
return actStr.startsWith(expStr);
case 'ends_with':
return actStr.endsWith(expStr);
case 'in':
const inList = expStr.split(',').map((s) => s.trim());
return inList.some((item) => isStringMatch(actStr, item));
case 'not_in':
const notInList = expStr.split(',').map((s) => s.trim());
return !notInList.some((item) => isStringMatch(actStr, item));
default:
return isStringMatch(actStr, expStr);
}
}
}