UNPKG

@tmlmobilidade/interfaces

Version:

This package provides SDK-style connectors for interacting with databases (e.g., stops, plans, rides, alerts) and external providers (e.g., authentication, storage). It simplifies data access and integration across projects.

481 lines (480 loc) 21.5 kB
import { Dates } from '@tmlmobilidade/dates'; // // Time thresholds const LAST_SEEN_WINDOW = 30 * 1000; // 30 seconds const OPERATIONAL_WINDOW = 60 * 1000 * 10; // 10 minutes const DELAY_THRESHOLDS = { delayed: 5 * 60 * 1000, // 5 minutes after scheduled time early: -1 * 60 * 1000, // 1 minute before scheduled time }; /** * Creates MongoDB aggregation pipeline stages to calculate and categorize delay statuses. * * This function generates three aggregation stages: * 1. Calculates delay differences (in milliseconds) between scheduled and observed times * 2. Categorizes delays into statuses: 'delayed', 'early', 'ontime', or 'none' * 3. Removes intermediate calculation fields * * Delay thresholds: * - Delayed: > 5 minutes (300000 ms) * - Early: < -1 minute (-60000 ms) * - On-time: between -1 minute and 5 minutes * - None: missing scheduled or observed time data * * @returns {Array} Array of MongoDB aggregation pipeline stages */ export function ridesPipelineDelayStatus({ filter } = {}) { const pipeline = [ // Stage 1: Calculate delay differences in milliseconds // Only compute if both scheduled and observed times exist { $addFields: { end_delay_diff: { $cond: { else: null, if: { $and: [ { $ifNull: ['$end_time_scheduled', false] }, { $ifNull: ['$end_time_observed', false] }, ] }, then: { $subtract: ['$end_time_observed', '$end_time_scheduled'] }, }, }, start_delay_diff: { $cond: { else: null, if: { $and: [ { $ifNull: ['$start_time_scheduled', false] }, { $ifNull: ['$start_time_observed', false] }, ] }, then: { $subtract: ['$start_time_observed', '$start_time_scheduled'] }, }, }, }, }, // Stage 2: Categorize delays into status strings // Uses switch statement to classify based on delay thresholds { $addFields: { end_delay_status: { $switch: { branches: [ // Delayed: > 5 minutes (300000 ms) { case: { $gt: ['$end_delay_diff', DELAY_THRESHOLDS.delayed] }, then: 'delayed' }, // Early: < -1 minute (-60000 ms) { case: { $lt: ['$end_delay_diff', DELAY_THRESHOLDS.early] }, then: 'early' }, // On-time: between -1 minute and 5 minutes { case: { $and: [ { $gte: ['$end_delay_diff', DELAY_THRESHOLDS.early] }, { $lte: ['$end_delay_diff', DELAY_THRESHOLDS.delayed] }, ] }, then: 'ontime' }, ], default: 'none', }, }, start_delay_status: { $switch: { branches: [ // Delayed: > 5 minutes (300000 ms) { case: { $gt: ['$start_delay_diff', DELAY_THRESHOLDS.delayed] }, then: 'delayed' }, // Early: < -1 minute (-60000 ms) { case: { $lt: ['$start_delay_diff', DELAY_THRESHOLDS.early] }, then: 'early' }, // On-time: between -1 minute and 5 minutes { case: { $and: [ { $gte: ['$start_delay_diff', DELAY_THRESHOLDS.early] }, { $lte: ['$start_delay_diff', DELAY_THRESHOLDS.delayed] }, ] }, then: 'ontime' }, ], default: 'none', }, }, }, }, // Stage 3: Remove intermediate calculation fields // These fields were only used for status calculation and are not needed in final output { $project: { end_delay_diff: 0, start_delay_diff: 0 } }, ]; // Stage 5: Filter by delay status if provided if (filter?.end_delay_status) { pipeline.push({ $match: { end_delay_status: { $in: filter.end_delay_status } } }); } if (filter?.start_delay_status) { pipeline.push({ $match: { start_delay_status: { $in: filter.start_delay_status } } }); } return pipeline; } /** * Creates MongoDB aggregation pipeline stages to calculate and categorize operational statuses. * * This function generates four aggregation stages: * 1. Adds the current timestamp (now) to each document * 2. Calculates time differences from last seen and from start time * 3. Categorizes operational status: 'scheduled', 'missed', 'running', or 'ended' * 4. Removes intermediate calculation fields * * Operational status logic: * - Scheduled: within 10 minutes of start time and never seen * - Missed: more than 10 minutes after start time and never seen * - Running: last seen within 10 minutes * - Ended: default fallback (last seen more than 10 minutes ago) * * Time thresholds: * - Operational window: 10 minutes (600000 ms) * * @param {number} now - Current timestamp in milliseconds * @returns {Array} Array of MongoDB aggregation pipeline stages */ export function ridesPipelineOperationalStatus({ filter } = {}) { const now = Dates.now('Europe/Lisbon').unix_timestamp; const pipeline = [ // Stage 1: Add current timestamp to each document { $addFields: { now } }, // Stage 2: Calculate time differences from last seen and from start time // Only compute if the relevant fields exist { $addFields: { milliseconds_from_last_seen_to_now: { $cond: { else: null, if: { $ifNull: ['$seen_last_at', false] }, then: { $subtract: ['$now', '$seen_last_at'] }, }, }, milliseconds_from_start_to_now: { $subtract: ['$now', '$start_time_scheduled'] }, }, }, // Stage 3: Categorize operational status using switch statement { $addFields: { operational_status: { $switch: { branches: [ // Scheduled: within 10 minutes of start time and never seen { case: { $and: [ { $lte: ['$milliseconds_from_start_to_now', OPERATIONAL_WINDOW] }, { $or: [{ $eq: ['$seen_last_at', null] }, { $not: ['$seen_last_at'] }] }, ], }, then: 'scheduled', }, // Missed: more than 10 minutes after start time and never seen { case: { $and: [ { $gt: ['$milliseconds_from_start_to_now', OPERATIONAL_WINDOW] }, { $or: [{ $eq: ['$seen_last_at', null] }, { $not: ['$seen_last_at'] }] }, ], }, then: 'missed', }, // Running: last seen within 10 minutes { case: { $lte: ['$milliseconds_from_last_seen_to_now', OPERATIONAL_WINDOW] }, then: 'running', }, ], default: 'ended', }, }, }, }, // Stage 4: Remove intermediate calculation fields // These fields were only used for status calculation and are not needed in final output { $project: { milliseconds_from_last_seen_to_now: 0, milliseconds_from_start_to_now: 0, now: 0, }, }, ]; // Stage 5: Filter by operational status if provided if (filter?.operational_status) { pipeline.push({ $match: { operational_status: { $in: filter.operational_status } } }); } return pipeline; } /** * Creates MongoDB aggregation pipeline stages to calculate and categorize seen statuses. * * This function generates three aggregation stages: * 1. Adds the current timestamp (now) to each document * 2. Calculates time difference from last seen to now * 3. Categorizes seen status: 'gone', 'seen', or 'unseen' * 4. Removes intermediate calculation fields * * Seen status logic: * - Gone: last seen more than 30 seconds ago * - Seen: last seen within 30 seconds * - Unseen: no last seen time * * Time thresholds: * - Seen window: 30 seconds (30000 ms) * * @param {number} now - Current timestamp in milliseconds * @returns {Array} Array of MongoDB aggregation pipeline stages */ export function ridesPipelineSeenStatus({ filter } = {}) { const now = Dates.now('Europe/Lisbon').unix_timestamp; const pipeline = [ // Stage 1: Add current timestamp to each document { $addFields: { now } }, // Stage 2: Calculate time difference from last seen to now // Only compute if the last seen time exists { $addFields: { milliseconds_from_last_seen_to_now: { $cond: { else: null, if: { $ifNull: ['$seen_last_at', false] }, then: { $subtract: ['$now', '$seen_last_at'] }, }, }, }, }, // Stage 3: Categorize seen status using switch statement { $addFields: { seen_status: { $switch: { branches: [ { case: { $eq: ['$seen_last_at', null] }, then: 'unseen' }, { case: { $lte: ['$milliseconds_from_last_seen_to_now', LAST_SEEN_WINDOW] }, then: 'seen' }, ], default: 'gone', }, }, }, }, // Stage 4: Remove intermediate calculation fields // These fields were only used for status calculation and are not needed in final output { $project: { milliseconds_from_last_seen_to_now: 0, now: 0, }, }, ]; // Stage 5: Filter by seen status if provided if (filter?.seen_status) { pipeline.push({ $match: { seen_status: { $in: filter.seen_status } } }); } return pipeline; } export function ridesPipelineTicketingStatus({ filter } = {}) { const pipeline = []; if (!filter?.ticketing_status?.length) return pipeline; const includesHasTicketing = filter.ticketing_status.includes('has_ticketing'); const includesNoTicketing = filter.ticketing_status.includes('no_ticketing'); // If both are present, match all documents (no filter needed) if (includesHasTicketing && includesNoTicketing) return pipeline; if (includesHasTicketing) { pipeline.push({ $match: { apex_validations_qty: { $gte: 1 } } }); } if (includesNoTicketing) { pipeline.push({ $match: { apex_validations_qty: { $eq: 0 } } }); } return pipeline; } /** * Attempts to map search term to specific indexed field based on structural * heuristics. Returns null when term does not match known pattern. */ function routeTermToField(term) { // Exact pattern_id match: "1001_0_2" if (/^\d+_\d+_\d+$/.test(term)) { return { pattern_id: term }; } // Wildcard trip_id: any string containing %%; %% → regex .* // NOTE: case-sensitive (no $options: 'i') so anchored prefix regex can use // the { trip_id: 1 } / { trip_id: 1, start_time_scheduled: 1 } index. if (term.includes('%%')) { let escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); escaped = escaped.replace(/%%/g, '.*'); return { trip_id: { $regex: `^${escaped}$` } }; } // route_id: "1001_0" if (/^\d+_\d+$/.test(term)) { return { route_id: term }; } // operational_date: "20240101" if (/^\d{8}$/.test(term)) { return { operational_date: term }; } // line_id: pure integer string like "1001" // agency_id also numeric, so use length heuristic if (/^\d+$/.test(term)) { const n = Number(term); if (term.length >= 3) return { line_id: n }; } return null; } function buildSearchPipeline(filter) { const pipeline = []; if (!filter.search) return pipeline; const vehicleMatch = filter.search.match(/v:([\d,]+)/); const driverMatch = filter.search.match(/d:([\d,]+)/); const rawSearch = filter.search .replace(/v:[\d,]+/g, '') .replace(/d:[\d,]+/g, '') .trim(); if (rawSearch.length > 0) { const terms = rawSearch .split(/\s+/) .filter(k => k.length > 0); const conditions = []; const unroutableTerms = []; const routedFields = new Set(); for (const term of terms) { const routed = routeTermToField(term); if (routed) { const [field] = Object.keys(routed); if (routedFields.has(field)) { unroutableTerms.push(term); continue; } routedFields.add(field); conditions.push(routed); } else { unroutableTerms.push(term); } } if (unroutableTerms.length > 0) { // Anchor first term as prefix on trip_id so the query can use the // { trip_id: 1, start_time_scheduled: 1 } compound index. // Remaining terms become plain contains-regex (no index, but bounded // by the prefix scan of the first term). // NOTE: case-sensitive; adding $options: 'i' disables index usage. const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const [firstTerm, ...restTerms] = unroutableTerms; conditions.push({ trip_id: { $regex: `^${escape(firstTerm)}` } }); for (const rest of restTerms) { conditions.push({ trip_id: { $regex: escape(rest) } }); } } if (conditions.length === 1) { pipeline.push({ $match: conditions[0] }); } else if (conditions.length > 1) { pipeline.push({ $match: { $and: conditions } }); } } if (vehicleMatch) { const vehicleIDs = vehicleMatch[1] .split(',') .map(id => Number(id.trim())) .filter(id => !isNaN(id)); if (vehicleIDs.length > 0) { pipeline.push({ $match: { vehicle_ids: { $in: vehicleIDs } } }); } } if (driverMatch) { const driverIDs = driverMatch[1] .split(',') .map(id => id.trim()) .filter(Boolean); if (driverIDs.length > 0) { pipeline.push({ $match: { driver_ids: { $in: driverIDs } } }); } } return pipeline; } /** * Creates MongoDB aggregation pipeline stages to filter and process ride data. * * This function generates an aggregation pipeline with the following stages: * 1. Filters rides by scheduled time range (date_start to date_end) * 2. Optionally filters by line IDs * 3. Optionally filters by agency IDs * 4. Optionally searches rides by ride ID using regex pattern matching and, if present, by vehicle IDs in the search string (with support for "v:1117,1118" etc.) * 5. Adds acceptance status by joining from the ride_acceptances collection * 6. Filters by analysis grades (ended_at_last_stop, expected_apex_validation_interval, simple_three_vehicle_events) * 7. Filters by acceptance status (excluding 'none' if present) * 8. Applies delay, operational, and seen status filters using dedicated pipeline functions * * @param {Object} params - Parameters object * @param {RidesPipelineFilter} params.filter - Filter criteria for rides * @returns {AggregationPipeline<Ride>} Array of MongoDB aggregation pipeline stages */ export function ridesBatchAggregationPipeline({ ...filter }) { const pipeline = []; // Stage 1: Filter by scheduled time range (required) if ('date_start' in filter && 'date_end' in filter) { pipeline.push({ $match: { start_time_scheduled: { $gte: filter.date_start, $lte: filter.date_end } } }); } else if ('operational_date_start' in filter && 'operational_date_end' in filter) { pipeline.push({ $match: { operational_date: { $gte: filter.operational_date_start, $lte: filter.operational_date_end } } }); } else { throw new Error('Either date_start and date_end or operational_date_start and operational_date_end must be provided'); } // Stage 2: Filter by agency IDs (required) pipeline.push({ $match: { agency_id: { $in: filter.agency_ids ?? [] } } }); // Stage 2.1: Sort by start_time_scheduled pipeline.push({ $sort: { start_time_scheduled: 1 } }); // Stage 3: Filter by line IDs if provided if (filter.line_ids?.length) pipeline.push({ $match: { line_id: { $in: filter.line_ids.map(id => Number(id)) } } }); // Stage 4: Search by term routing and selective fallback regex pipeline.push(...buildSearchPipeline(filter)); // Stage 5: Search by vehicle IDs from search field if provided (legacy support) // This allows filtering via search field with "v:1117" or "v:1117,1118" format // Remove literal 'v:' from the search string since it is breaking stuff // Stage 6: Add acceptance status from ride_acceptances collection // Lookup joins acceptance data, unwinds to flatten, adds status field, and removes intermediate data pipeline.push({ $lookup: { as: 'acceptance', foreignField: 'ride_id', from: 'ride_acceptances', localField: '_id' } }, { $unwind: { path: '$acceptance', preserveNullAndEmptyArrays: true } }, { $addFields: { acceptance_status: '$acceptance.acceptance_status' } }, { $project: { acceptance: 0 } }); // Stage 7: Filter by analysis grades // Maps filter fields to their corresponding analysis paths in the document const analysisFilters = [ { field: 'analysis_ended_at_last_stop_grade', path: 'analysis.ENDED_AT_LAST_STOP.grade' }, { field: 'analysis_expected_apex_validation_interval', path: 'analysis.EXPECTED_APEX_VALIDATION_INTERVAL.grade' }, { field: 'analysis_simple_three_vehicle_events_grade', path: 'analysis.SIMPLE_THREE_VEHICLE_EVENTS.grade' }, { field: 'analysis_transaction_sequentiality', path: 'analysis.TRANSACTION_SEQUENTIALITY.grade' }, ]; analysisFilters.forEach(({ field, path }) => { if (!filter[field]) return; if (filter[field].includes('none')) { // When 'none' is included, set the field to 'none' // if it doesn't exist, then match on the filter array pipeline.push({ $addFields: { [path]: { $ifNull: [`$${path}`, 'none'] }, }, }); } // Match documents where the field value is in the filter array pipeline.push({ $match: { [path]: { $in: filter[field] }, }, }); }); // Stage 8: Filter by acceptance status // Only applies filter if acceptance_status is provided and doesn't include 'none' if (filter.acceptance_status && filter.acceptance_status) { if (filter.acceptance_status.includes('none')) { // When 'none' is included, set the field to 'none' // if it doesn't exist, then match on the filter array pipeline.push({ $addFields: { acceptance_status: { $ifNull: [`$acceptance_status`, 'none'] }, }, }); } pipeline.push({ $match: { acceptance_status: { $exists: true } } }, { $match: { acceptance_status: { $in: filter.acceptance_status } } }); } // Stage 9: Apply status filters using dedicated pipeline functions // These functions add calculated status fields and filter by them pipeline.push(...ridesPipelineDelayStatus({ filter: { end_delay_status: filter.delay_statuses, start_delay_status: filter.delay_statuses } })); pipeline.push(...ridesPipelineOperationalStatus({ filter: { operational_status: filter.operational_statuses } })); pipeline.push(...ridesPipelineSeenStatus({ filter: { seen_status: filter.seen_statuses } })); pipeline.push(...ridesPipelineTicketingStatus({ filter: { ticketing_status: filter.ticketing_status } })); return pipeline; }