Compliance Rules with Configuration

From 3B Knowledge
Jump to navigation Jump to search

Intro

The compliance engine in 3B (currently only the scheduler compliance engine) allows you to configure fields within a compliance rule that will be made visible in the matching screen. These fields can then modify the behaviour of a given compliance rule, such as adding distance limitation to a location search rule.

Set Up

A text field called "Configuration" is available on the Scheduling Compliance Rule custom metadata item. You need to set a valid JSON object as per the example below:

{
    "searchModifiers": [
        {
            "label": "Distance",
            "type": "field-select",
            "field": "distance_select",
            "default": "1",
            "options": [
                {
                    "label": "Up to 1 mile away",
                    "value": "1"
                },
                {
                    "label": "Up to 5 miles away",
                    "value": "5"
                },
                {
                    "label": "Any",
                    "value": "99999"
                }
            ]
        }
    ]
}

This JSON configuration defines something called "search modifiers" - i.e. fields with static values. In the example above, we are creating a drop-down field with three options, a default option and a unique API name "distance_select".

Configurable Compliance Rule Fields

This configuration will render a drop down field together with a search icon within the shift matching pop-up in the scheduler as per the screenshot.

When the compliance engine executes for the first time (i.e. when you open the pop up, immediately after invocing the matching engine), we will execute the matching rules with any default values. In the example above, the distance_select will be set to "1", as this is the default value.

A user can then update the field and click on the search button (the button with the magnifying glass) in order to execute another search with the updated filter parameters.

The compliance rule too needs to be updated to support the modifiers - this is why, there is an object available called "matchingModifiers" - this object will contain the values of any and all modifiers defined across all compliance rules.

The example code below searches for candidates within a certain distance, as long as that distance is within the perimeter distance selected by the user.

async ({ event, events, contactId, loaders, matchingModifiers, EvaluationResult }) => {
        if (!contactId) {
            console.warn('No contact id for evaluation');
            return [];
        }

        const contactsLoader = loaders.find((l) => l.name === 'contacts');
        const contact = contactsLoader?.loader?.grouped[contactId][0];

        if(!contact?.MailingLatitude || ! contact?.MailingLongitude){
            return [
                new EvaluationResult({
                    event: event,
                    detail: `🫢 Unknown candidate location`,
                    category: 'Distance',
                    points: 0,
                }),
            ];
        }else if(!event?.extendedProps?.record?.b3s__Site__r?.b3o__Coordinates__Latitude__s || !event?.extendedProps?.record?.b3s__Site__r?.b3o__Coordinates__Longitude__s){
            return [
                new EvaluationResult({
                    event: event,
                    detail: `🫢 Unknown site location`,
                    category: 'Distance',
                    points: 0,
                }),
            ];
        }


    //A haversine formula, which calculates the distance between two
    //points on a sphere as the crow flies.
    const getDistanceFromLatLon = function (lat1, lon1, lat2, lon2, unit = 'K') {
        if (lat1 == lat2 && lon1 == lon2) {
            return 0;
        } else {
            const radlat1 = (Math.PI * lat1) / 180;
            const radlat2 = (Math.PI * lat2) / 180;
            const theta = lon1 - lon2;
            const radtheta = (Math.PI * theta) / 180;
            let dist =
                Math.sin(radlat1) * Math.sin(radlat2) +
                Math.cos(radlat1) * Math.cos(radlat2) * Math.cos(radtheta);
            if (dist > 1) {
                dist = 1;
            }
            dist = Math.acos(dist);
            dist = (dist * 180) / Math.PI;
            dist = dist * 60 * 1.1515;
            if (unit == 'K') {
                dist = dist * 1.609344;
            }
            if (unit == 'N') {
                dist = dist * 0.8684;
            }
            return dist;
        }
    };

    const distance = getDistanceFromLatLon(
        contact.MailingLatitude,
        contact.MailingLongitude,
        event.extendedProps.record?.b3s__Site__r?.b3o__Coordinates__Latitude__s,
        event.extendedProps.record?.b3s__Site__r?.b3o__Coordinates__Longitude__s,
    );

    const maxAllowedDistance = Number(matchingModifiers?.distance_select ?? 9999);

    if (distance <= maxAllowedDistance) {
        return [
            new EvaluationResult({
                event: event,
                detail: `📍 Lives nearby ${distance.toFixed(2)} miles away`,
                category: 'Distance',
                points: 25,
            }),
        ];
    } else  {
        return [
            new EvaluationResult({
                event: event,
                detail: `🛫 Lives too far (${distance.toFixed(2)} miles away)`,
                category: 'Distance',
                points: -25,
            }),
        ];
    }
}

It is also possible to dynamically change the positive and negative scores of an attribute based on which option is selected by the user - see scoreProfile below

async ({ event, events, contactId, loaders, matchingModifiers, EvaluationResult }) => {
    if (!contactId) {
        console.warn('No contact id for evaluation');
        return [];
    }

    const contactsLoader = loaders.find((l) => l.name === 'contacts');
    const contact = contactsLoader?.loader?.grouped?.[contactId]?.[0];

    if (!contact?.MailingLatitude || !contact?.MailingLongitude) {
        return [
            new EvaluationResult({
                event: event,
                detail: `🫢 Unknown candidate location`,
                category: 'Distance',
                points: 0,
            }),
        ];
    } else if (
        !event?.extendedProps?.record?.b3s__Site__r?.b3o__Coordinates__Latitude__s ||
        !event?.extendedProps?.record?.b3s__Site__r?.b3o__Coordinates__Longitude__s
    ) {
        return [
            new EvaluationResult({
                event: event,
                detail: `🫢 Unknown site location`,
                category: 'Distance',
                points: 0,
            }),
        ];
    }

    // Haversine-style distance calculation
    const getDistanceFromLatLon = function (lat1, lon1, lat2, lon2, unit = 'M') {
        if (lat1 == lat2 && lon1 == lon2) {
            return 0;
        }

        const radlat1 = (Math.PI * lat1) / 180;
        const radlat2 = (Math.PI * lat2) / 180;
        const theta = lon1 - lon2;
        const radtheta = (Math.PI * theta) / 180;

        let dist =
            Math.sin(radlat1) * Math.sin(radlat2) +
            Math.cos(radlat1) * Math.cos(radlat2) * Math.cos(radtheta);

        if (dist > 1) {
            dist = 1;
        }

        dist = Math.acos(dist);
        dist = (dist * 180) / Math.PI;
        dist = dist * 60 * 1.1515; // miles

        if (unit === 'K') {
            dist = dist * 1.609344;
        }

        if (unit === 'N') {
            dist = dist * 0.8684;
        }

        return dist;
    };

    const distance = getDistanceFromLatLon(
        contact.MailingLatitude,
        contact.MailingLongitude,
        event.extendedProps.record?.b3s__Site__r?.b3o__Coordinates__Latitude__s,
        event.extendedProps.record?.b3s__Site__r?.b3o__Coordinates__Longitude__s,
        'M'
    );

    const maxAllowedDistance = Number(matchingModifiers?.distance_select ?? 99999);

    const distanceScoring = {
        5: { boost: 25, penalty: -100 },
        15: { boost: 15, penalty: -60 },
        99999: { boost: 5, penalty: -35 },
    };

    const scoreProfile = distanceScoring[maxAllowedDistance] ?? distanceScoring[99999];

    if (distance <= maxAllowedDistance) {
        return [
            new EvaluationResult({
                event: event,
                detail: `📍 Lives nearby (${distance.toFixed(2)} miles away)`,
                category: 'Distance',
                points: scoreProfile.boost,
            }),
        ];
    } else {
        return [
            new EvaluationResult({
                event: event,
                detail: `🛫 Lives too far (${distance.toFixed(2)} miles away)`,
                category: 'Distance',
                points: scoreProfile.penalty,
            }),
        ];
    }
}