matcher/src/modules/ad/core/domain/candidate.entity.ts

400 lines
13 KiB
TypeScript

import {
AggregateRoot,
AggregateID,
ArgumentInvalidException,
} from '@mobicoop/ddd-library';
import {
CandidateProps,
CreateCandidateProps,
Target,
} from './candidate.types';
import {
CarpoolPathItem,
CarpoolPathItemProps,
} from './value-objects/carpool-path-item.value-object';
import { Step, StepProps } from './value-objects/step.value-object';
import {
ScheduleItem,
ScheduleItemProps,
} from './value-objects/schedule-item.value-object';
import { Journey } from './value-objects/journey.value-object';
import { CalendarTools } from './calendar-tools.service';
import { JourneyItem } from './value-objects/journey-item.value-object';
import { Actor } from './value-objects/actor.value-object';
import { ActorTime } from './value-objects/actor-time.value-object';
import { Role } from './ad.types';
export class CandidateEntity extends AggregateRoot<CandidateProps> {
protected readonly _id: AggregateID;
static create = (create: CreateCandidateProps): CandidateEntity => {
const props: CandidateProps = { ...create };
return new CandidateEntity({ id: create.id, props });
};
setCarpoolPath = (carpoolPath: CarpoolPathItemProps[]): CandidateEntity => {
this.props.carpoolPath = carpoolPath;
return this;
};
setMetrics = (distance: number, duration: number): CandidateEntity => {
this.props.distance = distance;
this.props.duration = duration;
return this;
};
setSteps = (steps: StepProps[]): CandidateEntity => {
this.props.steps = steps;
return this;
};
isDetourValid = (): boolean =>
this._validateDistanceDetour() && this._validateDurationDetour();
hasJourneys = (): boolean =>
this.getProps().journeys !== undefined &&
(this.getProps().journeys as Journey[]).length > 0;
/**
* Create the journeys based on the driver schedule (the driver 'drives' the carpool !)
* This is a tedious process : additional information can be found in deeper methods !
*/
createJourneys = (): CandidateEntity => {
// driver and passenger schedules are mandatory
if (!this.props.driverSchedule) this._createDriverSchedule();
if (!this.props.passengerSchedule) this._createPassengerSchedule();
try {
this.props.journeys = (this.props.driverSchedule as ScheduleItemProps[])
// first we create the journeys
.map((driverScheduleItem: ScheduleItem) =>
this._createJourney(driverScheduleItem),
)
// then we filter the ones with invalid pickups
.filter((journey: Journey) => journey.hasValidPickUp());
} catch (e) {
// irrelevant journeys fall here
// eg. no available day for the given date range
}
return this;
};
private _validateDurationDetour = (): boolean =>
this.props.duration
? this.props.duration <=
this.props.driverDuration *
(1 + this.props.spacetimeDetourRatio.maxDurationDetourRatio)
: false;
private _validateDistanceDetour = (): boolean =>
this.props.distance
? this.props.distance <=
this.props.driverDistance *
(1 + this.props.spacetimeDetourRatio.maxDistanceDetourRatio)
: false;
/**
* Create the driver schedule based on the passenger schedule
*/
private _createDriverSchedule = (): void => {
if (this.props.passengerSchedule) {
let driverSchedule: ScheduleItemProps[] =
this.props.passengerSchedule.map(
(scheduleItemProps: ScheduleItemProps) => ({
day: scheduleItemProps.day,
time: scheduleItemProps.time,
margin: scheduleItemProps.margin,
}),
);
// adjust the driver theoretical schedule :
// we guess the ideal driver departure time based on the duration to
// reach the passenger starting point from the driver starting point
driverSchedule = driverSchedule.map(
(scheduleItemProps: ScheduleItemProps) => {
const driverDate: Date = CalendarTools.firstDate(
scheduleItemProps.day,
this.props.dateInterval,
);
const driverStartDatetime: Date = CalendarTools.datetimeWithSeconds(
driverDate,
scheduleItemProps.time,
-this._passengerStartDuration(),
);
return {
day: driverDate.getUTCDay(),
margin: scheduleItemProps.margin,
time: `${driverStartDatetime
.getUTCHours()
.toString()
.padStart(2, '0')}:${driverStartDatetime
.getUTCMinutes()
.toString()
.padStart(2, '0')}`,
};
},
);
this.props.driverSchedule = driverSchedule.map(
(scheduleItemProps: ScheduleItemProps) => ({
day: scheduleItemProps.day,
time: scheduleItemProps.time,
margin: scheduleItemProps.margin,
}),
);
}
};
/**
* Return the duration to reach the passenger starting point from the driver starting point
*/
private _passengerStartDuration = (): number => {
let passengerStartStepIndex = 0;
this.props.carpoolPath?.forEach(
(carpoolPathItem: CarpoolPathItem, index: number) => {
carpoolPathItem.actors.forEach((actor: Actor) => {
if (actor.role == Role.PASSENGER && actor.target == Target.START)
passengerStartStepIndex = index;
});
},
);
return this.props.steps![passengerStartStepIndex].duration;
};
/**
* Create the passenger schedule based on the driver schedule
*/
private _createPassengerSchedule = (): void => {
if (this.props.driverSchedule) {
let passengerSchedule: ScheduleItemProps[] =
this.props.driverSchedule.map(
(scheduleItemProps: ScheduleItemProps) => ({
day: scheduleItemProps.day,
time: scheduleItemProps.time,
margin: scheduleItemProps.margin,
}),
);
// adjust the passenger theoretical schedule :
// we guess the ideal passenger departure time based on the duration to
// reach the passenger starting point from the driver starting point
passengerSchedule = passengerSchedule.map(
(scheduleItemProps: ScheduleItemProps) => {
const passengerDate: Date = CalendarTools.firstDate(
scheduleItemProps.day,
this.props.dateInterval,
);
const passengeStartDatetime: Date = CalendarTools.datetimeWithSeconds(
passengerDate,
scheduleItemProps.time,
this._passengerStartDuration(),
);
return {
day: passengerDate.getUTCDay(),
margin: scheduleItemProps.margin,
time: `${passengeStartDatetime
.getUTCHours()
.toString()
.padStart(2, '0')}:${passengeStartDatetime
.getUTCMinutes()
.toString()
.padStart(2, '0')}`,
};
},
);
this.props.passengerSchedule = passengerSchedule.map(
(scheduleItemProps: ScheduleItemProps) => ({
day: scheduleItemProps.day,
time: scheduleItemProps.time,
margin: scheduleItemProps.margin,
}),
);
}
};
private _createJourney = (driverScheduleItem: ScheduleItem): Journey =>
new Journey({
firstDate: CalendarTools.firstDate(
driverScheduleItem.day,
this.props.dateInterval,
),
lastDate: CalendarTools.lastDate(
driverScheduleItem.day,
this.props.dateInterval,
),
journeyItems: this._createJourneyItems(driverScheduleItem),
});
private _createJourneyItems = (
driverScheduleItem: ScheduleItem,
): JourneyItem[] =>
this.props.carpoolPath?.map(
(carpoolPathItem: CarpoolPathItem, index: number) =>
this._createJourneyItem(carpoolPathItem, index, driverScheduleItem),
) as JourneyItem[];
/**
* Create a journey item based on a carpool path item and driver schedule item
* The stepIndex is used to get the duration to reach the carpool path item
* from the steps prop (computed previously by a georouter)
* There MUST be a one/one relation between the carpool path items indexes
* and the steps indexes.
*/
private _createJourneyItem = (
carpoolPathItem: CarpoolPathItem,
stepIndex: number,
driverScheduleItem: ScheduleItem,
): JourneyItem =>
new JourneyItem({
lon: carpoolPathItem.lon,
lat: carpoolPathItem.lat,
duration: ((this.props.steps as Step[])[stepIndex] as Step).duration,
distance: ((this.props.steps as Step[])[stepIndex] as Step).distance,
actorTimes: carpoolPathItem.actors.map((actor: Actor) =>
this._createActorTime(
actor,
driverScheduleItem,
((this.props.steps as Step[])[stepIndex] as Step).duration,
),
),
});
private _createActorTime = (
actor: Actor,
driverScheduleItem: ScheduleItem,
duration: number,
): ActorTime => {
const scheduleItem: ScheduleItem =
actor.role == Role.PASSENGER && actor.target == Target.START
? this._closestPassengerScheduleItem(driverScheduleItem)
: driverScheduleItem;
const effectiveDuration =
(actor.role == Role.PASSENGER && actor.target == Target.START) ||
actor.target == Target.START
? 0
: duration;
const firstDate: Date = CalendarTools.firstDate(
scheduleItem.day,
this.props.dateInterval,
);
const lastDate: Date = CalendarTools.lastDate(
scheduleItem.day,
this.props.dateInterval,
);
return new ActorTime({
role: actor.role,
target: actor.target,
firstDatetime: CalendarTools.datetimeWithSeconds(
firstDate,
scheduleItem.time,
effectiveDuration,
),
firstMinDatetime: CalendarTools.datetimeWithSeconds(
firstDate,
scheduleItem.time,
-scheduleItem.margin + effectiveDuration,
),
firstMaxDatetime: CalendarTools.datetimeWithSeconds(
firstDate,
scheduleItem.time,
scheduleItem.margin + effectiveDuration,
),
lastDatetime: CalendarTools.datetimeWithSeconds(
lastDate,
scheduleItem.time,
effectiveDuration,
),
lastMinDatetime: CalendarTools.datetimeWithSeconds(
lastDate,
scheduleItem.time,
-scheduleItem.margin + effectiveDuration,
),
lastMaxDatetime: CalendarTools.datetimeWithSeconds(
lastDate,
scheduleItem.time,
scheduleItem.margin + effectiveDuration,
),
});
};
/**
* Get the closest (in time) passenger schedule item for a given driver schedule item
* This is mandatory as we can't rely only on the day of the schedule item :
* items on different days can match when playing with margins around midnight
*/
private _closestPassengerScheduleItem = (
driverScheduleItem: ScheduleItem,
): ScheduleItem =>
CalendarTools.epochDaysFromTime(
driverScheduleItem.day,
driverScheduleItem.time,
)
.map((driverDate: Date) =>
this._minPassengerScheduleItemGapForDate(driverDate),
)
.reduce(
(
previousScheduleItemGap: ScheduleItemGap,
currentScheduleItemGap: ScheduleItemGap,
) =>
previousScheduleItemGap.gap < currentScheduleItemGap.gap
? previousScheduleItemGap
: currentScheduleItemGap,
).scheduleItem;
/**
* Find the passenger schedule item with the minimum duration between a given date and the dates of the passenger schedule
*/
private _minPassengerScheduleItemGapForDate = (date: Date): ScheduleItemGap =>
(this.props.passengerSchedule as ScheduleItemProps[])
// first map the passenger schedule to "real" dates (we use unix epoch date as base)
.map(
(scheduleItem: ScheduleItem) =>
<ScheduleItemRange>{
scheduleItem,
range: CalendarTools.epochDaysFromTime(
scheduleItem.day,
scheduleItem.time,
),
},
)
// then compute the duration in seconds to the given date
// for each "real" date computed in step 1
.map((scheduleItemRange: ScheduleItemRange) => ({
scheduleItem: scheduleItemRange.scheduleItem,
gap: scheduleItemRange.range
// compute the duration
.map((scheduleDate: Date) =>
Math.round(Math.abs(scheduleDate.getTime() - date.getTime())),
)
// keep the lowest duration
.reduce((previousGap: number, currentGap: number) =>
previousGap < currentGap ? previousGap : currentGap,
),
}))
// finally, keep the passenger schedule item with the lowest duration
.reduce(
(
previousScheduleItemGap: ScheduleItemGap,
currentScheduleItemGap: ScheduleItemGap,
) =>
previousScheduleItemGap.gap < currentScheduleItemGap.gap
? previousScheduleItemGap
: currentScheduleItemGap,
);
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
if (!this.props.driverSchedule && !this.props.passengerSchedule)
throw new ArgumentInvalidException(
'at least the driver or the passenger schedule is required',
);
}
}
type ScheduleItemRange = {
scheduleItem: ScheduleItem;
range: Date[];
};
type ScheduleItemGap = {
scheduleItem: ScheduleItem;
gap: number;
};