Improve support for trips that span more than one date

- Remove the where clause on schedule times, as it is not possible to compute a sensible driver date within the SQL query.
- Simplify the date clause comparisons
- Replace the compared date range with one adjusted with the driver duration, to catch ads that depart on a different day
This commit is contained in:
Romain Thouvenin 2024-04-18 17:54:40 +02:00
parent a9f5c36d49
commit 3c65582d8e
3 changed files with 84 additions and 146 deletions

View File

@ -1,16 +1,19 @@
import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { ScheduleItem } from '@modules/ad/core/domain/value-objects/schedule-item.value-object';
import { Point } from '../../../types/point.type'; import { Point } from '../../../types/point.type';
import { Waypoint } from '../../../types/waypoint.type'; import { Waypoint } from '../../../types/waypoint.type';
import { Selector } from '../algorithm.abstract'; import { Selector } from '../algorithm.abstract';
import { ScheduleItem } from '../match.query';
/** /**
* This class complements the AdRepository prisma service by turning a match query object into a SQL query, * This class complements the AdRepository prisma service by turning a match query object into a SQL query,
* with the assumption that the query is passenger-oriented (i.e. it is up to the driver to go out of his way to pick up the passenger) * with the assumption that the query is passenger-oriented (i.e. it is up to the driver to go out of his way to pick up the passenger).
* TODO: Converting the query object into a SQL query is a job for the prisma service / repository implementation, * The idea is to make a rough filter of the ads in DB to limit the number of ads to be processed more precisely by the application code.
* any logic related to being passenger-oriented should be in the domain layer * TODO: Converting the query object into a SQL query is a job for the repository implementation
* (or anything behind the repository interface),
* any logic related to being passenger-oriented should be in the domain layer.
* (though it might be difficult to describe generically the search criteria with a query object)
*/ */
export class PassengerOrientedSelector extends Selector { export class PassengerOrientedSelector extends Selector {
select = async (): Promise<CandidateEntity[]> => { select = async (): Promise<CandidateEntity[]> => {
@ -25,6 +28,7 @@ export class PassengerOrientedSelector extends Selector {
query: this._createQueryString(Role.PASSENGER), query: this._createQueryString(Role.PASSENGER),
role: Role.PASSENGER, role: Role.PASSENGER,
}); });
return ( return (
await Promise.all( await Promise.all(
queryStringRoles.map<Promise<AdsRole>>( queryStringRoles.map<Promise<AdsRole>>(
@ -140,8 +144,7 @@ export class PassengerOrientedSelector extends Selector {
[ [
this._whereRole(role), this._whereRole(role),
this._whereStrict(), this._whereStrict(),
this._whereDate(), this._whereDate(role),
this._whereSchedule(role),
this._whereExcludedAd(), this._whereExcludedAd(),
this._whereAzimuth(), this._whereAzimuth(),
this._whereProportion(role), this._whereProportion(role),
@ -160,110 +163,71 @@ export class PassengerOrientedSelector extends Selector {
: `frequency='${Frequency.RECURRENT}'` : `frequency='${Frequency.RECURRENT}'`
: ''; : '';
private _whereDate = (): string => /**
this.query.frequency == Frequency.PUNCTUAL * Generates the WHERE clause checking that the date range of the query intersects with the range of the ad.
? `("fromDate" <= '${this.query.fromDate}' AND "toDate" >= '${this.query.fromDate}')` * Note that driver dates might not be comparable with passenger dates when the trip is by night or very long.
: `(\ * For this reason, the pickup date is adjusted with the driver duration,
(\ * so as to compare with the maximum / minimum driver date that could make sense for the passenger.
"fromDate" <= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\ * This may return more ads than necessary, but they will be filtered out in further processing.
"toDate" >= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\ */
) OR (\ private _whereDate = (role: Role): string => {
"fromDate" >= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\ const maxFromDate = this._maxFromDate(role);
"toDate" <= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\ const minToDate = this._minToDate(role);
) OR (\ return `("fromDate" <= ${maxFromDate} AND "toDate" >= ${minToDate})`;
"fromDate" <= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\ };
"toDate" <= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\
) OR (\
"fromDate" >= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\
"toDate" >= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\
)\
)`;
private _whereSchedule = (role: Role): string => { private _maxFromDate = (role: Role): string => {
// no schedule filtering if schedule is not set if (role == Role.DRIVER) {
if (this.query.schedule === undefined) return ''; const querySchedule = this.query.schedule;
const schedule: string[] = []; // When there is no schedule (search whole day), we consider the driver accepts to depart until 23:59
// we need full dates to compare times, because margins can lead to compare on previous or next day const maxScheduleTime =
// - first we establish a base calendar (up to a week) querySchedule === undefined
const scheduleDates: Date[] = this._datesBetweenBoundaries( ? '23:59'
this.query.fromDate, : querySchedule.reduce(
this.query.toDate, (max, s) => (s.time > max ? s.time : max),
); '00:00',
// - then we compare each resulting day of the schedule with each day of calendar, );
// adding / removing margin depending on the role const [h, m] = maxScheduleTime.split(':');
scheduleDates.map((date: Date) => { const maxFromDate = new Date(this.query.toDate);
(this.query.schedule as ScheduleItem[]) maxFromDate.setHours(parseInt(h));
.filter( maxFromDate.setMinutes(parseInt(m));
(scheduleItem: ScheduleItem) => date.getUTCDay() == scheduleItem.day, maxFromDate.setSeconds(this.query.driverRoute!.duration);
) return `'${maxFromDate.getUTCFullYear()}-${maxFromDate.getUTCMonth() + 1}-${maxFromDate.getUTCDate()}'`;
.map((scheduleItem: ScheduleItem) => { } else {
switch (role) { return `'${this.query.toDate}'`;
case Role.PASSENGER: }
schedule.push(this._wherePassengerSchedule(date, scheduleItem)); };
break;
case Role.DRIVER: private _minToDate = (role: Role): string => {
schedule.push(this._whereDriverSchedule(date, scheduleItem)); if (role == Role.PASSENGER) {
break; const querySchedule = this.query.schedule;
} // When there is no schedule (search whole day), we consider the passenger accepts to depart from 00:00
}); const minScheduleTime =
}); querySchedule === undefined
if (schedule.length > 0) { ? '00:00'
return ['(', schedule.join(' OR '), ')'].join(''); : querySchedule.reduce(
(min, s) => (s.time < min ? s.time : min),
'23:59',
);
const [h, m] = minScheduleTime.split(':');
const minToDate = new Date(this.query.fromDate);
minToDate.setHours(parseInt(h));
minToDate.setMinutes(parseInt(m));
return `(make_timestamp(\
${minToDate.getUTCFullYear()},\
${minToDate.getUTCMonth() + 1},\
${minToDate.getUTCDate()},\
${minToDate.getUTCHours()},\
${minToDate.getUTCMinutes()},0)\
- concat("driverDuration", ' second')::interval)::date`;
} else {
return `'${this.query.fromDate}'`;
} }
return '';
}; };
private _whereExcludedAd = (): string => private _whereExcludedAd = (): string =>
this.query.excludedAdId ? `ad.uuid <> '${this.query.excludedAdId}'` : ''; this.query.excludedAdId ? `ad.uuid <> '${this.query.excludedAdId}'` : '';
private _wherePassengerSchedule = (
date: Date,
scheduleItem: ScheduleItem,
): string => {
let maxDepartureDatetime: Date = new Date(date);
maxDepartureDatetime.setHours(parseInt(scheduleItem.time.split(':')[0]));
maxDepartureDatetime.setMinutes(parseInt(scheduleItem.time.split(':')[1]));
maxDepartureDatetime = this._addMargin(
maxDepartureDatetime,
scheduleItem.margin as number,
);
// we want the min departure time of the driver to be before the max departure time of the passenger
return `make_timestamp(\
${maxDepartureDatetime.getUTCFullYear()},\
${maxDepartureDatetime.getUTCMonth() + 1},\
${maxDepartureDatetime.getUTCDate()},\
CAST(EXTRACT(hour from time) as integer),\
CAST(EXTRACT(minute from time) as integer),0) - interval '1 second' * margin <=\
make_timestamp(\
${maxDepartureDatetime.getUTCFullYear()},\
${maxDepartureDatetime.getUTCMonth() + 1},\
${maxDepartureDatetime.getUTCDate()},${maxDepartureDatetime.getUTCHours()},${maxDepartureDatetime.getUTCMinutes()},0)`;
};
private _whereDriverSchedule = (
date: Date,
scheduleItem: ScheduleItem,
): string => {
let minDepartureDatetime: Date = new Date(date);
minDepartureDatetime.setHours(parseInt(scheduleItem.time.split(':')[0]));
minDepartureDatetime.setMinutes(parseInt(scheduleItem.time.split(':')[1]));
minDepartureDatetime = this._addMargin(
minDepartureDatetime,
-(scheduleItem.margin as number),
);
// we want the max departure time of the passenger to be after the min departure time of the driver
return `make_timestamp(\
${minDepartureDatetime.getUTCFullYear()},
${minDepartureDatetime.getUTCMonth() + 1},
${minDepartureDatetime.getUTCDate()},\
CAST(EXTRACT(hour from time) as integer),\
CAST(EXTRACT(minute from time) as integer),0) + interval '1 second' * margin >=\
make_timestamp(\
${minDepartureDatetime.getUTCFullYear()},
${minDepartureDatetime.getUTCMonth() + 1},
${minDepartureDatetime.getUTCDate()},${minDepartureDatetime.getUTCHours()},${minDepartureDatetime.getUTCMinutes()},0)`;
};
private _whereAzimuth = (): string => { private _whereAzimuth = (): string => {
if (!this.query.useAzimuth) return ''; if (!this.query.useAzimuth) return '';
const { minAzimuth, maxAzimuth } = this._azimuthRange( const { minAzimuth, maxAzimuth } = this._azimuthRange(
@ -323,37 +287,6 @@ export class PassengerOrientedSelector extends Selector {
} }
}; };
/**
* Returns an array of dates containing all the dates (limited to 7 by default) between 2 boundary dates.
*
* The array length can be limited to a _max_ number of dates (default: 7)
*/
private _datesBetweenBoundaries = (
firstDate: string,
lastDate: string,
max = 7,
): Date[] => {
const fromDate: Date = new Date(firstDate);
const toDate: Date = new Date(lastDate);
const dates: Date[] = [];
let count = 0;
for (
let date = fromDate;
date <= toDate;
date.setUTCDate(date.getUTCDate() + 1)
) {
dates.push(new Date(date));
count++;
if (count == max) break;
}
return dates;
};
private _addMargin = (date: Date, marginInSeconds: number): Date => {
date.setUTCSeconds(marginInSeconds);
return date;
};
private _azimuthRange = ( private _azimuthRange = (
azimuth: number, azimuth: number,
margin: number, margin: number,

View File

@ -353,7 +353,7 @@ class Schedule extends ValueObject<{
duration, duration,
); );
acc.push({ acc.push({
day: itemDate.getUTCDay(), day: driverStartDatetime.getUTCDay(),
margin: scheduleItemProps.margin, margin: scheduleItemProps.margin,
time: this._formatTime(driverStartDatetime), time: this._formatTime(driverStartDatetime),
}); });

View File

@ -1,8 +1,8 @@
import { import {
Domain,
KeyType,
Configurator, Configurator,
Domain,
GetConfigurationRepositoryPort, GetConfigurationRepositoryPort,
KeyType,
} from '@mobicoop/configuration-module'; } from '@mobicoop/configuration-module';
import { import {
CARPOOL_CONFIG_DEPARTURE_TIME_MARGIN, CARPOOL_CONFIG_DEPARTURE_TIME_MARGIN,
@ -16,8 +16,9 @@ import {
AD_REPOSITORY, AD_REPOSITORY,
INPUT_DATETIME_TRANSFORMER, INPUT_DATETIME_TRANSFORMER,
MATCHING_REPOSITORY, MATCHING_REPOSITORY,
TIMEZONE_FINDER,
TIME_CONVERTER,
} from '@modules/ad/ad.di-tokens'; } from '@modules/ad/ad.di-tokens';
import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port';
import { MatchingRepositoryPort } from '@modules/ad/core/application/ports/matching.repository.port'; import { MatchingRepositoryPort } from '@modules/ad/core/application/ports/matching.repository.port';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import { import {
@ -30,6 +31,9 @@ import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { Target } from '@modules/ad/core/domain/candidate.types'; import { Target } from '@modules/ad/core/domain/candidate.types';
import { MatchEntity } from '@modules/ad/core/domain/match.entity'; import { MatchEntity } from '@modules/ad/core/domain/match.entity';
import { MatchingEntity } from '@modules/ad/core/domain/matching.entity'; import { MatchingEntity } from '@modules/ad/core/domain/matching.entity';
import { InputDateTimeTransformer } from '@modules/ad/infrastructure/input-datetime-transformer';
import { TimeConverter } from '@modules/ad/infrastructure/time-converter';
import { TimezoneFinder } from '@modules/ad/infrastructure/timezone-finder';
import { import {
MATCH_CONFIG_ALGORITHM, MATCH_CONFIG_ALGORITHM,
MATCH_CONFIG_AZIMUTH_MARGIN, MATCH_CONFIG_AZIMUTH_MARGIN,
@ -344,13 +348,6 @@ const mockConfigurationRepository: GetConfigurationRepositoryPort = {
), ),
}; };
const mockInputDateTimeTransformer: DateTimeTransformerPort = {
fromDate: jest.fn(),
toDate: jest.fn(),
day: jest.fn(),
time: jest.fn(),
};
const mockRouteProvider = simpleMockGeorouter; const mockRouteProvider = simpleMockGeorouter;
describe('Match Query Handler', () => { describe('Match Query Handler', () => {
@ -372,9 +369,17 @@ describe('Match Query Handler', () => {
provide: AD_CONFIGURATION_REPOSITORY, provide: AD_CONFIGURATION_REPOSITORY,
useValue: mockConfigurationRepository, useValue: mockConfigurationRepository,
}, },
{
provide: TIMEZONE_FINDER,
useClass: TimezoneFinder,
},
{
provide: TIME_CONVERTER,
useClass: TimeConverter,
},
{ {
provide: INPUT_DATETIME_TRANSFORMER, provide: INPUT_DATETIME_TRANSFORMER,
useValue: mockInputDateTimeTransformer, useClass: InputDateTimeTransformer,
}, },
], ],
}).compile(); }).compile();