diff --git a/src/modules/ad/core/domain/candidate.entity.ts b/src/modules/ad/core/domain/candidate.entity.ts index 434fe05..95abf70 100644 --- a/src/modules/ad/core/domain/candidate.entity.ts +++ b/src/modules/ad/core/domain/candidate.entity.ts @@ -1,4 +1,8 @@ -import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library'; +import { + AggregateRoot, + AggregateID, + ArgumentInvalidException, +} from '@mobicoop/ddd-library'; import { CandidateProps, CreateCandidateProps, @@ -9,7 +13,10 @@ import { CarpoolPathItemProps, } from './value-objects/carpool-path-item.value-object'; import { Step, StepProps } from './value-objects/step.value-object'; -import { ScheduleItem } from './value-objects/schedule-item.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'; @@ -53,8 +60,11 @@ export class CandidateEntity extends AggregateRoot { * 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 + this.props.journeys = (this.props.driverSchedule as ScheduleItemProps[]) // first we create the journeys .map((driverScheduleItem: ScheduleItem) => this._createJourney(driverScheduleItem), @@ -82,6 +92,122 @@ export class CandidateEntity extends AggregateRoot { (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 as Step[])[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( @@ -216,7 +342,7 @@ export class CandidateEntity extends AggregateRoot { * 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 + (this.props.passengerSchedule as ScheduleItemProps[]) // first map the passenger schedule to "real" dates (we use unix epoch date as base) .map( (scheduleItem: ScheduleItem) => @@ -255,6 +381,10 @@ export class CandidateEntity extends AggregateRoot { 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', + ); } } diff --git a/src/modules/ad/core/domain/candidate.types.ts b/src/modules/ad/core/domain/candidate.types.ts index a7d82cf..388ea27 100644 --- a/src/modules/ad/core/domain/candidate.types.ts +++ b/src/modules/ad/core/domain/candidate.types.ts @@ -11,8 +11,8 @@ export interface CandidateProps { frequency: Frequency; driverWaypoints: PointProps[]; passengerWaypoints: PointProps[]; - driverSchedule: ScheduleItemProps[]; - passengerSchedule: ScheduleItemProps[]; + driverSchedule?: ScheduleItemProps[]; + passengerSchedule?: ScheduleItemProps[]; driverDistance: number; driverDuration: number; dateInterval: DateInterval; @@ -33,8 +33,8 @@ export interface CreateCandidateProps { driverDuration: number; driverWaypoints: PointProps[]; passengerWaypoints: PointProps[]; - driverSchedule: ScheduleItemProps[]; - passengerSchedule: ScheduleItemProps[]; + driverSchedule?: ScheduleItemProps[]; + passengerSchedule?: ScheduleItemProps[]; spacetimeDetourRatio: SpacetimeDetourRatio; dateInterval: DateInterval; } diff --git a/src/modules/ad/core/domain/value-objects/journey.value-object.ts b/src/modules/ad/core/domain/value-objects/journey.value-object.ts index 836aed9..90ca3ff 100644 --- a/src/modules/ad/core/domain/value-objects/journey.value-object.ts +++ b/src/modules/ad/core/domain/value-objects/journey.value-object.ts @@ -46,6 +46,23 @@ export class Journey extends ValueObject { const driverActorTime = passengerDepartureJourneyItem.actorTimes.find( (actorTime: ActorTime) => actorTime.role == Role.DRIVER, ) as ActorTime; + // return ( + // // 1 + // (driverActorTime.firstMinDatetime >= + // passengerDepartureActorTime.firstMinDatetime && + // driverActorTime.firstMaxDatetime <= + // passengerDepartureActorTime.firstMaxDatetime) || + // // 2 & 4 + // (driverActorTime.firstMinDatetime <= + // passengerDepartureActorTime.firstMinDatetime && + // driverActorTime.firstMaxDatetime >= + // passengerDepartureActorTime.firstMinDatetime) || + // // 3 + // (driverActorTime.firstMinDatetime >= + // passengerDepartureActorTime.firstMinDatetime && + // driverActorTime.firstMinDatetime <= + // passengerDepartureActorTime.firstMaxDatetime) + // ); return ( (passengerDepartureActorTime.firstMinDatetime <= driverActorTime.firstMaxDatetime && diff --git a/src/modules/ad/core/domain/value-objects/match-query.value-object.ts b/src/modules/ad/core/domain/value-objects/match-query.value-object.ts index 3b1e42b..23994d4 100644 --- a/src/modules/ad/core/domain/value-objects/match-query.value-object.ts +++ b/src/modules/ad/core/domain/value-objects/match-query.value-object.ts @@ -14,7 +14,7 @@ export interface MatchQueryProps { frequency: Frequency; fromDate: string; toDate: string; - schedule: ScheduleItemProps[]; + schedule?: ScheduleItemProps[]; seatsProposed: number; seatsRequested: number; strict: boolean; @@ -50,7 +50,7 @@ export class MatchQuery extends ValueObject { return this.props.toDate; } - get schedule(): ScheduleItemProps[] { + get schedule(): ScheduleItemProps[] | undefined { return this.props.schedule; } diff --git a/src/modules/ad/tests/unit/core/candidate.entity.spec.ts b/src/modules/ad/tests/unit/core/candidate.entity.spec.ts index 10bb27b..4583f9e 100644 --- a/src/modules/ad/tests/unit/core/candidate.entity.spec.ts +++ b/src/modules/ad/tests/unit/core/candidate.entity.spec.ts @@ -1,3 +1,4 @@ +import { ArgumentInvalidException } from '@mobicoop/ddd-library'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { @@ -6,7 +7,10 @@ import { } from '@modules/ad/core/domain/candidate.types'; import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; import { CarpoolPathItemProps } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object'; -import { Journey } from '@modules/ad/core/domain/value-objects/journey.value-object'; +import { + Journey, + JourneyProps, +} from '@modules/ad/core/domain/value-objects/journey.value-object'; import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object'; import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object'; import { StepProps } from '@modules/ad/core/domain/value-objects/step.value-object'; @@ -374,6 +378,95 @@ describe('Candidate entity', () => { .createJourneys(); expect(candidateEntity.getProps().journeys).toHaveLength(1); }); + it('should create journeys for a single date without driver schedule', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: undefined, + passengerSchedule: schedule2, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps) + .createJourneys(); + expect(candidateEntity.getProps().journeys).toHaveLength(1); + // computed driver start time should be 06:49 + expect( + ( + candidateEntity.getProps().journeys as JourneyProps[] + )[0].journeyItems[0].actorTimes[0].firstDatetime.getUTCMinutes(), + ).toBe(49); + expect( + ( + candidateEntity.getProps().journeys as JourneyProps[] + )[0].journeyItems[0].actorTimes[0].firstDatetime.getUTCHours(), + ).toBe(6); + }); + it('should create journeys for a single date without passenger schedule', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: undefined, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps) + .createJourneys(); + expect(candidateEntity.getProps().journeys).toHaveLength(1); + // computed passenger start time should be 07:20 + expect( + ( + candidateEntity.getProps().journeys as JourneyProps[] + )[0].journeyItems[1].actorTimes[0].firstDatetime.getUTCMinutes(), + ).toBe(20); + expect( + ( + candidateEntity.getProps().journeys as JourneyProps[] + )[0].journeyItems[1].actorTimes[0].firstDatetime.getUTCHours(), + ).toBe(7); + }); + it('should throw without driver and passenger schedule', () => { + expect(() => + CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + frequency: Frequency.PUNCTUAL, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: undefined, + passengerSchedule: undefined, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps) + .createJourneys(), + ).toThrow(ArgumentInvalidException); + }); it('should create journeys for multiple dates', () => { const candidateEntity: CandidateEntity = CandidateEntity.create({ id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', diff --git a/src/modules/ad/tests/unit/core/match-query.value-object.spec.ts b/src/modules/ad/tests/unit/core/match-query.value-object.spec.ts index c774714..a8defc0 100644 --- a/src/modules/ad/tests/unit/core/match-query.value-object.spec.ts +++ b/src/modules/ad/tests/unit/core/match-query.value-object.spec.ts @@ -44,7 +44,7 @@ describe('Match Query value object', () => { expect(matchQueryVO.frequency).toBe(Frequency.PUNCTUAL); expect(matchQueryVO.fromDate).toBe('2023-09-01'); expect(matchQueryVO.toDate).toBe('2023-09-01'); - expect(matchQueryVO.schedule.length).toBe(1); + expect(matchQueryVO.schedule?.length).toBe(1); expect(matchQueryVO.seatsProposed).toBe(3); expect(matchQueryVO.seatsRequested).toBe(1); expect(matchQueryVO.strict).toBe(false); diff --git a/src/modules/ad/tests/unit/core/match.query.spec.ts b/src/modules/ad/tests/unit/core/match.query.spec.ts index f88aad0..4379f60 100644 --- a/src/modules/ad/tests/unit/core/match.query.spec.ts +++ b/src/modules/ad/tests/unit/core/match.query.spec.ts @@ -1,6 +1,9 @@ import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port'; -import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; +import { + MatchQuery, + ScheduleItem, +} from '@modules/ad/core/application/queries/match/match.query'; import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; import { Frequency } from '@modules/ad/core/domain/ad.types'; @@ -135,9 +138,9 @@ describe('Match Query', () => { expect(matchQuery.maxDetourDurationRatio).toBe(0.3); expect(matchQuery.fromDate).toBe('2023-08-27'); expect(matchQuery.toDate).toBe('2023-08-27'); - expect(matchQuery.schedule[0].day).toBe(0); - expect(matchQuery.schedule[0].time).toBe('23:05'); - expect(matchQuery.schedule[0].margin).toBe(900); + expect((matchQuery.schedule as ScheduleItem[])[0].day).toBe(0); + expect((matchQuery.schedule as ScheduleItem[])[0].time).toBe('23:05'); + expect((matchQuery.schedule as ScheduleItem[])[0].margin).toBe(900); }); it('should set good values for seats', async () => {