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 { 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) => { 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; };