Merge branch 'matcherModule' into 'main'

Matcher module

See merge request v3/service/matcher!11
This commit is contained in:
Sylvain Briat 2023-09-28 14:40:10 +00:00
commit fb34757463
168 changed files with 11095 additions and 1347 deletions

View File

@ -15,32 +15,25 @@ MESSAGE_BROKER_EXCHANGE=mobicoop
REDIS_HOST=v3-redis
REDIS_PASSWORD=redis
REDIS_PORT=6379
REDIS_MATCHING_KEY=MATCHER:MATCHING
REDIS_MATCHING_TTL=900
# CACHE
CACHE_TTL=5000
# DEFAULT CONFIGURATION
# default identifier used for match requests
DEFAULT_UUID=00000000-0000-0000-0000-000000000000
# default number of seats proposed as driver
DEFAULT_SEATS=3
# algorithm type
ALGORITHM=CLASSIC
# strict algorithm (if relevant with the algorithm type)
# if set to true, matches are made so that
# punctual ads match only with punctual ads and
# recurrent ads match only with recurrent ads
STRICT_ALGORITHM=0
ALGORITHM=PASSENGER_ORIENTED
# max distance in metres between driver
# route and passenger pick-up / drop-off
REMOTENESS=15000
# use passenger proportion
USE_PROPORTION=1
USE_PROPORTION=true
# minimal driver proportion
PROPORTION=0.3
# use azimuth calculation
USE_AZIMUTH=1
USE_AZIMUTH=true
# azimuth margin
AZIMUTH_MARGIN=10
# margin duration in seconds
@ -54,3 +47,16 @@ MAX_DETOUR_DURATION_RATIO=0.3
GEOROUTER_TYPE=graphhopper
# georouter url
GEOROUTER_URL=http://localhost:8989
# default carpool departure time margin (in seconds)
DEPARTURE_TIME_MARGIN=900
# default role
ROLE=passenger
# seats proposes as driver / requested as passenger
SEATS_PROPOSED=3
SEATS_REQUESTED=1
# accept only same frequency requests
STRICT_FREQUENCY=false
# default timezone
TIMEZONE=Europe/Paris
# number of matching results per page
PER_PAGE=10

229
README.md
View File

@ -9,3 +9,232 @@ You need [Docker](https://docs.docker.com/engine/) and its [compose](https://doc
You also need NodeJS installed locally : we **strongly** advise to install [Node Version Manager](https://github.com/nvm-sh/nvm) and use the latest LTS version of Node (check that your local version matches with the one used in the Dockerfile).
The API will run inside a docker container, **but** the install itself is made outside the container, because during development we need tools that need to be available locally (eg. ESLint or Prettier with fix-on-save).
A RabbitMQ instance is also required to send / receive messages when data has been inserted/updated/deleted.
# Installation
- copy `.env.dist` to `.env` :
```bash
cp .env.dist .env
```
Modify it if needed.
- install the dependencies :
```bash
npm install
```
- start the containers :
```bash
docker compose up -d
```
The app runs automatically on port **5005**.
## Database migration
Before using the app, you need to launch the database migration (it will be launched inside the container) :
```bash
npm run migrate
```
## Usage
The app exposes the following [gRPC](https://grpc.io/) services :
- **Match** : find matching ads corresponding to the given criteria
For example, as a passenger, to search for drivers for a punctual carpool :
```json
{
"driver": false,
"passenger": true,
"frequency": "PUNCTUAL",
"algorithmType": "PASSENGER_ORIENTED",
"fromDate": "2024-06-05",
"toDate": "2024-06-05",
"schedule": [
{
"time": "07:30"
}
],
"waypoints": [
{
"houseNumber": "23",
"street": "rue de viller",
"postalCode": "54300",
"locality": "Lunéville",
"lon": 6.490527,
"lat": 48.590119,
"country": "France",
"position": 0
},
{
"houseNumber": "3",
"street": "rue du passage",
"postalCode": "67117",
"locality": "Ittenheim",
"lon": 7.594361,
"lat": 48.603004,
"country": "France",
"position": 1
}
]
}
```
As a passenger, to search for drivers for a recurrent carpool :
```json
{
"driver": false,
"passenger": true,
"frequency": "RECURRENT",
"algorithmType": "PASSENGER_ORIENTED",
"fromDate": "2024-01-02",
"toDate": "2024-06-30",
"strict": true,
"page": 1,
"perPage": 5,
"schedule": [
{
"day": 1,
"time": "07:30"
},
{
"day": 2,
"time": "07:45"
},
{
"day": 4,
"time": "07:30"
},
,
{
"day": 5,
"time": "07:30"
}
],
"waypoints": [
{
"houseNumber": "298",
"street": "Aveue de la liberté",
"postalCode": "86180",
"locality": "Buxerolles",
"lon": 0.364394,
"lat": 46.607501,
"country": "France",
"position": 0
},
{
"houseNumber": "1",
"street": "place du 8 mai 1945",
"postalCode": "47310",
"locality": "Roquefort",
"lon": 0.559606,
"lat": 44.175994,
"country": "France",
"position": 1
}
]
}
```
The list of possible criteria :
- **id** (optional): the id of a previous matching result (as a uuid)
- **driver** (boolean, optional): to search for passengers (_default : false_)
- **passenger** (boolean, optional): to search fo drivers (_default : true_)
- **frequency**: the frequency of the search (`PUNCTUAL` or `RECURRENT`)
- **strict** (boolean, optional): if set to true, allow matching only with similar frequency ads (_default : false_)
- **fromDate**: start date for recurrent ad, carpool date for punctual ad
- **toDate**: end date for recurrent ad, same as fromDate for punctual ad
- **schedule**: an array of schedule items, a schedule item containing :
- the week day as a number, from 0 (sunday) to 6 (saturday) if the ad is recurrent (default to fromDate day for punctual search)
- the departure time (as HH:MM)
- the margin around the departure time in seconds (optional) (_default : 900_)
- **seatsProposed** (integer, optional): number of seats proposed as driver (_default : 3_)
- **seatsRequested** (integer, optional): number of seats requested as passenger (_default : 1_)
- **waypoints**: an array of addresses that represent the waypoints of the journey (only first and last waypoints are used for passenger ads). Note that positions are **required** and **must** be consecutives
- **algorithmType** (optional): the type of algorithm to use (as of 2023-09-28, only the default `PASSENGER_ORIENTED` is accepted)
- **remoteness** (integer, optional): an integer to indicate the maximum flying distance (in metres) between the driver route and the passenger pick-up / drop-off points (_default : 15000_)
- **useProportion** (boolean, optional): a boolean to indicate if the matching algorithm will compare the distance of the passenger route against the distance of the driver route (_default : 1_). Works in combination with **proportion** parameter
- **proportion** (float, optional): a fraction (float between 0 and 1) to indicate minimum proportion of the distance of the passenger route against the distance of the driver route (_default : 0.3_). Works in combination with **use_proportion** parameter
- **useAzimuth** (boolean, optional): a boolean to indicate if the matching algorithm will use the azimuth of the driver and passenger routes (_default : 1_)
- **azimuthMargin** (integer, optional): an integer (representing the number of degrees) to indicate the range around the opposite azimuth to consider the candidate route excluded (_default : 10_)
- **maxDetourDistanceRatio** (float, optional): a fraction (float between 0 and 1) of the driver route distance to indicate the maximum detour distance acceptable for a passenger (_default : 0.3_)
- **maxDetourDurationRatio** (float, optional): a fraction (float between 0 and 1) of the driver route duration to indicate the maximum detour duration acceptable for a passenger (_default : 0.3_)
- **page** (integer, optional): the page of results to display (_default : 1_)
- **perPage** (integer, optional): the number of results to display per page (_default : 10_)
If the matching is successful, you will get a result, containing :
- **id**: the id of the matching; as matching is a time-consuming process, results are cached and thus accessible later using this id (pagination works as well !)
- **total**: the total number of results
- **page**: the number of the page that is returned (may be different than the number of the required page, if number of results does not match with perPage parameter)
- **perPage**: the number of results per page (as it may not be specified in the request)
- **data**: an array of the results themselves, each including:
- **id**: an id for the result
- **adId**: the id of the ad that matches
- **role**: the role of the ad owner in that match
- **distance**: the distance in metres of the resulting carpool
- **duration**: the duration in seconds of the resulting carpool
- **initialDistance**: the initial distance in metres for the driver
- **initialDuration**: the initial duration in seconds for the driver
- **distanceDetour**: the detour distance in metres
- **durationDetour**: the detour duration in seconds
- **distanceDetourPercentage**: the detour distance in percentage of the original distance
- **durationDetourPercentage**: the detour duration in percentage of the original duration
- **journeys**: the possible journeys for the carpool (one journey for punctual carpools, one or more journeys for recurrent carpools), each including:
- **day**: the week day for the journey, as a number, from 0 (sunday) to 6 (saturday)
- **firstDate**: the first possible date for the journey
- **lastDate**: the last possible date for the journey
- **steps**: the steps of the journey (coordinates with distance, duration and actors implied), each including:
- **distance**: the distance to reach the step in metres
- **duration**: the duration to reach the step in seconds
- **lon**: the longitude of the point for the step
- **lat**: the longitude of the point for the step
- **time**: the driver time at that step
- **actors**: the actors for that step:
- **role**: the role of the actor (`DRIVER` or `PASSENGER`)
- **target**: the meaning of the step for the actor:
- _START_ for the first point of the actor
- _FINISH_ for the last point of the actor
- _INTERMEDIATE_ for a driver intermediate point
- _NEUTRAL_ for a passenger point from the point of view of a driver
## Tests / ESLint / Prettier
Tests are run outside the container for ease of use (switching between different environments inside containers using prisma is complicated and error prone).
The integration tests use a dedicated database (see _db-test_ section of _docker-compose.yml_).
```bash
# run all tests (unit + integration)
npm run test
# unit tests only
npm run test:unit
# integration tests only
npm run test:integration
# coverage
npm run test:cov
# ESLint
npm run lint
# Prettier
npm run pretty
```
## License
Mobicoop V3 - Matcher Service is [AGPL licensed](LICENSE).

12
package-lock.json generated
View File

@ -1,19 +1,19 @@
{
"name": "@mobicoop/matcher",
"version": "0.0.2",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@mobicoop/matcher",
"version": "0.0.2",
"version": "1.0.0",
"license": "AGPL",
"dependencies": {
"@grpc/grpc-js": "^1.8.14",
"@grpc/proto-loader": "^0.7.6",
"@liaoliaots/nestjs-redis": "^9.0.5",
"@mobicoop/configuration-module": "^1.2.0",
"@mobicoop/ddd-library": "^1.1.0",
"@mobicoop/ddd-library": "^1.5.0",
"@mobicoop/health-module": "^2.0.0",
"@mobicoop/message-broker-module": "^1.2.0",
"@nestjs/axios": "^2.0.0",
@ -1505,9 +1505,9 @@
}
},
"node_modules/@mobicoop/ddd-library": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-1.1.0.tgz",
"integrity": "sha512-x4X7j2CJYZQPDZgLuZP5TFk59fle1wTPdX++Z2YyD7VwwV+yOmVvMIRfTyLRFUTzLObrd6FKs8mh+g59n9jUlA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-1.5.0.tgz",
"integrity": "sha512-CX/V2+vSXrGtKobsyBfVpMW323ZT8tHrgUl1qrvU1XjRKNShvwsKyC7739x7CNgkJ9sr3XV+75JrOXEnqU83zw==",
"dependencies": {
"@nestjs/event-emitter": "^1.4.2",
"@nestjs/microservices": "^9.4.0",

View File

@ -1,6 +1,6 @@
{
"name": "@mobicoop/matcher",
"version": "0.0.2",
"version": "1.0.0",
"description": "Mobicoop V3 Matcher",
"author": "sbriat",
"private": true,
@ -34,7 +34,7 @@
"@grpc/proto-loader": "^0.7.6",
"@liaoliaots/nestjs-redis": "^9.0.5",
"@mobicoop/configuration-module": "^1.2.0",
"@mobicoop/ddd-library": "^1.1.0",
"@mobicoop/ddd-library": "^1.5.0",
"@mobicoop/health-module": "^2.0.0",
"@mobicoop/message-broker-module": "^1.2.0",
"@nestjs/axios": "^2.0.0",

View File

@ -26,15 +26,19 @@ import { GeographyModule } from '@modules/geography/geography.module';
useFactory: async (
configService: ConfigService,
): Promise<ConfigurationModuleOptions> => ({
domain: configService.get<string>('SERVICE_CONFIGURATION_DOMAIN'),
domain: configService.get<string>(
'SERVICE_CONFIGURATION_DOMAIN',
) as string,
messageBroker: {
uri: configService.get<string>('MESSAGE_BROKER_URI'),
exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE'),
uri: configService.get<string>('MESSAGE_BROKER_URI') as string,
exchange: configService.get<string>(
'MESSAGE_BROKER_EXCHANGE',
) as string,
},
redis: {
host: configService.get<string>('REDIS_HOST'),
host: configService.get<string>('REDIS_HOST') as string,
password: configService.get<string>('REDIS_PASSWORD'),
port: configService.get<number>('REDIS_PORT'),
port: configService.get<number>('REDIS_PORT') as number,
},
setConfigurationBrokerQueue: 'matcher-configuration-create-update',
deleteConfigurationQueue: 'matcher-configuration-delete',

View File

@ -11,14 +11,17 @@ async function bootstrap() {
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.GRPC,
options: {
package: ['health'],
protoPath: [join(__dirname, 'health.proto')],
package: ['matcher', 'health'],
protoPath: [
join(__dirname, 'modules/ad/interface/grpc-controllers/matcher.proto'),
join(__dirname, 'health.proto'),
],
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
loader: { keepCase: true },
loader: { keepCase: true, enums: String },
},
});
await app.startAllMicroservices();
await app.listen(process.env.HEALTH_SERVICE_PORT);
await app.listen(process.env.HEALTH_SERVICE_PORT as string);
}
bootstrap();

View File

@ -1,7 +1,18 @@
export const AD_REPOSITORY = Symbol('AD_REPOSITORY');
export const MATCHING_REPOSITORY = Symbol('MATCHING_REPOSITORY');
export const AD_DIRECTION_ENCODER = Symbol('AD_DIRECTION_ENCODER');
export const AD_MESSAGE_PUBLISHER = Symbol('AD_MESSAGE_PUBLISHER');
export const AD_GET_BASIC_ROUTE_CONTROLLER = Symbol(
'AD_GET_BASIC_ROUTE_CONTROLLER',
);
export const AD_GET_DETAILED_ROUTE_CONTROLLER = Symbol(
'AD_GET_DETAILED_ROUTE_CONTROLLER',
);
export const AD_ROUTE_PROVIDER = Symbol('AD_ROUTE_PROVIDER');
export const PARAMS_PROVIDER = Symbol('PARAMS_PROVIDER');
export const TIMEZONE_FINDER = Symbol('TIMEZONE_FINDER');
export const TIME_CONVERTER = Symbol('TIME_CONVERTER');
export const INPUT_DATETIME_TRANSFORMER = Symbol('INPUT_DATETIME_TRANSFORMER');
export const OUTPUT_DATETIME_TRANSFORMER = Symbol(
'OUTPUT_DATETIME_TRANSFORMER',
);

View File

@ -4,11 +4,13 @@ import {
AdWriteModel,
AdReadModel,
ScheduleItemModel,
AdUnsupportedWriteModel,
AdWriteExtraModel,
} from './infrastructure/ad.repository';
import { Frequency } from './core/domain/ad.types';
import { v4 } from 'uuid';
import { ScheduleItemProps } from './core/domain/value-objects/schedule-item.value-object';
import {
ScheduleItem,
ScheduleItemProps,
} from './core/domain/value-objects/schedule-item.value-object';
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
import { AD_DIRECTION_ENCODER } from './ad.di-tokens';
import { ExtendedMapper } from '@mobicoop/ddd-library';
@ -27,7 +29,7 @@ export class AdMapper
AdEntity,
AdReadModel,
AdWriteModel,
AdUnsupportedWriteModel,
AdWriteExtraModel,
undefined
>
{
@ -77,18 +79,27 @@ export class AdMapper
return record;
};
toDomain = (record: AdReadModel): AdEntity => {
const entity = new AdEntity({
toDomain = (record: AdReadModel): AdEntity =>
new AdEntity({
id: record.uuid,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
props: {
seatsProposed: record.seatsProposed,
seatsRequested: record.seatsRequested,
strict: record.strict,
driverDuration: record.driverDuration,
driverDistance: record.driverDistance,
passengerDuration: record.passengerDuration,
passengerDistance: record.passengerDistance,
driver: record.driver,
passenger: record.passenger,
frequency: Frequency[record.frequency],
frequency: record.frequency,
fromDate: record.fromDate.toISOString().split('T')[0],
toDate: record.toDate.toISOString().split('T')[0],
schedule: record.schedule.map((scheduleItem: ScheduleItemModel) => ({
schedule: record.schedule.map(
(scheduleItem: ScheduleItemModel) =>
new ScheduleItem({
day: scheduleItem.day,
time: `${scheduleItem.time
.getUTCHours()
@ -98,14 +109,8 @@ export class AdMapper
.toString()
.padStart(2, '0')}`,
margin: scheduleItem.margin,
})),
seatsProposed: record.seatsProposed,
seatsRequested: record.seatsRequested,
strict: record.strict,
driverDuration: record.driverDuration,
driverDistance: record.driverDistance,
passengerDuration: record.passengerDuration,
passengerDistance: record.passengerDistance,
}),
),
waypoints: this.directionEncoder
.decode(record.waypoints)
.map((coordinates, index) => ({
@ -117,15 +122,8 @@ export class AdMapper
points: [],
},
});
return entity;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
toResponse = (entity: AdEntity): undefined => {
return undefined;
};
toUnsupportedPersistence = (entity: AdEntity): AdUnsupportedWriteModel => ({
toPersistenceExtra = (entity: AdEntity): AdWriteExtraModel => ({
waypoints: this.directionEncoder.encode(entity.getProps().waypoints),
direction: this.directionEncoder.encode(entity.getProps().points),
});

View File

@ -6,6 +6,13 @@ import {
AD_DIRECTION_ENCODER,
AD_ROUTE_PROVIDER,
AD_GET_BASIC_ROUTE_CONTROLLER,
PARAMS_PROVIDER,
TIMEZONE_FINDER,
TIME_CONVERTER,
INPUT_DATETIME_TRANSFORMER,
AD_GET_DETAILED_ROUTE_CONTROLLER,
OUTPUT_DATETIME_TRANSFORMER,
MATCHING_REPOSITORY,
} from './ad.di-tokens';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { AdRepository } from './infrastructure/ad.repository';
@ -17,18 +24,37 @@ import { GetBasicRouteController } from '@modules/geography/interface/controller
import { RouteProvider } from './infrastructure/route-provider';
import { GeographyModule } from '@modules/geography/geography.module';
import { CreateAdService } from './core/application/commands/create-ad/create-ad.service';
import { MatchGrpcController } from './interface/grpc-controllers/match.grpc-controller';
import { MatchQueryHandler } from './core/application/queries/match/match.query-handler';
import { DefaultParamsProvider } from './infrastructure/default-params-provider';
import { TimezoneFinder } from './infrastructure/timezone-finder';
import { TimeConverter } from './infrastructure/time-converter';
import { InputDateTimeTransformer } from './infrastructure/input-datetime-transformer';
import { GetDetailedRouteController } from '@modules/geography/interface/controllers/get-detailed-route.controller';
import { MatchMapper } from './match.mapper';
import { OutputDateTimeTransformer } from './infrastructure/output-datetime-transformer';
import { MatchingRepository } from './infrastructure/matching.repository';
import { MatchingMapper } from './matching.mapper';
const grpcControllers = [MatchGrpcController];
const messageHandlers = [AdCreatedMessageHandler];
const commandHandlers: Provider[] = [CreateAdService];
const mappers: Provider[] = [AdMapper];
const queryHandlers: Provider[] = [MatchQueryHandler];
const mappers: Provider[] = [AdMapper, MatchMapper, MatchingMapper];
const repositories: Provider[] = [
{
provide: AD_REPOSITORY,
useClass: AdRepository,
},
{
provide: MATCHING_REPOSITORY,
useClass: MatchingRepository,
},
];
const messagePublishers: Provider[] = [
@ -53,19 +79,51 @@ const adapters: Provider[] = [
provide: AD_GET_BASIC_ROUTE_CONTROLLER,
useClass: GetBasicRouteController,
},
{
provide: AD_GET_DETAILED_ROUTE_CONTROLLER,
useClass: GetDetailedRouteController,
},
{
provide: PARAMS_PROVIDER,
useClass: DefaultParamsProvider,
},
{
provide: TIMEZONE_FINDER,
useClass: TimezoneFinder,
},
{
provide: TIME_CONVERTER,
useClass: TimeConverter,
},
{
provide: INPUT_DATETIME_TRANSFORMER,
useClass: InputDateTimeTransformer,
},
{
provide: OUTPUT_DATETIME_TRANSFORMER,
useClass: OutputDateTimeTransformer,
},
];
@Module({
imports: [CqrsModule, GeographyModule],
controllers: [...grpcControllers],
providers: [
...messageHandlers,
...commandHandlers,
...queryHandlers,
...mappers,
...repositories,
...messagePublishers,
...orms,
...adapters,
],
exports: [PrismaService, AdMapper, AD_REPOSITORY, AD_DIRECTION_ENCODER],
exports: [
PrismaService,
AdMapper,
AD_REPOSITORY,
AD_DIRECTION_ENCODER,
AD_MESSAGE_PUBLISHER,
],
})
export class AdModule {}

View File

@ -1,7 +1,7 @@
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { Command, CommandProps } from '@mobicoop/ddd-library';
import { ScheduleItem } from '../../types/schedule-item.type';
import { Waypoint } from '../../types/waypoint.type';
import { Address } from '../../types/address.type';
export class CreateAdCommand extends Command {
readonly id: string;
@ -14,11 +14,11 @@ export class CreateAdCommand extends Command {
readonly seatsProposed: number;
readonly seatsRequested: number;
readonly strict: boolean;
readonly waypoints: Waypoint[];
readonly waypoints: Address[];
constructor(props: CommandProps<CreateAdCommand>) {
super(props);
this.id = props.id;
this.id = props.id as string;
this.driver = props.driver;
this.passenger = props.passenger;
this.frequency = props.frequency;

View File

@ -8,7 +8,15 @@ import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { RouteProviderPort } from '../../ports/route-provider.port';
import { Role } from '@modules/ad/core/domain/ad.types';
import { Route } from '../../types/route.type';
import {
Path,
PathCreator,
PathType,
TypedRoute,
} from '@modules/ad/core/domain/path-creator.service';
import { Waypoint } from '../../types/waypoint.type';
import { Point as PointValueObject } from '@modules/ad/core/domain/value-objects/point.value-object';
import { Point } from '@modules/geography/core/domain/route.types';
@CommandHandler(CreateAdCommand)
export class CreateAdService implements ICommandHandler {
@ -23,10 +31,70 @@ export class CreateAdService implements ICommandHandler {
const roles: Role[] = [];
if (command.driver) roles.push(Role.DRIVER);
if (command.passenger) roles.push(Role.PASSENGER);
const route: Route = await this.routeProvider.getBasic(
const pathCreator: PathCreator = new PathCreator(
roles,
command.waypoints,
command.waypoints.map(
(waypoint: Waypoint) =>
new PointValueObject({
lon: waypoint.lon,
lat: waypoint.lat,
}),
),
);
let typedRoutes: TypedRoute[];
try {
typedRoutes = await Promise.all(
pathCreator.getBasePaths().map(async (path: Path) => ({
type: path.type,
route: await this.routeProvider.getBasic(path.waypoints),
})),
);
} catch (e: any) {
throw new Error('Unable to find a route for given waypoints');
}
let driverDistance: number | undefined;
let driverDuration: number | undefined;
let passengerDistance: number | undefined;
let passengerDuration: number | undefined;
let points: PointValueObject[] | undefined;
let fwdAzimuth: number | undefined;
let backAzimuth: number | undefined;
try {
typedRoutes.forEach((typedRoute: TypedRoute) => {
if ([PathType.DRIVER, PathType.GENERIC].includes(typedRoute.type)) {
driverDistance = typedRoute.route.distance;
driverDuration = typedRoute.route.duration;
points = typedRoute.route.points.map(
(point: Point) =>
new PointValueObject({
lon: point.lon,
lat: point.lat,
}),
);
fwdAzimuth = typedRoute.route.fwdAzimuth;
backAzimuth = typedRoute.route.backAzimuth;
}
if ([PathType.PASSENGER, PathType.GENERIC].includes(typedRoute.type)) {
passengerDistance = typedRoute.route.distance;
passengerDuration = typedRoute.route.duration;
if (!points)
points = typedRoute.route.points.map(
(point: Point) =>
new PointValueObject({
lon: point.lon,
lat: point.lat,
}),
);
if (!fwdAzimuth) fwdAzimuth = typedRoute.route.fwdAzimuth;
if (!backAzimuth) backAzimuth = typedRoute.route.backAzimuth;
}
});
} catch (error: any) {
throw new Error('Invalid route');
}
const ad = AdEntity.create({
id: command.id,
driver: command.driver,
@ -39,17 +107,16 @@ export class CreateAdService implements ICommandHandler {
seatsRequested: command.seatsRequested,
strict: command.strict,
waypoints: command.waypoints,
points: route.points,
driverDistance: route.driverDistance,
driverDuration: route.driverDuration,
passengerDistance: route.passengerDistance,
passengerDuration: route.passengerDuration,
fwdAzimuth: route.fwdAzimuth,
backAzimuth: route.backAzimuth,
points: points as PointValueObject[],
driverDistance,
driverDuration,
passengerDistance,
passengerDuration,
fwdAzimuth: fwdAzimuth as number,
backAzimuth: backAzimuth as number,
});
try {
await this.repository.insertWithUnsupportedFields(ad, 'ad');
await this.repository.insertExtra(ad, 'ad');
return ad.id;
} catch (error: any) {
if (error instanceof ConflictException) {

View File

@ -1,4 +1,6 @@
import { ExtendedRepositoryPort } from '@mobicoop/ddd-library';
import { AdEntity } from '../../domain/ad.entity';
export type AdRepositoryPort = ExtendedRepositoryPort<AdEntity>;
export type AdRepositoryPort = ExtendedRepositoryPort<AdEntity> & {
getCandidateAds(queryString: string): Promise<AdEntity[]>;
};

View File

@ -0,0 +1,26 @@
export interface DateTimeTransformerPort {
fromDate(geoFromDate: GeoDateTime, frequency: Frequency): string;
toDate(
toDate: string,
geoFromDate: GeoDateTime,
frequency: Frequency,
): string;
day(day: number, geoFromDate: GeoDateTime, frequency: Frequency): number;
time(geoFromDate: GeoDateTime, frequency: Frequency): string;
}
export type GeoDateTime = {
date: string;
time: string;
coordinates: Coordinates;
};
export type Coordinates = {
lon: number;
lat: number;
};
export enum Frequency {
PUNCTUAL = 'PUNCTUAL',
RECURRENT = 'RECURRENT',
}

View File

@ -0,0 +1,5 @@
import { DefaultParams } from './default-params.type';
export interface DefaultParamsProviderPort {
getParams(): DefaultParams;
}

View File

@ -0,0 +1,20 @@
import { AlgorithmType } from '../types/algorithm.types';
export type DefaultParams = {
DRIVER: boolean;
PASSENGER: boolean;
SEATS_PROPOSED: number;
SEATS_REQUESTED: number;
DEPARTURE_TIME_MARGIN: number;
STRICT: boolean;
TIMEZONE: string;
ALGORITHM_TYPE: AlgorithmType;
REMOTENESS: number;
USE_PROPORTION: boolean;
PROPORTION: number;
USE_AZIMUTH: boolean;
AZIMUTH_MARGIN: number;
MAX_DETOUR_DISTANCE_RATIO: number;
MAX_DETOUR_DURATION_RATIO: number;
PER_PAGE: number;
};

View File

@ -0,0 +1,6 @@
import { MatchingEntity } from '../../domain/matching.entity';
export type MatchingRepositoryPort = {
get(id: string): Promise<MatchingEntity>;
save(matching: MatchingEntity): Promise<void>;
};

View File

@ -1,10 +1,19 @@
import { Role } from '../../domain/ad.types';
import { Waypoint } from '../types/waypoint.type';
import { Point } from '../types/point.type';
import { Route } from '../types/route.type';
export interface RouteProviderPort {
/**
* Get a basic route with points and overall duration / distance
* Get a basic route :
* - simple points (coordinates only)
* - overall duration
* - overall distance
*/
getBasic(roles: Role[], waypoints: Waypoint[]): Promise<Route>;
getBasic(waypoints: Point[]): Promise<Route>;
/**
* Get a detailed route :
* - detailed points (coordinates and time / distance to reach the point)
* - overall duration
* - overall distance
*/
getDetailed(waypoints: Point[]): Promise<Route>;
}

View File

@ -0,0 +1,18 @@
export interface TimeConverterPort {
localStringTimeToUtcStringTime(time: string, timezone: string): string;
utcStringTimeToLocalStringTime(time: string, timezone: string): string;
localStringDateTimeToUtcDate(
date: string,
time: string,
timezone: string,
dst?: boolean,
): Date;
utcStringDateTimeToLocalIsoString(
date: string,
time: string,
timezone: string,
dst?: boolean,
): string;
utcUnixEpochDayFromTime(time: string, timezone: string): number;
localUnixEpochDayFromTime(time: string, timezone: string): number;
}

View File

@ -0,0 +1,3 @@
export interface TimezoneFinderPort {
timezones(lon: number, lat: number, defaultTimezone?: string): string[];
}

View File

@ -0,0 +1,59 @@
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { MatchEntity } from '../../../domain/match.entity';
import { MatchQuery } from './match.query';
import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port';
import { Journey } from '@modules/ad/core/domain/value-objects/journey.value-object';
export abstract class Algorithm {
protected candidates: CandidateEntity[];
protected selector: Selector;
protected processors: Processor[];
constructor(
protected readonly query: MatchQuery,
protected readonly repository: AdRepositoryPort,
) {}
/**
* Return Ads that matches the query as Matches
*/
match = async (): Promise<MatchEntity[]> => {
this.candidates = await this.selector.select();
for (const processor of this.processors) {
this.candidates = await processor.execute(this.candidates);
}
// console.log(JSON.stringify(this.candidates, null, 2));
return this.candidates.map((candidate: CandidateEntity) =>
MatchEntity.create({
adId: candidate.id,
role: candidate.getProps().role,
frequency: candidate.getProps().frequency,
distance: candidate.getProps().distance as number,
duration: candidate.getProps().duration as number,
initialDistance: candidate.getProps().driverDistance,
initialDuration: candidate.getProps().driverDuration,
journeys: candidate.getProps().journeys as Journey[],
}),
);
};
}
/**
* A selector queries potential candidates in a repository
*/
export abstract class Selector {
protected readonly query: MatchQuery;
protected readonly repository: AdRepositoryPort;
constructor(query: MatchQuery, repository: AdRepositoryPort) {
this.query = query;
this.repository = repository;
}
abstract select(): Promise<CandidateEntity[]>;
}
/**
* A processor processes candidates information
*/
export abstract class Processor {
constructor(protected readonly query: MatchQuery) {}
abstract execute(candidates: CandidateEntity[]): Promise<CandidateEntity[]>;
}

View File

@ -0,0 +1,9 @@
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Processor } from '../algorithm.abstract';
export abstract class Completer extends Processor {
execute = async (candidates: CandidateEntity[]): Promise<CandidateEntity[]> =>
this.complete(candidates);
abstract complete(candidates: CandidateEntity[]): Promise<CandidateEntity[]>;
}

View File

@ -0,0 +1,9 @@
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Completer } from './completer.abstract';
export class JourneyCompleter extends Completer {
complete = async (
candidates: CandidateEntity[],
): Promise<CandidateEntity[]> =>
candidates.map((candidate: CandidateEntity) => candidate.createJourneys());
}

View File

@ -0,0 +1,55 @@
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Completer } from './completer.abstract';
import { Role } from '@modules/ad/core/domain/ad.types';
import { Waypoint } from '../../../types/waypoint.type';
import { CarpoolPathCreator } from '@modules/ad/core/domain/carpool-path-creator.service';
import {
Point,
PointProps,
} from '@modules/ad/core/domain/value-objects/point.value-object';
/**
* Complete candidates with crew carpool path
*/
export class PassengerOrientedCarpoolPathCompleter extends Completer {
complete = async (
candidates: CandidateEntity[],
): Promise<CandidateEntity[]> => {
candidates.forEach((candidate: CandidateEntity) => {
const carpoolPathCreator = new CarpoolPathCreator(
candidate.getProps().role == Role.DRIVER
? candidate.getProps().driverWaypoints.map(
(waypoint: PointProps) =>
new Point({
lon: waypoint.lon,
lat: waypoint.lat,
}),
)
: this.query.waypoints.map(
(waypoint: Waypoint) =>
new Point({
lon: waypoint.lon,
lat: waypoint.lat,
}),
),
candidate.getProps().role == Role.PASSENGER
? candidate.getProps().driverWaypoints.map(
(waypoint: PointProps) =>
new Point({
lon: waypoint.lon,
lat: waypoint.lat,
}),
)
: this.query.waypoints.map(
(waypoint: Waypoint) =>
new Point({
lon: waypoint.lon,
lat: waypoint.lat,
}),
),
);
candidate.setCarpoolPath(carpoolPathCreator.carpoolPath());
});
return candidates;
};
}

View File

@ -0,0 +1,51 @@
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Completer } from './completer.abstract';
import { MatchQuery } from '../match.query';
import { Step } from '../../../types/step.type';
import { CarpoolPathItem } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object';
export class RouteCompleter extends Completer {
protected readonly type: RouteCompleterType;
constructor(query: MatchQuery, type: RouteCompleterType) {
super(query);
this.type = type;
}
complete = async (
candidates: CandidateEntity[],
): Promise<CandidateEntity[]> => {
await Promise.all(
candidates.map(async (candidate: CandidateEntity) => {
switch (this.type) {
case RouteCompleterType.BASIC:
const basicCandidateRoute = await this.query.routeProvider.getBasic(
(candidate.getProps().carpoolPath as CarpoolPathItem[]).map(
(carpoolPathItem: CarpoolPathItem) => carpoolPathItem,
),
);
candidate.setMetrics(
basicCandidateRoute.distance,
basicCandidateRoute.duration,
);
break;
case RouteCompleterType.DETAILED:
const detailedCandidateRoute =
await this.query.routeProvider.getDetailed(
(candidate.getProps().carpoolPath as CarpoolPathItem[]).map(
(carpoolPathItem: CarpoolPathItem) => carpoolPathItem,
),
);
candidate.setSteps(detailedCandidateRoute.steps as Step[]);
break;
}
return candidate;
}),
);
return candidates;
};
}
export enum RouteCompleterType {
BASIC = 'basic',
DETAILED = 'detailed',
}

View File

@ -0,0 +1,9 @@
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Processor } from '../algorithm.abstract';
export abstract class Filter extends Processor {
execute = async (candidates: CandidateEntity[]): Promise<CandidateEntity[]> =>
this.filter(candidates);
abstract filter(candidates: CandidateEntity[]): Promise<CandidateEntity[]>;
}

View File

@ -0,0 +1,10 @@
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Filter } from './filter.abstract';
/**
* Filter candidates with empty journeys
*/
export class JourneyFilter extends Filter {
filter = async (candidates: CandidateEntity[]): Promise<CandidateEntity[]> =>
candidates.filter((candidate: CandidateEntity) => candidate.hasJourneys());
}

View File

@ -0,0 +1,12 @@
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Filter } from './filter.abstract';
/**
* Filter candidates with unacceptable detour
*/
export class PassengerOrientedGeoFilter extends Filter {
filter = async (candidates: CandidateEntity[]): Promise<CandidateEntity[]> =>
candidates.filter((candidate: CandidateEntity) =>
candidate.isDetourValid(),
);
}

View File

@ -0,0 +1,149 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { MatchQuery, ScheduleItem } from './match.query';
import { Algorithm } from './algorithm.abstract';
import { PassengerOrientedAlgorithm } from './passenger-oriented-algorithm';
import { AlgorithmType } from '../../types/algorithm.types';
import { Inject } from '@nestjs/common';
import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port';
import {
AD_REPOSITORY,
INPUT_DATETIME_TRANSFORMER,
MATCHING_REPOSITORY,
PARAMS_PROVIDER,
} from '@modules/ad/ad.di-tokens';
import { MatchEntity } from '@modules/ad/core/domain/match.entity';
import { DefaultParamsProviderPort } from '../../ports/default-params-provider.port';
import { DefaultParams } from '../../ports/default-params.type';
import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port';
import { Paginator } from '@mobicoop/ddd-library';
import { MatchingEntity } from '@modules/ad/core/domain/matching.entity';
import { MatchingRepositoryPort } from '../../ports/matching.repository.port';
@QueryHandler(MatchQuery)
export class MatchQueryHandler implements IQueryHandler {
private readonly _defaultParams: DefaultParams;
constructor(
@Inject(PARAMS_PROVIDER)
private readonly defaultParamsProvider: DefaultParamsProviderPort,
@Inject(AD_REPOSITORY) private readonly adRepository: AdRepositoryPort,
@Inject(MATCHING_REPOSITORY)
private readonly matchingRepository: MatchingRepositoryPort,
@Inject(INPUT_DATETIME_TRANSFORMER)
private readonly datetimeTransformer: DateTimeTransformerPort,
) {
this._defaultParams = defaultParamsProvider.getParams();
}
execute = async (query: MatchQuery): Promise<MatchingResult> => {
query
.setMissingMarginDurations(this._defaultParams.DEPARTURE_TIME_MARGIN)
.setMissingStrict(this._defaultParams.STRICT)
.setDefaultDriverAndPassengerParameters({
driver: this._defaultParams.DRIVER,
passenger: this._defaultParams.PASSENGER,
seatsProposed: this._defaultParams.SEATS_PROPOSED,
seatsRequested: this._defaultParams.SEATS_REQUESTED,
})
.setDefaultAlgorithmParameters({
algorithmType: this._defaultParams.ALGORITHM_TYPE,
remoteness: this._defaultParams.REMOTENESS,
useProportion: this._defaultParams.USE_PROPORTION,
proportion: this._defaultParams.PROPORTION,
useAzimuth: this._defaultParams.USE_AZIMUTH,
azimuthMargin: this._defaultParams.AZIMUTH_MARGIN,
maxDetourDistanceRatio: this._defaultParams.MAX_DETOUR_DISTANCE_RATIO,
maxDetourDurationRatio: this._defaultParams.MAX_DETOUR_DURATION_RATIO,
})
.setDefaultPagination({
page: 1,
perPage: this._defaultParams.PER_PAGE,
})
.setDatesAndSchedule(this.datetimeTransformer);
let matchingEntity: MatchingEntity | undefined = await this._cachedMatching(
query.id,
);
if (!matchingEntity)
matchingEntity = (await this._createMatching(query)) as MatchingEntity;
const perPage: number = query.perPage as number;
const page: number = Paginator.pageNumber(
matchingEntity.getProps().matches.length,
perPage,
query.page as number,
);
return {
id: matchingEntity.id,
matches: Paginator.pageItems(
matchingEntity.getProps().matches,
page,
perPage,
),
total: matchingEntity.getProps().matches.length,
page,
perPage,
};
};
private _cachedMatching = async (
id?: string,
): Promise<MatchingEntity | undefined> => {
if (!id) return undefined;
try {
return await this.matchingRepository.get(id);
} catch (e: any) {
return undefined;
}
};
private _createMatching = async (
query: MatchQuery,
): Promise<MatchingEntity> => {
await query.setRoutes();
let algorithm: Algorithm;
switch (query.algorithmType) {
case AlgorithmType.PASSENGER_ORIENTED:
default:
algorithm = new PassengerOrientedAlgorithm(query, this.adRepository);
}
const matches: MatchEntity[] = await algorithm.match();
const matchingEntity = MatchingEntity.create({
matches,
query: {
driver: query.driver as boolean,
passenger: query.passenger as boolean,
frequency: query.frequency,
fromDate: query.fromDate,
toDate: query.toDate,
schedule: query.schedule.map((scheduleItem: ScheduleItem) => ({
day: scheduleItem.day as number,
time: scheduleItem.time,
margin: scheduleItem.margin as number,
})),
seatsProposed: query.seatsProposed as number,
seatsRequested: query.seatsRequested as number,
strict: query.strict as boolean,
waypoints: query.waypoints,
algorithmType: query.algorithmType as AlgorithmType,
remoteness: query.remoteness as number,
useProportion: query.useProportion as boolean,
proportion: query.proportion as number,
useAzimuth: query.useAzimuth as boolean,
azimuthMargin: query.azimuthMargin as number,
maxDetourDistanceRatio: query.maxDetourDistanceRatio as number,
maxDetourDurationRatio: query.maxDetourDurationRatio as number,
},
});
await this.matchingRepository.save(matchingEntity);
return matchingEntity;
};
}
export type MatchingResult = {
id: string;
matches: MatchEntity[];
total: number;
page: number;
perPage: number;
};

View File

@ -0,0 +1,258 @@
import { QueryBase } from '@mobicoop/ddd-library';
import { AlgorithmType } from '../../types/algorithm.types';
import { Waypoint } from '../../types/waypoint.type';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { MatchRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/match.request.dto';
import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port';
import { RouteProviderPort } from '../../ports/route-provider.port';
import {
Path,
PathCreator,
PathType,
TypedRoute,
} from '@modules/ad/core/domain/path-creator.service';
import { Point } from '@modules/ad/core/domain/value-objects/point.value-object';
import { Route } from '../../types/route.type';
export class MatchQuery extends QueryBase {
id?: string;
driver?: boolean;
passenger?: boolean;
readonly frequency: Frequency;
fromDate: string;
toDate: string;
schedule: ScheduleItem[];
seatsProposed?: number;
seatsRequested?: number;
strict?: boolean;
readonly waypoints: Waypoint[];
algorithmType?: AlgorithmType;
remoteness?: number;
useProportion?: boolean;
proportion?: number;
useAzimuth?: boolean;
azimuthMargin?: number;
maxDetourDistanceRatio?: number;
maxDetourDurationRatio?: number;
page?: number;
perPage?: number;
driverRoute?: Route;
passengerRoute?: Route;
backAzimuth?: number;
private readonly originWaypoint: Waypoint;
routeProvider: RouteProviderPort;
constructor(props: MatchRequestDto, routeProvider: RouteProviderPort) {
super();
this.id = props.id;
this.driver = props.driver;
this.passenger = props.passenger;
this.frequency = props.frequency;
this.fromDate = props.fromDate;
this.toDate = props.toDate;
this.schedule = props.schedule;
this.seatsProposed = props.seatsProposed;
this.seatsRequested = props.seatsRequested;
this.strict = props.strict;
this.waypoints = props.waypoints;
this.algorithmType = props.algorithmType;
this.remoteness = props.remoteness;
this.useProportion = props.useProportion;
this.proportion = props.proportion;
this.useAzimuth = props.useAzimuth;
this.azimuthMargin = props.azimuthMargin;
this.maxDetourDistanceRatio = props.maxDetourDistanceRatio;
this.maxDetourDurationRatio = props.maxDetourDurationRatio;
this.page = props.page;
this.perPage = props.perPage;
this.originWaypoint = this.waypoints.filter(
(waypoint: Waypoint) => waypoint.position == 0,
)[0];
this.routeProvider = routeProvider;
}
setMissingMarginDurations = (defaultMarginDuration: number): MatchQuery => {
this.schedule.forEach((day: ScheduleItem) => {
if (day.margin === undefined) day.margin = defaultMarginDuration;
});
return this;
};
setMissingStrict = (strict: boolean): MatchQuery => {
if (this.strict === undefined) this.strict = strict;
return this;
};
setDefaultDriverAndPassengerParameters = (
defaultDriverAndPassengerParameters: DefaultDriverAndPassengerParameters,
): MatchQuery => {
this.driver = !!this.driver;
this.passenger = !!this.passenger;
if (!this.driver && !this.passenger) {
this.driver = defaultDriverAndPassengerParameters.driver;
this.seatsProposed = defaultDriverAndPassengerParameters.seatsProposed;
this.passenger = defaultDriverAndPassengerParameters.passenger;
this.seatsRequested = defaultDriverAndPassengerParameters.seatsRequested;
return this;
}
if (!this.seatsProposed || this.seatsProposed <= 0)
this.seatsProposed = defaultDriverAndPassengerParameters.seatsProposed;
if (!this.seatsRequested || this.seatsRequested <= 0)
this.seatsRequested = defaultDriverAndPassengerParameters.seatsRequested;
return this;
};
setDefaultAlgorithmParameters = (
defaultAlgorithmParameters: DefaultAlgorithmParameters,
): MatchQuery => {
if (!this.algorithmType)
this.algorithmType = defaultAlgorithmParameters.algorithmType;
if (!this.remoteness)
this.remoteness = defaultAlgorithmParameters.remoteness;
if (this.useProportion == undefined)
this.useProportion = defaultAlgorithmParameters.useProportion;
if (!this.proportion)
this.proportion = defaultAlgorithmParameters.proportion;
if (this.useAzimuth == undefined)
this.useAzimuth = defaultAlgorithmParameters.useAzimuth;
if (!this.azimuthMargin)
this.azimuthMargin = defaultAlgorithmParameters.azimuthMargin;
if (!this.maxDetourDistanceRatio)
this.maxDetourDistanceRatio =
defaultAlgorithmParameters.maxDetourDistanceRatio;
if (!this.maxDetourDurationRatio)
this.maxDetourDurationRatio =
defaultAlgorithmParameters.maxDetourDurationRatio;
return this;
};
setDefaultPagination = (defaultPagination: DefaultPagination): MatchQuery => {
if (!this.page) this.page = defaultPagination.page;
if (!this.perPage) this.perPage = defaultPagination.perPage;
return this;
};
setDatesAndSchedule = (
datetimeTransformer: DateTimeTransformerPort,
): MatchQuery => {
const initialFromDate: string = this.fromDate;
this.fromDate = datetimeTransformer.fromDate(
{
date: initialFromDate,
time: this.schedule[0].time,
coordinates: {
lon: this.originWaypoint.lon,
lat: this.originWaypoint.lat,
},
},
this.frequency,
);
this.toDate = datetimeTransformer.toDate(
this.toDate,
{
date: initialFromDate,
time: this.schedule[0].time,
coordinates: {
lon: this.originWaypoint.lon,
lat: this.originWaypoint.lat,
},
},
this.frequency,
);
this.schedule = this.schedule.map((scheduleItem: ScheduleItem) => ({
day: datetimeTransformer.day(
scheduleItem.day ?? new Date(this.fromDate).getUTCDay(),
{
date: this.fromDate,
time: scheduleItem.time,
coordinates: {
lon: this.originWaypoint.lon,
lat: this.originWaypoint.lat,
},
},
this.frequency,
),
time: datetimeTransformer.time(
{
date: this.fromDate,
time: scheduleItem.time,
coordinates: {
lon: this.originWaypoint.lon,
lat: this.originWaypoint.lat,
},
},
this.frequency,
),
margin: scheduleItem.margin,
}));
return this;
};
setRoutes = async (): Promise<MatchQuery> => {
const roles: Role[] = [];
if (this.driver) roles.push(Role.DRIVER);
if (this.passenger) roles.push(Role.PASSENGER);
const pathCreator: PathCreator = new PathCreator(
roles,
this.waypoints.map(
(waypoint: Waypoint) =>
new Point({
lon: waypoint.lon,
lat: waypoint.lat,
}),
),
);
try {
(
await Promise.all(
pathCreator.getBasePaths().map(async (path: Path) => ({
type: path.type,
route: await this.routeProvider.getBasic(path.waypoints),
})),
)
).forEach((typedRoute: TypedRoute) => {
if (typedRoute.type !== PathType.PASSENGER) {
this.driverRoute = typedRoute.route;
this.backAzimuth = typedRoute.route.backAzimuth;
}
if (typedRoute.type !== PathType.DRIVER) {
this.passengerRoute = typedRoute.route;
if (!this.backAzimuth)
this.backAzimuth = typedRoute.route.backAzimuth;
}
});
} catch (e: any) {
throw new Error('Unable to find a route for given waypoints');
}
return this;
};
}
export type ScheduleItem = {
day?: number;
time: string;
margin?: number;
};
interface DefaultDriverAndPassengerParameters {
driver: boolean;
passenger: boolean;
seatsProposed: number;
seatsRequested: number;
}
interface DefaultAlgorithmParameters {
algorithmType: AlgorithmType;
remoteness: number;
useProportion: boolean;
proportion: number;
useAzimuth: boolean;
azimuthMargin: number;
maxDetourDistanceRatio: number;
maxDetourDurationRatio: number;
}
interface DefaultPagination {
page: number;
perPage: number;
}

View File

@ -0,0 +1,30 @@
import { Algorithm } from './algorithm.abstract';
import { MatchQuery } from './match.query';
import { PassengerOrientedCarpoolPathCompleter } from './completer/passenger-oriented-carpool-path.completer';
import { PassengerOrientedGeoFilter } from './filter/passenger-oriented-geo.filter';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { PassengerOrientedSelector } from './selector/passenger-oriented.selector';
import {
RouteCompleter,
RouteCompleterType,
} from './completer/route.completer';
import { JourneyCompleter } from './completer/journey.completer';
import { JourneyFilter } from './filter/journey.filter';
export class PassengerOrientedAlgorithm extends Algorithm {
constructor(
protected readonly query: MatchQuery,
protected readonly repository: AdRepositoryPort,
) {
super(query, repository);
this.selector = new PassengerOrientedSelector(query, repository);
this.processors = [
new PassengerOrientedCarpoolPathCompleter(query),
new RouteCompleter(query, RouteCompleterType.BASIC),
new PassengerOrientedGeoFilter(query),
new RouteCompleter(query, RouteCompleterType.DETAILED),
new JourneyCompleter(query),
new JourneyFilter(query),
];
}
}

View File

@ -0,0 +1,364 @@
import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { Selector } from '../algorithm.abstract';
import { Waypoint } from '../../../types/waypoint.type';
import { Point } from '../../../types/point.type';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { ScheduleItem } from '@modules/ad/core/domain/value-objects/schedule-item.value-object';
export class PassengerOrientedSelector extends Selector {
select = async (): Promise<CandidateEntity[]> => {
const queryStringRoles: QueryStringRole[] = [];
if (this.query.driver)
queryStringRoles.push({
query: this._createQueryString(Role.DRIVER),
role: Role.DRIVER,
});
if (this.query.passenger)
queryStringRoles.push({
query: this._createQueryString(Role.PASSENGER),
role: Role.PASSENGER,
});
return (
await Promise.all(
queryStringRoles.map<Promise<AdsRole>>(
async (queryStringRole: QueryStringRole) =>
<AdsRole>{
ads: await this.repository.getCandidateAds(queryStringRole.query),
role: queryStringRole.role,
},
),
)
)
.map((adsRole: AdsRole) =>
adsRole.ads.map((adEntity: AdEntity) =>
CandidateEntity.create({
id: adEntity.id,
role: adsRole.role == Role.DRIVER ? Role.PASSENGER : Role.DRIVER,
frequency: adEntity.getProps().frequency,
dateInterval: {
lowerDate: this._maxDateString(
this.query.fromDate,
adEntity.getProps().fromDate,
),
higherDate: this._minDateString(
this.query.toDate,
adEntity.getProps().toDate,
),
},
driverWaypoints:
adsRole.role == Role.PASSENGER
? adEntity.getProps().waypoints
: this.query.waypoints.map((waypoint: Waypoint) => ({
position: waypoint.position,
lon: waypoint.lon,
lat: waypoint.lat,
})),
passengerWaypoints:
adsRole.role == Role.DRIVER
? adEntity.getProps().waypoints
: this.query.waypoints.map((waypoint: Waypoint) => ({
position: waypoint.position,
lon: waypoint.lon,
lat: waypoint.lat,
})),
driverDistance:
adsRole.role == Role.PASSENGER
? (adEntity.getProps().driverDistance as number)
: (this.query.driverRoute?.distance as number),
driverDuration:
adsRole.role == Role.PASSENGER
? (adEntity.getProps().driverDuration as number)
: (this.query.driverRoute?.duration as number),
driverSchedule:
adsRole.role == Role.PASSENGER
? adEntity.getProps().schedule
: this.query.schedule.map((scheduleItem: ScheduleItem) => ({
day: scheduleItem.day as number,
time: scheduleItem.time,
margin: scheduleItem.margin as number,
})),
passengerSchedule:
adsRole.role == Role.DRIVER
? adEntity.getProps().schedule
: this.query.schedule.map((scheduleItem: ScheduleItem) => ({
day: scheduleItem.day as number,
time: scheduleItem.time,
margin: scheduleItem.margin as number,
})),
spacetimeDetourRatio: {
maxDistanceDetourRatio: this.query
.maxDetourDistanceRatio as number,
maxDurationDetourRatio: this.query
.maxDetourDurationRatio as number,
},
}),
),
)
.flat();
};
private _createQueryString = (role: Role): string =>
[
this._createSelect(role),
this._createFrom(),
'WHERE',
this._createWhere(role),
]
.join(' ')
.replace(/\s+/g, ' '); // remove duplicate spaces for easy debug !
private _createSelect = (role: Role): string =>
[
`SELECT \
ad.uuid,driver,passenger,frequency,public.st_astext(ad.waypoints) as waypoints,\
"fromDate","toDate",\
"seatsProposed","seatsRequested",\
strict,\
"fwdAzimuth","backAzimuth",\
ad."createdAt",ad."updatedAt",\
si.uuid as "scheduleItemUuid",si.day,si.time,si.margin,si."createdAt" as "scheduleItemCreatedAt",si."updatedAt" as "scheduleItemUpdatedAt"`,
role == Role.DRIVER ? this._selectAsDriver() : this._selectAsPassenger(),
].join();
private _selectAsDriver = (): string =>
`${this.query.driverRoute?.duration} as driverDuration,${this.query.driverRoute?.distance} as driverDistance`;
private _selectAsPassenger = (): string =>
`"driverDuration","driverDistance"`;
private _createFrom = (): string =>
'FROM ad LEFT JOIN schedule_item si ON ad.uuid = si."adUuid"';
private _createWhere = (role: Role): string =>
[
this._whereRole(role),
this._whereStrict(),
this._whereDate(),
this._whereSchedule(role),
this._whereAzimuth(),
this._whereProportion(role),
this._whereRemoteness(role),
]
.filter((where: string) => where != '')
.join(' AND ');
private _whereRole = (role: Role): string =>
role == Role.PASSENGER ? 'driver=True' : 'passenger=True';
private _whereStrict = (): string =>
this.query.strict
? this.query.frequency == Frequency.PUNCTUAL
? `frequency='${Frequency.PUNCTUAL}'`
: `frequency='${Frequency.RECURRENT}'`
: '';
private _whereDate = (): string =>
`(\
(\
"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}'\
) OR (\
"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 => {
const schedule: string[] = [];
// we need full dates to compare times, because margins can lead to compare on previous or next day
// -first we establish a base calendar (up to a week)
const scheduleDates: Date[] = this._datesBetweenBoundaries(
this.query.fromDate,
this.query.toDate,
);
// - then we compare each resulting day of the schedule with each day of calendar,
// adding / removing margin depending on the role
scheduleDates.map((date: Date) => {
this.query.schedule
.filter(
(scheduleItem: ScheduleItem) => date.getUTCDay() == scheduleItem.day,
)
.map((scheduleItem: ScheduleItem) => {
switch (role) {
case Role.PASSENGER:
schedule.push(this._wherePassengerSchedule(date, scheduleItem));
break;
case Role.DRIVER:
schedule.push(this._whereDriverSchedule(date, scheduleItem));
break;
}
});
});
if (schedule.length > 0) {
return ['(', schedule.join(' OR '), ')'].join('');
}
return '';
};
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 => {
if (!this.query.useAzimuth) return '';
const { minAzimuth, maxAzimuth } = this._azimuthRange(
this.query.backAzimuth as number,
this.query.azimuthMargin as number,
);
if (minAzimuth <= maxAzimuth)
return `("fwdAzimuth" <= ${minAzimuth} OR "fwdAzimuth" >= ${maxAzimuth})`;
return `("fwdAzimuth" <= ${minAzimuth} AND "fwdAzimuth" >= ${maxAzimuth})`;
};
private _whereProportion = (role: Role): string => {
if (!this.query.useProportion) return '';
switch (role) {
case Role.PASSENGER:
return `(${this.query.passengerRoute?.distance}>(${this.query.proportion}*"driverDistance"))`;
case Role.DRIVER:
return `("passengerDistance">(${this.query.proportion}*${this.query.driverRoute?.distance}))`;
}
};
private _whereRemoteness = (role: Role): string => {
this.query.waypoints.sort(
(firstWaypoint: Waypoint, secondWaypoint: Waypoint) =>
firstWaypoint.position - secondWaypoint.position,
);
switch (role) {
case Role.PASSENGER:
return `\
public.st_distance('POINT(${this.query.waypoints[0].lon} ${
this.query.waypoints[0].lat
})'::public.geography,direction)<\
${this.query.remoteness} AND \
public.st_distance('POINT(${
this.query.waypoints[this.query.waypoints.length - 1].lon
} ${
this.query.waypoints[this.query.waypoints.length - 1].lat
})'::public.geography,direction)<\
${this.query.remoteness}`;
case Role.DRIVER:
const lineStringPoints: string[] = [];
this.query.driverRoute?.points.forEach((point: Point) =>
lineStringPoints.push(
`public.st_makepoint(${point.lon},${point.lat})`,
),
);
const lineString = [
'public.st_makeline( ARRAY[ ',
lineStringPoints.join(','),
'] )::public.geography',
].join('');
return `\
public.st_distance( public.st_startpoint(waypoints::public.geometry), ${lineString})<\
${this.query.remoteness} AND \
public.st_distance( public.st_endpoint(waypoints::public.geometry), ${lineString})<\
${this.query.remoteness}`;
}
};
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 = (
azimuth: number,
margin: number,
): { minAzimuth: number; maxAzimuth: number } => ({
minAzimuth:
azimuth - margin < 0 ? azimuth - margin + 360 : azimuth - margin,
maxAzimuth:
azimuth + margin > 360 ? azimuth + margin - 360 : azimuth + margin,
});
private _maxDateString = (date1: string, date2: string): string =>
new Date(date1) > new Date(date2) ? date1 : date2;
private _minDateString = (date1: string, date2: string): string =>
new Date(date1) < new Date(date2) ? date1 : date2;
}
export type QueryStringRole = {
query: string;
role: Role;
};
type AdsRole = {
ads: AdEntity[];
role: Role;
};

View File

@ -0,0 +1,10 @@
import { Point } from './point.type';
export type Address = {
name?: string;
houseNumber?: string;
street?: string;
locality?: string;
postalCode?: string;
country?: string;
} & Point;

View File

@ -0,0 +1,20 @@
import { Role } from '../../domain/ad.types';
import { Point } from '../../domain/value-objects/point.value-object';
export enum AlgorithmType {
PASSENGER_ORIENTED = 'PASSENGER_ORIENTED',
}
/**
* A candidate is a potential match
*/
export type Candidate = {
ad: Ad;
role: Role;
driverWaypoints: Point[];
crewWaypoints: Point[];
};
export type Ad = {
id: string;
};

View File

@ -1,4 +1,4 @@
export type Coordinates = {
export type Point = {
lon: number;
lat: number;
};

View File

@ -1,11 +1,12 @@
import { Coordinates } from './coordinates.type';
import { Point } from './point.type';
import { Step } from './step.type';
export type Route = {
driverDistance?: number;
driverDuration?: number;
passengerDistance?: number;
passengerDuration?: number;
distance: number;
duration: number;
fwdAzimuth: number;
backAzimuth: number;
points: Coordinates[];
distanceAzimuth: number;
points: Point[];
steps?: Step[];
};

View File

@ -0,0 +1,6 @@
import { Point } from './point.type';
export type Step = Point & {
duration: number;
distance?: number;
};

View File

@ -1,5 +1,5 @@
import { Coordinates } from './coordinates.type';
import { Address } from './address.type';
export type Waypoint = {
position: number;
} & Coordinates;
} & Address;

View File

@ -1,6 +1,5 @@
import { PointProps } from './value-objects/point.value-object';
import { ScheduleItemProps } from './value-objects/schedule-item.value-object';
import { WaypointProps } from './value-objects/waypoint.value-object';
// All properties that an Ad has
export interface AdProps {
@ -17,7 +16,7 @@ export interface AdProps {
driverDistance?: number;
passengerDuration?: number;
passengerDistance?: number;
waypoints: WaypointProps[];
waypoints: PointProps[];
points: PointProps[];
fwdAzimuth: number;
backAzimuth: number;
@ -35,7 +34,7 @@ export interface CreateAdProps {
seatsProposed: number;
seatsRequested: number;
strict: boolean;
waypoints: WaypointProps[];
waypoints: PointProps[];
driverDuration?: number;
driverDistance?: number;
passengerDuration?: number;

View File

@ -0,0 +1,125 @@
import { ExceptionBase } from '@mobicoop/ddd-library';
import { DateInterval } from './candidate.types';
export class CalendarTools {
/**
* Returns the first date corresponding to a week day (0 based monday)
* within a date range
*/
static firstDate = (weekDay: number, dateInterval: DateInterval): Date => {
if (weekDay < 0 || weekDay > 6)
throw new CalendarToolsException(
new Error('weekDay must be between 0 and 6'),
);
const lowerDateAsDate: Date = new Date(dateInterval.lowerDate);
const higherDateAsDate: Date = new Date(dateInterval.higherDate);
if (lowerDateAsDate.getUTCDay() == weekDay) return lowerDateAsDate;
const nextDate: Date = new Date(lowerDateAsDate);
nextDate.setUTCDate(
lowerDateAsDate.getUTCDate() +
(7 - (lowerDateAsDate.getUTCDay() - weekDay)),
);
if (lowerDateAsDate.getUTCDay() < weekDay) {
nextDate.setUTCMonth(lowerDateAsDate.getUTCMonth());
nextDate.setUTCFullYear(lowerDateAsDate.getUTCFullYear());
nextDate.setUTCDate(
lowerDateAsDate.getUTCDate() + (weekDay - lowerDateAsDate.getUTCDay()),
);
}
if (nextDate <= higherDateAsDate) return nextDate;
throw new CalendarToolsException(
new Error('no available day for the given date range'),
);
};
/**
* Returns the last date corresponding to a week day (0 based monday)
* within a date range
*/
static lastDate = (weekDay: number, dateInterval: DateInterval): Date => {
if (weekDay < 0 || weekDay > 6)
throw new CalendarToolsException(
new Error('weekDay must be between 0 and 6'),
);
const lowerDateAsDate: Date = new Date(dateInterval.lowerDate);
const higherDateAsDate: Date = new Date(dateInterval.higherDate);
if (higherDateAsDate.getUTCDay() == weekDay) return higherDateAsDate;
const previousDate: Date = new Date(higherDateAsDate);
previousDate.setUTCDate(
higherDateAsDate.getUTCDate() - (higherDateAsDate.getUTCDay() - weekDay),
);
if (higherDateAsDate.getUTCDay() < weekDay) {
previousDate.setUTCMonth(higherDateAsDate.getUTCMonth());
previousDate.setUTCFullYear(higherDateAsDate.getUTCFullYear());
previousDate.setUTCDate(
higherDateAsDate.getUTCDate() -
(7 + (higherDateAsDate.getUTCDay() - weekDay)),
);
}
if (previousDate >= lowerDateAsDate) return previousDate;
throw new CalendarToolsException(
new Error('no available day for the given date range'),
);
};
/**
* Returns a date from a date (as a date) and a time (as a string), adding optional seconds
*/
static datetimeWithSeconds = (
date: Date,
time: string,
additionalSeconds = 0,
): Date => {
const datetime: Date = new Date(date);
datetime.setUTCHours(parseInt(time.split(':')[0]));
datetime.setUTCMinutes(parseInt(time.split(':')[1]));
datetime.setUTCSeconds(additionalSeconds);
return datetime;
};
/**
* Returns dates from a day and time based on unix epoch day
* (1970-01-01 is day 4)
* The method returns an array of dates because for edges (day 0 and 6)
* we need to return 2 possibilities : one for the previous week, one for the next week
*/
static epochDaysFromTime = (weekDay: number, time: string): Date[] => {
if (weekDay < 0 || weekDay > 6)
throw new CalendarToolsException(
new Error('weekDay must be between 0 and 6'),
);
switch (weekDay) {
case 0:
return [
new Date(`1969-12-28T${time}:00Z`),
new Date(`1970-01-04T${time}:00Z`),
];
case 1:
return [new Date(`1969-12-29T${time}:00Z`)];
case 2:
return [new Date(`1969-12-30T${time}:00Z`)];
case 3:
return [new Date(`1969-12-31T${time}:00Z`)];
case 5:
return [new Date(`1970-01-02T${time}:00Z`)];
case 6:
return [
new Date(`1969-12-27T${time}:00Z`),
new Date(`1970-01-03T${time}:00Z`),
];
case 4:
default:
return [new Date(`1970-01-01T${time}:00Z`)];
}
};
}
export class CalendarToolsException extends ExceptionBase {
static readonly message = 'Calendar tools error';
public readonly code = 'CALENDAR.TOOLS';
constructor(cause?: Error, metadata?: unknown) {
super(CalendarToolsException.message, cause, metadata);
}
}

View File

@ -0,0 +1,264 @@
import { AggregateRoot, AggregateID } 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 } 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 => {
this.props.journeys = this.props.driverSchedule
// first we create the journeys
.map((driverScheduleItem: ScheduleItem) =>
this._createJourney(driverScheduleItem),
)
// then we filter the ones with invalid pickups
.filter((journey: Journey) => journey.hasValidPickUp());
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;
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
// 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
}
}
type ScheduleItemRange = {
scheduleItem: ScheduleItem;
range: Date[];
};
type ScheduleItemGap = {
scheduleItem: ScheduleItem;
gap: number;
};

View File

@ -0,0 +1,61 @@
import { Frequency, Role } from './ad.types';
import { PointProps } from './value-objects/point.value-object';
import { ScheduleItemProps } from './value-objects/schedule-item.value-object';
import { CarpoolPathItemProps } from './value-objects/carpool-path-item.value-object';
import { JourneyProps } from './value-objects/journey.value-object';
import { StepProps } from './value-objects/step.value-object';
// All properties that a Candidate has
export interface CandidateProps {
role: Role;
frequency: Frequency;
driverWaypoints: PointProps[];
passengerWaypoints: PointProps[];
driverSchedule: ScheduleItemProps[];
passengerSchedule: ScheduleItemProps[];
driverDistance: number;
driverDuration: number;
dateInterval: DateInterval;
carpoolPath?: CarpoolPathItemProps[];
distance?: number;
duration?: number;
steps?: StepProps[];
journeys?: JourneyProps[];
spacetimeDetourRatio: SpacetimeDetourRatio;
}
// Properties that are needed for a Candidate creation
export interface CreateCandidateProps {
id: string;
role: Role;
frequency: Frequency;
driverDistance: number;
driverDuration: number;
driverWaypoints: PointProps[];
passengerWaypoints: PointProps[];
driverSchedule: ScheduleItemProps[];
passengerSchedule: ScheduleItemProps[];
spacetimeDetourRatio: SpacetimeDetourRatio;
dateInterval: DateInterval;
}
export enum Target {
START = 'START',
INTERMEDIATE = 'INTERMEDIATE',
FINISH = 'FINISH',
NEUTRAL = 'NEUTRAL',
}
export abstract class Validator {
abstract validate(): boolean;
}
export type SpacetimeDetourRatio = {
maxDistanceDetourRatio: number;
maxDurationDetourRatio: number;
};
export type DateInterval = {
lowerDate: string;
higherDate: string;
};

View File

@ -0,0 +1,283 @@
import { Role } from './ad.types';
import { Target } from './candidate.types';
import { CarpoolPathCreatorException } from './match.errors';
import { Actor } from './value-objects/actor.value-object';
import { Point } from './value-objects/point.value-object';
import { CarpoolPathItem } from './value-objects/carpool-path-item.value-object';
export class CarpoolPathCreator {
private PRECISION = 5;
constructor(
private readonly driverWaypoints: Point[],
private readonly passengerWaypoints: Point[],
) {
if (driverWaypoints.length < 2)
throw new CarpoolPathCreatorException(
new Error('At least 2 driver waypoints must be defined'),
);
if (passengerWaypoints.length < 2)
throw new CarpoolPathCreatorException(
new Error('At least 2 passenger waypoints must be defined'),
);
}
/**
* Creates a path (a list of carpoolPathItem) between driver waypoints
and passenger waypoints respecting the order
of the driver waypoints
Inspired by :
https://stackoverflow.com/questions/849211/shortest-distance-between-a-point-and-a-line-segment
*/
public carpoolPath = (): CarpoolPathItem[] =>
this._consolidate(
this._mixedCarpoolPath(
this._driverCarpoolPath(),
this._passengerCarpoolPath(),
),
);
private _mixedCarpoolPath = (
driverCarpoolPath: CarpoolPathItem[],
passengerCarpoolPath: CarpoolPathItem[],
): CarpoolPathItem[] =>
driverCarpoolPath.length == 2
? this._simpleMixedCarpoolPath(driverCarpoolPath, passengerCarpoolPath)
: this._complexMixedCarpoolPath(driverCarpoolPath, passengerCarpoolPath);
private _driverCarpoolPath = (): CarpoolPathItem[] =>
this.driverWaypoints.map(
(waypoint: Point, index: number) =>
new CarpoolPathItem({
lon: waypoint.lon,
lat: waypoint.lat,
actors: [
new Actor({
role: Role.DRIVER,
target: this._getTarget(index, this.driverWaypoints),
}),
],
}),
);
/**
* Creates the passenger carpoolPath with original passenger waypoints, adding driver waypoints that are the same
*/
private _passengerCarpoolPath = (): CarpoolPathItem[] => {
const carpoolPath: CarpoolPathItem[] = [];
this.passengerWaypoints.forEach(
(passengerWaypoint: Point, index: number) => {
const carpoolPathItem: CarpoolPathItem = new CarpoolPathItem({
lon: passengerWaypoint.lon,
lat: passengerWaypoint.lat,
actors: [
new Actor({
role: Role.PASSENGER,
target: this._getTarget(index, this.passengerWaypoints),
}),
],
});
if (
this.driverWaypoints.filter((driverWaypoint: Point) =>
passengerWaypoint.equals(driverWaypoint),
).length == 0
) {
carpoolPathItem.actors.push(
new Actor({
role: Role.DRIVER,
target: Target.NEUTRAL,
}),
);
}
carpoolPath.push(carpoolPathItem);
},
);
return carpoolPath;
};
private _simpleMixedCarpoolPath = (
driverCarpoolPath: CarpoolPathItem[],
passengerCarpoolPath: CarpoolPathItem[],
): CarpoolPathItem[] => [
driverCarpoolPath[0],
...passengerCarpoolPath,
driverCarpoolPath[1],
];
private _complexMixedCarpoolPath = (
driverCarpoolPath: CarpoolPathItem[],
passengerCarpoolPath: CarpoolPathItem[],
): CarpoolPathItem[] => {
let mixedCarpoolPath: CarpoolPathItem[] = [...driverCarpoolPath];
const originInsertIndex: number = this._insertIndex(
passengerCarpoolPath[0],
driverCarpoolPath,
);
mixedCarpoolPath = [
...mixedCarpoolPath.slice(0, originInsertIndex),
passengerCarpoolPath[0],
...mixedCarpoolPath.slice(originInsertIndex),
];
const destinationInsertIndex: number =
this._insertIndex(
passengerCarpoolPath[passengerCarpoolPath.length - 1],
driverCarpoolPath,
) + 1;
mixedCarpoolPath = [
...mixedCarpoolPath.slice(0, destinationInsertIndex),
passengerCarpoolPath[passengerCarpoolPath.length - 1],
...mixedCarpoolPath.slice(destinationInsertIndex),
];
return mixedCarpoolPath;
};
private _insertIndex = (
targetCarpoolPathItem: CarpoolPathItem,
carpoolPath: CarpoolPathItem[],
): number =>
this._closestSegmentIndex(
targetCarpoolPathItem,
this._segments(carpoolPath),
) + 1;
private _segments = (carpoolPath: CarpoolPathItem[]): CarpoolPathItem[][] => {
const segments: CarpoolPathItem[][] = [];
carpoolPath.forEach((carpoolPathItem: CarpoolPathItem, index: number) => {
if (index < carpoolPath.length - 1)
segments.push([carpoolPathItem, carpoolPath[index + 1]]);
});
return segments;
};
private _closestSegmentIndex = (
carpoolPathItem: CarpoolPathItem,
segments: CarpoolPathItem[][],
): number => {
const distances: Map<number, number> = new Map();
segments.forEach((segment: CarpoolPathItem[], index: number) => {
distances.set(index, this._distanceToSegment(carpoolPathItem, segment));
});
const sortedDistances: Map<number, number> = new Map(
[...distances.entries()].sort((a, b) => a[1] - b[1]),
);
const [closestSegmentIndex] = sortedDistances.keys();
return closestSegmentIndex;
};
private _distanceToSegment = (
carpoolPathItem: CarpoolPathItem,
segment: CarpoolPathItem[],
): number =>
parseFloat(
Math.sqrt(
this._distanceToSegmentSquared(carpoolPathItem, segment),
).toFixed(this.PRECISION),
);
private _distanceToSegmentSquared = (
carpoolPathItem: CarpoolPathItem,
segment: CarpoolPathItem[],
): number => {
const length2: number = this._distanceSquared(
new Point({
lon: segment[0].lon,
lat: segment[0].lat,
}),
new Point({
lon: segment[1].lon,
lat: segment[1].lat,
}),
);
if (length2 == 0)
return this._distanceSquared(
new Point({
lon: carpoolPathItem.lon,
lat: carpoolPathItem.lat,
}),
new Point({
lon: segment[0].lon,
lat: segment[0].lat,
}),
);
const length: number = Math.max(
0,
Math.min(
1,
((carpoolPathItem.lon - segment[0].lon) *
(segment[1].lon - segment[0].lon) +
(carpoolPathItem.lat - segment[0].lat) *
(segment[1].lat - segment[0].lat)) /
length2,
),
);
const newPoint: Point = new Point({
lon: segment[0].lon + length * (segment[1].lon - segment[0].lon),
lat: segment[0].lat + length * (segment[1].lat - segment[0].lat),
});
return this._distanceSquared(
new Point({
lon: carpoolPathItem.lon,
lat: carpoolPathItem.lat,
}),
newPoint,
);
};
private _distanceSquared = (point1: Point, point2: Point): number =>
parseFloat(
(
Math.pow(point1.lon - point2.lon, 2) +
Math.pow(point1.lat - point2.lat, 2)
).toFixed(this.PRECISION),
);
private _getTarget = (index: number, waypoints: Point[]): Target =>
index == 0
? Target.START
: index == waypoints.length - 1
? Target.FINISH
: Target.INTERMEDIATE;
/**
* Consolidate carpoolPath by removing duplicate actors (eg. driver with neutral and start or finish target)
*/
private _consolidate = (
carpoolPath: CarpoolPathItem[],
): CarpoolPathItem[] => {
const uniquePoints: Point[] = [];
carpoolPath.forEach((carpoolPathItem: CarpoolPathItem) => {
if (
uniquePoints.find((point: Point) =>
point.equals(
new Point({
lon: carpoolPathItem.lon,
lat: carpoolPathItem.lat,
}),
),
) === undefined
)
uniquePoints.push(
new Point({
lon: carpoolPathItem.lon,
lat: carpoolPathItem.lat,
}),
);
});
return uniquePoints.map(
(point: Point) =>
new CarpoolPathItem({
lon: point.lon,
lat: point.lat,
actors: carpoolPath
.filter((carpoolPathItem: CarpoolPathItem) =>
new Point({
lon: carpoolPathItem.lon,
lat: carpoolPathItem.lat,
}).equals(point),
)
.map((carpoolPathItem: CarpoolPathItem) => carpoolPathItem.actors)
.flat(),
}),
);
};
}

View File

@ -0,0 +1,27 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { v4 } from 'uuid';
import { CreateMatchProps, MatchProps } from './match.types';
export class MatchEntity extends AggregateRoot<MatchProps> {
protected readonly _id: AggregateID;
static create = (create: CreateMatchProps): MatchEntity => {
const id = v4();
const props: MatchProps = {
...create,
distanceDetour: create.distance - create.initialDistance,
durationDetour: create.duration - create.initialDuration,
distanceDetourPercentage: parseFloat(
((100 * create.distance) / create.initialDistance - 100).toFixed(2),
),
durationDetourPercentage: parseFloat(
((100 * create.duration) / create.initialDuration - 100).toFixed(2),
),
};
return new MatchEntity({ id, props });
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

@ -0,0 +1,21 @@
import { ExceptionBase } from '@mobicoop/ddd-library';
export class PathCreatorException extends ExceptionBase {
static readonly message = 'Path creator error';
public readonly code = 'MATCHER.PATH_CREATOR';
constructor(cause?: Error, metadata?: unknown) {
super(PathCreatorException.message, cause, metadata);
}
}
export class CarpoolPathCreatorException extends ExceptionBase {
static readonly message = 'Carpool path creator error';
public readonly code = 'MATCHER.CARPOOL_PATH_CREATOR';
constructor(cause?: Error, metadata?: unknown) {
super(CarpoolPathCreatorException.message, cause, metadata);
}
}

View File

@ -0,0 +1,48 @@
import { AlgorithmType } from '../application/types/algorithm.types';
import { Frequency, Role } from './ad.types';
import { JourneyProps } from './value-objects/journey.value-object';
// All properties that a Match has
export interface MatchProps {
adId: string;
role: Role;
frequency: Frequency;
distance: number;
duration: number;
initialDistance: number;
initialDuration: number;
distanceDetour: number;
durationDetour: number;
distanceDetourPercentage: number;
durationDetourPercentage: number;
journeys: JourneyProps[];
}
// Properties that are needed for a Match creation
export interface CreateMatchProps {
adId: string;
role: Role;
frequency: Frequency;
distance: number;
duration: number;
initialDistance: number;
initialDuration: number;
journeys: JourneyProps[];
}
export interface DefaultMatchQueryProps {
driver: boolean;
passenger: boolean;
marginDuration: number;
strict: boolean;
seatsProposed: number;
seatsRequested: number;
algorithmType?: AlgorithmType;
remoteness?: number;
useProportion?: boolean;
proportion?: number;
useAzimuth?: boolean;
azimuthMargin?: number;
maxDetourDistanceRatio?: number;
maxDetourDurationRatio?: number;
}

View File

@ -0,0 +1,19 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { v4 } from 'uuid';
import { CreateMatchingProps, MatchingProps } from './matching.types';
export class MatchingEntity extends AggregateRoot<MatchingProps> {
protected readonly _id: AggregateID;
static create = (create: CreateMatchingProps): MatchingEntity => {
const id = v4();
const props: MatchingProps = {
...create,
};
return new MatchingEntity({ id, props });
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

@ -0,0 +1,11 @@
import { ExceptionBase } from '@mobicoop/ddd-library';
export class MatchingNotFoundException extends ExceptionBase {
static readonly message = 'Matching error';
public readonly code = 'MATCHER.MATCHING_NOT_FOUND';
constructor(cause?: Error, metadata?: unknown) {
super(MatchingNotFoundException.message, cause, metadata);
}
}

View File

@ -0,0 +1,14 @@
import { MatchEntity } from './match.entity';
import { MatchQueryProps } from './value-objects/match-query.value-object';
// All properties that a Matching has
export interface MatchingProps {
query: MatchQueryProps; // the query that induced the matches
matches: MatchEntity[];
}
// Properties that are needed for a Matching creation
export interface CreateMatchingProps {
query: MatchQueryProps;
matches: MatchEntity[];
}

View File

@ -0,0 +1,87 @@
import { Role } from './ad.types';
import { Point } from './value-objects/point.value-object';
import { PathCreatorException } from './match.errors';
import { Route } from '../application/types/route.type';
export class PathCreator {
constructor(
private readonly roles: Role[],
private readonly waypoints: Point[],
) {
if (roles.length == 0)
throw new PathCreatorException(
new Error('At least a role must be defined'),
);
if (waypoints.length < 2)
throw new PathCreatorException(
new Error('At least 2 waypoints must be defined'),
);
}
public getBasePaths = (): Path[] => {
const paths: Path[] = [];
if (
this.roles.includes(Role.DRIVER) &&
this.roles.includes(Role.PASSENGER)
) {
if (this.waypoints.length == 2) {
// 2 points => same route for driver and passenger
paths.push(this._createGenericPath());
} else {
paths.push(this._createDriverPath(), this._createPassengerPath());
}
} else if (this.roles.includes(Role.DRIVER)) {
paths.push(this._createDriverPath());
} else if (this.roles.includes(Role.PASSENGER)) {
paths.push(this._createPassengerPath());
}
return paths;
};
private _createGenericPath = (): Path =>
this._createPath(this.waypoints, PathType.GENERIC);
private _createDriverPath = (): Path =>
this._createPath(this.waypoints, PathType.DRIVER);
private _createPassengerPath = (): Path =>
this._createPath(
[this._firstWaypoint(), this._lastWaypoint()],
PathType.PASSENGER,
);
private _firstWaypoint = (): Point => this.waypoints[0];
private _lastWaypoint = (): Point =>
this.waypoints[this.waypoints.length - 1];
private _createPath = (waypoints: Point[], type: PathType): Path => ({
type,
waypoints,
});
}
export type Path = {
type: PathType;
waypoints: Point[];
};
export type TypedRoute = {
type: PathType;
route: Route;
};
/**
* PathType id used for route calculation, to reduce the number of routes to compute :
* - a single route for a driver only
* - a single route for a passenger only
* - a single route for a driver and passenger with 2 waypoints given
* - two routes for a driver and passenger with more than 2 waypoints given
* (all the waypoints as driver, only origin and destination as passenger as
* intermediate waypoints doesn't matter in that case)
*/
export enum PathType {
GENERIC = 'generic',
DRIVER = 'driver',
PASSENGER = 'passenger',
}

View File

@ -0,0 +1,84 @@
import { ArgumentInvalidException, ValueObject } from '@mobicoop/ddd-library';
import { Role } from '../ad.types';
import { Target } from '../candidate.types';
import { Actor, ActorProps } from './actor.value-object';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface ActorTimeProps extends ActorProps {
firstDatetime: Date;
firstMinDatetime: Date;
firstMaxDatetime: Date;
lastDatetime: Date;
lastMinDatetime: Date;
lastMaxDatetime: Date;
}
export class ActorTime extends ValueObject<ActorTimeProps> {
get role(): Role {
return this.props.role;
}
get target(): Target {
return this.props.target;
}
get firstDatetime(): Date {
return this.props.firstDatetime;
}
get firstMinDatetime(): Date {
return this.props.firstMinDatetime;
}
get firstMaxDatetime(): Date {
return this.props.firstMaxDatetime;
}
get lastDatetime(): Date {
return this.props.lastDatetime;
}
get lastMinDatetime(): Date {
return this.props.lastMinDatetime;
}
get lastMaxDatetime(): Date {
return this.props.lastMaxDatetime;
}
protected validate(props: ActorTimeProps): void {
// validate actor props
new Actor({
role: props.role,
target: props.target,
});
if (props.firstDatetime.getUTCDay() != props.lastDatetime.getUTCDay())
throw new ArgumentInvalidException(
'firstDatetime week day must be equal to lastDatetime week day',
);
if (props.firstDatetime > props.lastDatetime)
throw new ArgumentInvalidException(
'firstDatetime must be before or equal to lastDatetime',
);
if (props.firstMinDatetime > props.firstDatetime)
throw new ArgumentInvalidException(
'firstMinDatetime must be before or equal to firstDatetime',
);
if (props.firstDatetime > props.firstMaxDatetime)
throw new ArgumentInvalidException(
'firstDatetime must be before or equal to firstMaxDatetime',
);
if (props.lastMinDatetime > props.lastDatetime)
throw new ArgumentInvalidException(
'lastMinDatetime must be before or equal to lastDatetime',
);
if (props.lastDatetime > props.lastMaxDatetime)
throw new ArgumentInvalidException(
'lastDatetime must be before or equal to lastMaxDatetime',
);
}
}

View File

@ -0,0 +1,27 @@
import { ValueObject } from '@mobicoop/ddd-library';
import { Role } from '../ad.types';
import { Target } from '../candidate.types';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface ActorProps {
role: Role;
target: Target;
}
export class Actor extends ValueObject<ActorProps> {
get role(): Role {
return this.props.role;
}
get target(): Target {
return this.props.target;
}
protected validate(): void {
return;
}
}

View File

@ -0,0 +1,47 @@
import {
ArgumentOutOfRangeException,
ValueObject,
} from '@mobicoop/ddd-library';
import { Actor, ActorProps } from './actor.value-object';
import { Role } from '../ad.types';
import { Point, PointProps } from './point.value-object';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface CarpoolPathItemProps extends PointProps {
actors: ActorProps[];
}
export class CarpoolPathItem extends ValueObject<CarpoolPathItemProps> {
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
get actors(): ActorProps[] {
return this.props.actors;
}
protected validate(props: CarpoolPathItemProps): void {
// validate point props
new Point({
lon: props.lon,
lat: props.lat,
});
if (props.actors.length <= 0)
throw new ArgumentOutOfRangeException('at least one actor is required');
if (
props.actors.filter((actor: Actor) => actor.role == Role.DRIVER).length >
1
)
throw new ArgumentOutOfRangeException(
'a carpoolStep can contain only one driver',
);
}
}

View File

@ -0,0 +1,64 @@
import {
ArgumentOutOfRangeException,
ValueObject,
} from '@mobicoop/ddd-library';
import { ActorTime, ActorTimeProps } from './actor-time.value-object';
import { Step, StepProps } from './step.value-object';
import { Role } from '../ad.types';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface JourneyItemProps extends StepProps {
actorTimes: ActorTimeProps[];
}
export class JourneyItem extends ValueObject<JourneyItemProps> {
get duration(): number {
return this.props.duration;
}
get distance(): number | undefined {
return this.props.distance;
}
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
get actorTimes(): ActorTimeProps[] {
return this.props.actorTimes;
}
driverTime = (): string => {
const driverTime: Date = (
this.actorTimes.find(
(actorTime: ActorTime) => actorTime.role == Role.DRIVER,
) as ActorTime
).firstDatetime;
return `${driverTime.getHours().toString().padStart(2, '0')}:${driverTime
.getMinutes()
.toString()
.padStart(2, '0')}`;
};
protected validate(props: JourneyItemProps): void {
// validate step props
new Step({
lon: props.lon,
lat: props.lat,
distance: props.distance,
duration: props.duration,
});
if (props.actorTimes.length == 0)
throw new ArgumentOutOfRangeException(
'at least one actorTime is required',
);
}
}

View File

@ -0,0 +1,106 @@
import { ArgumentInvalidException, ValueObject } from '@mobicoop/ddd-library';
import { JourneyItem, JourneyItemProps } from './journey-item.value-object';
import { ActorTime } from './actor-time.value-object';
import { Role } from '../ad.types';
import { Target } from '../candidate.types';
import { Point } from './point.value-object';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface JourneyProps {
firstDate: Date;
lastDate: Date;
journeyItems: JourneyItemProps[];
}
export class Journey extends ValueObject<JourneyProps> {
get firstDate(): Date {
return this.props.firstDate;
}
get lastDate(): Date {
return this.props.lastDate;
}
get journeyItems(): JourneyItemProps[] {
return this.props.journeyItems;
}
hasValidPickUp = (): boolean => {
const passengerDepartureJourneyItem: JourneyItem = this.journeyItems.find(
(journeyItem: JourneyItem) =>
journeyItem.actorTimes.find(
(actorTime: ActorTime) =>
actorTime.role == Role.PASSENGER &&
actorTime.target == Target.START,
) as ActorTime,
) as JourneyItem;
const passengerDepartureActorTime =
passengerDepartureJourneyItem.actorTimes.find(
(actorTime: ActorTime) =>
actorTime.role == Role.PASSENGER && actorTime.target == Target.START,
) as ActorTime;
const driverNeutralActorTime =
passengerDepartureJourneyItem.actorTimes.find(
(actorTime: ActorTime) =>
actorTime.role == Role.DRIVER && actorTime.target == Target.NEUTRAL,
) as ActorTime;
return (
(passengerDepartureActorTime.firstMinDatetime <=
driverNeutralActorTime.firstMaxDatetime &&
driverNeutralActorTime.firstMaxDatetime <=
passengerDepartureActorTime.firstMaxDatetime) ||
(passengerDepartureActorTime.firstMinDatetime <=
driverNeutralActorTime.firstMinDatetime &&
driverNeutralActorTime.firstMinDatetime <=
passengerDepartureActorTime.firstMaxDatetime)
);
};
firstDriverDepartureTime = (): string => {
const firstDriverDepartureDatetime: Date = (
this._driverDepartureJourneyItem().actorTimes.find(
(actorTime: ActorTime) =>
actorTime.role == Role.DRIVER && actorTime.target == Target.START,
) as ActorTime
).firstDatetime;
return `${firstDriverDepartureDatetime
.getUTCHours()
.toString()
.padStart(2, '0')}:${firstDriverDepartureDatetime
.getUTCMinutes()
.toString()
.padStart(2, '0')}`;
};
driverOrigin = (): Point =>
new Point({
lon: this._driverDepartureJourneyItem().lon,
lat: this._driverDepartureJourneyItem().lat,
});
private _driverDepartureJourneyItem = (): JourneyItem =>
this.journeyItems.find(
(journeyItem: JourneyItem) =>
journeyItem.actorTimes.find(
(actorTime: ActorTime) =>
actorTime.role == Role.DRIVER && actorTime.target == Target.START,
) as ActorTime,
) as JourneyItem;
protected validate(props: JourneyProps): void {
if (props.firstDate.getUTCDay() != props.lastDate.getUTCDay())
throw new ArgumentInvalidException(
'firstDate week day must be equal to lastDate week day',
);
if (props.firstDate > props.lastDate)
throw new ArgumentInvalidException('firstDate must be before lastDate');
if (props.journeyItems.length < 2)
throw new ArgumentInvalidException(
'at least 2 journey items are required',
);
}
}

View File

@ -0,0 +1,109 @@
import { ValueObject } from '@mobicoop/ddd-library';
import { Frequency } from '../ad.types';
import { ScheduleItemProps } from './schedule-item.value-object';
import { PointProps } from './point.value-object';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface MatchQueryProps {
driver: boolean;
passenger: boolean;
frequency: Frequency;
fromDate: string;
toDate: string;
schedule: ScheduleItemProps[];
seatsProposed: number;
seatsRequested: number;
strict: boolean;
waypoints: PointProps[];
algorithmType: string;
remoteness: number;
useProportion: boolean;
proportion: number;
useAzimuth: boolean;
azimuthMargin: number;
maxDetourDistanceRatio: number;
maxDetourDurationRatio: number;
}
export class MatchQuery extends ValueObject<MatchQueryProps> {
get driver(): boolean {
return this.props.driver;
}
get passenger(): boolean {
return this.props.passenger;
}
get frequency(): Frequency {
return this.props.frequency;
}
get fromDate(): string {
return this.props.fromDate;
}
get toDate(): string {
return this.props.toDate;
}
get schedule(): ScheduleItemProps[] {
return this.props.schedule;
}
get seatsProposed(): number {
return this.props.seatsProposed;
}
get seatsRequested(): number {
return this.props.seatsRequested;
}
get strict(): boolean {
return this.props.strict;
}
get waypoints(): PointProps[] {
return this.props.waypoints;
}
get algorithmType(): string {
return this.props.algorithmType;
}
get remoteness(): number {
return this.props.remoteness;
}
get useProportion(): boolean {
return this.props.useProportion;
}
get proportion(): number {
return this.props.proportion;
}
get useAzimuth(): boolean {
return this.props.useAzimuth;
}
get azimuthMargin(): number {
return this.props.azimuthMargin;
}
get maxDetourDistanceRatio(): number {
return this.props.maxDetourDistanceRatio;
}
get maxDetourDurationRatio(): number {
return this.props.maxDetourDurationRatio;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected validate(props: MatchQueryProps): void {
return;
}
}

View File

@ -16,7 +16,7 @@ export interface ScheduleItemProps {
}
export class ScheduleItem extends ValueObject<ScheduleItemProps> {
get day(): number | undefined {
get day(): number {
return this.props.day;
}
@ -24,11 +24,10 @@ export class ScheduleItem extends ValueObject<ScheduleItemProps> {
return this.props.time;
}
get margin(): number | undefined {
get margin(): number {
return this.props.margin;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected validate(props: ScheduleItemProps): void {
if (props.day < 0 || props.day > 6)
throw new ArgumentOutOfRangeException('day must be between 0 and 6');

View File

@ -0,0 +1,46 @@
import { ArgumentInvalidException, ValueObject } from '@mobicoop/ddd-library';
import { Point, PointProps } from './point.value-object';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface StepProps extends PointProps {
duration: number;
distance?: number;
}
export class Step extends ValueObject<StepProps> {
get duration(): number {
return this.props.duration;
}
get distance(): number | undefined {
return this.props.distance;
}
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
protected validate(props: StepProps): void {
// validate point props
new Point({
lon: props.lon,
lat: props.lat,
});
if (props.duration < 0)
throw new ArgumentInvalidException(
'duration must be greater than or equal to 0',
);
if (props.distance !== undefined && props.distance < 0)
throw new ArgumentInvalidException(
'distance must be greater than or equal to 0',
);
}
}

View File

@ -1,41 +0,0 @@
import {
ArgumentInvalidException,
ArgumentOutOfRangeException,
ValueObject,
} from '@mobicoop/ddd-library';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface WaypointProps {
position: number;
lon: number;
lat: number;
}
export class Waypoint extends ValueObject<WaypointProps> {
get position(): number {
return this.props.position;
}
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
protected validate(props: WaypointProps): void {
if (props.position < 0)
throw new ArgumentInvalidException(
'position must be greater than or equal to 0',
);
if (props.lon > 180 || props.lon < -180)
throw new ArgumentOutOfRangeException('lon must be between -180 and 180');
if (props.lat > 90 || props.lat < -90)
throw new ArgumentOutOfRangeException('lat must be between -90 and 90');
}
}

View File

@ -7,53 +7,75 @@ import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens';
import { AdEntity } from '../core/domain/ad.entity';
import { AdMapper } from '../ad.mapper';
import { ExtendedPrismaRepositoryBase } from '@mobicoop/ddd-library/dist/db/prisma-repository.base';
import { Frequency } from '../core/domain/ad.types';
export type AdBaseModel = {
export type AdModel = {
uuid: string;
driver: boolean;
passenger: boolean;
frequency: string;
frequency: Frequency;
fromDate: Date;
toDate: Date;
seatsProposed: number;
seatsRequested: number;
strict: boolean;
driverDuration: number;
driverDistance: number;
passengerDuration: number;
passengerDistance: number;
driverDuration?: number;
driverDistance?: number;
passengerDuration?: number;
passengerDistance?: number;
fwdAzimuth: number;
backAzimuth: number;
createdAt: Date;
updatedAt: Date;
};
export type AdReadModel = AdBaseModel & {
/**
* The record as returned by the persistence system
*/
export type AdReadModel = AdModel & {
waypoints: string;
direction: string;
schedule: ScheduleItemModel[];
};
export type AdWriteModel = AdBaseModel & {
/**
* The record ready to be sent to the peristence system
*/
export type AdWriteModel = AdModel & {
schedule: {
create: ScheduleItemModel[];
};
};
export type AdUnsupportedWriteModel = {
export type AdWriteExtraModel = {
waypoints: string;
direction: string;
};
export type ScheduleItemModel = {
uuid: string;
export type ScheduleItem = {
day: number;
time: Date;
margin: number;
};
export type ScheduleItemModel = ScheduleItem & {
uuid: string;
createdAt: Date;
updatedAt: Date;
};
export type UngroupedAdModel = AdModel &
ScheduleItem & {
scheduleItemUuid: string;
scheduleItemCreatedAt: Date;
scheduleItemUpdatedAt: Date;
waypoints: string;
};
export type GroupedAdModel = AdModel & {
schedule: ScheduleItemModel[];
waypoints: string;
};
/**
* Repository is used for retrieving/saving domain entities
* */
@ -63,7 +85,7 @@ export class AdRepository
AdEntity,
AdReadModel,
AdWriteModel,
AdUnsupportedWriteModel
AdWriteExtraModel
>
implements AdRepositoryPort
{
@ -86,4 +108,64 @@ export class AdRepository
}),
);
}
getCandidateAds = async (queryString: string): Promise<AdEntity[]> =>
this._toAdReadModels(
(await this.prismaRaw.$queryRawUnsafe(queryString)) as UngroupedAdModel[],
)
.map((adReadModel: AdReadModel) => {
if (this.mapper.toDomain) return this.mapper.toDomain(adReadModel);
})
.filter(
(adEntity: AdEntity | undefined) => adEntity !== undefined,
) as AdEntity[];
private _toAdReadModels = (
ungroupedAds: UngroupedAdModel[],
): AdReadModel[] => {
const groupedAdModels: GroupedAdModel[] = ungroupedAds.map(
(ungroupedAd: UngroupedAdModel) => ({
uuid: ungroupedAd.uuid,
driver: ungroupedAd.driver,
passenger: ungroupedAd.passenger,
frequency: ungroupedAd.frequency,
fromDate: ungroupedAd.fromDate,
toDate: ungroupedAd.toDate,
schedule: [
{
uuid: ungroupedAd.scheduleItemUuid,
day: ungroupedAd.day,
time: ungroupedAd.time,
margin: ungroupedAd.margin,
createdAt: ungroupedAd.scheduleItemCreatedAt,
updatedAt: ungroupedAd.scheduleItemUpdatedAt,
},
],
seatsProposed: ungroupedAd.seatsProposed,
seatsRequested: ungroupedAd.seatsRequested,
strict: ungroupedAd.strict,
driverDuration: ungroupedAd.driverDuration,
driverDistance: ungroupedAd.driverDistance,
passengerDuration: ungroupedAd.passengerDuration,
passengerDistance: ungroupedAd.passengerDistance,
fwdAzimuth: ungroupedAd.fwdAzimuth,
backAzimuth: ungroupedAd.backAzimuth,
waypoints: ungroupedAd.waypoints,
createdAt: ungroupedAd.createdAt,
updatedAt: ungroupedAd.updatedAt,
}),
);
const adReadModels: AdReadModel[] = [];
groupedAdModels.forEach((groupdeAdModel: GroupedAdModel) => {
const adReadModel: AdReadModel | undefined = adReadModels.find(
(arm: AdReadModel) => arm.uuid == groupdeAdModel.uuid,
);
if (adReadModel) {
adReadModel.schedule.push(...groupdeAdModel.schedule);
} else {
adReadModels.push(groupdeAdModel);
}
});
return adReadModels;
};
}

View File

@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DefaultParamsProviderPort } from '../core/application/ports/default-params-provider.port';
import { DefaultParams } from '../core/application/ports/default-params.type';
import { AlgorithmType } from '../core/application/types/algorithm.types';
const DRIVER = false;
const PASSENGER = true;
const SEATS_PROPOSED = 3;
const SEATS_REQUESTED = 1;
const DEPARTURE_TIME_MARGIN = 900;
const TIMEZONE = 'Europe/Paris';
const ALGORITHM_TYPE = 'PASSENGER_ORIENTED';
const REMOTENESS = 15000;
const USE_PROPORTION = true;
const PROPORTION = 0.3;
const USE_AZIMUTH = true;
const AZIMUTH_MARGIN = 10;
const MAX_DETOUR_DISTANCE_RATIO = 0.3;
const MAX_DETOUR_DURATION_RATIO = 0.3;
const PER_PAGE = 10;
@Injectable()
export class DefaultParamsProvider implements DefaultParamsProviderPort {
constructor(private readonly _configService: ConfigService) {}
getParams = (): DefaultParams => ({
DRIVER:
this._configService.get('ROLE') !== undefined
? this._configService.get('ROLE') == 'driver'
: DRIVER,
SEATS_PROPOSED:
this._configService.get('SEATS_PROPOSED') !== undefined
? parseInt(this._configService.get('SEATS_PROPOSED') as string)
: SEATS_PROPOSED,
PASSENGER:
this._configService.get('ROLE') !== undefined
? this._configService.get('ROLE') == 'passenger'
: PASSENGER,
SEATS_REQUESTED:
this._configService.get('SEATS_REQUESTED') !== undefined
? parseInt(this._configService.get('SEATS_REQUESTED') as string)
: SEATS_REQUESTED,
DEPARTURE_TIME_MARGIN:
this._configService.get('DEPARTURE_TIME_MARGIN') !== undefined
? parseInt(this._configService.get('DEPARTURE_TIME_MARGIN') as string)
: DEPARTURE_TIME_MARGIN,
STRICT: this._configService.get('STRICT_FREQUENCY') == 'true',
TIMEZONE: this._configService.get('TIMEZONE') ?? TIMEZONE,
ALGORITHM_TYPE:
AlgorithmType[
this._configService.get('ALGORITHM_TYPE') as AlgorithmType
] ?? AlgorithmType[ALGORITHM_TYPE],
REMOTENESS:
this._configService.get('REMOTENESS') !== undefined
? parseInt(this._configService.get('REMOTENESS') as string)
: REMOTENESS,
USE_PROPORTION:
this._configService.get('USE_PROPORTION') !== undefined
? this._configService.get('USE_PROPORTION') == 'true'
: USE_PROPORTION,
PROPORTION:
this._configService.get('PROPORTION') !== undefined
? parseFloat(this._configService.get('PROPORTION') as string)
: PROPORTION,
USE_AZIMUTH:
this._configService.get('USE_AZIMUTH') !== undefined
? this._configService.get('USE_AZIMUTH') == 'true'
: USE_AZIMUTH,
AZIMUTH_MARGIN:
this._configService.get('AZIMUTH_MARGIN') !== undefined
? parseInt(this._configService.get('AZIMUTH_MARGIN') as string)
: AZIMUTH_MARGIN,
MAX_DETOUR_DISTANCE_RATIO:
this._configService.get('MAX_DETOUR_DISTANCE_RATIO') !== undefined
? parseFloat(
this._configService.get('MAX_DETOUR_DISTANCE_RATIO') as string,
)
: MAX_DETOUR_DISTANCE_RATIO,
MAX_DETOUR_DURATION_RATIO:
this._configService.get('MAX_DETOUR_DURATION_RATIO') !== undefined
? parseFloat(
this._configService.get('MAX_DETOUR_DURATION_RATIO') as string,
)
: MAX_DETOUR_DURATION_RATIO,
PER_PAGE:
this._configService.get('PER_PAGE') !== undefined
? parseInt(this._configService.get('PER_PAGE') as string)
: PER_PAGE,
});
}

View File

@ -0,0 +1,132 @@
import { Inject, Injectable } from '@nestjs/common';
import {
DateTimeTransformerPort,
Frequency,
GeoDateTime,
} from '../core/application/ports/datetime-transformer.port';
import { TimeConverterPort } from '../core/application/ports/time-converter.port';
import {
PARAMS_PROVIDER,
TIMEZONE_FINDER,
TIME_CONVERTER,
} from '../ad.di-tokens';
import { TimezoneFinderPort } from '../core/application/ports/timezone-finder.port';
import { DefaultParamsProviderPort } from '../core/application/ports/default-params-provider.port';
@Injectable()
export class InputDateTimeTransformer implements DateTimeTransformerPort {
private readonly _defaultTimezone: string;
constructor(
@Inject(PARAMS_PROVIDER)
private readonly defaultParamsProvider: DefaultParamsProviderPort,
@Inject(TIMEZONE_FINDER)
private readonly timezoneFinder: TimezoneFinderPort,
@Inject(TIME_CONVERTER) private readonly timeConverter: TimeConverterPort,
) {
this._defaultTimezone = defaultParamsProvider.getParams().TIMEZONE;
}
/**
* Compute the fromDate : if an ad is punctual, the departure date
* is converted to UTC with the time and timezone
*/
fromDate = (geoFromDate: GeoDateTime, frequency: Frequency): string => {
if (frequency === Frequency.RECURRENT) return geoFromDate.date;
return this.timeConverter
.localStringDateTimeToUtcDate(
geoFromDate.date,
geoFromDate.time,
this.timezoneFinder.timezones(
geoFromDate.coordinates.lon,
geoFromDate.coordinates.lat,
this._defaultTimezone,
)[0],
)
.toISOString()
.split('T')[0];
};
/**
* Get the toDate depending on frequency, time and timezone :
* if the ad is punctual, the toDate is equal to the fromDate
*/
toDate = (
toDate: string,
geoFromDate: GeoDateTime,
frequency: Frequency,
): string => {
if (frequency === Frequency.RECURRENT) return toDate;
return this.fromDate(geoFromDate, frequency);
};
/**
* Get the day for a schedule item :
* - if the ad is punctual, the day is infered from fromDate
* - if the ad is recurrent, the day is computed by converting the time to utc
*/
day = (
day: number,
geoFromDate: GeoDateTime,
frequency: Frequency,
): number => {
if (frequency === Frequency.RECURRENT)
return this.recurrentDay(
day,
geoFromDate.time,
this.timezoneFinder.timezones(
geoFromDate.coordinates.lon,
geoFromDate.coordinates.lat,
this._defaultTimezone,
)[0],
);
return new Date(this.fromDate(geoFromDate, frequency)).getUTCDay();
};
/**
* Get the utc time
*/
time = (geoFromDate: GeoDateTime, frequency: Frequency): string => {
if (frequency === Frequency.RECURRENT)
return this.timeConverter.localStringTimeToUtcStringTime(
geoFromDate.time,
this.timezoneFinder.timezones(
geoFromDate.coordinates.lon,
geoFromDate.coordinates.lat,
this._defaultTimezone,
)[0],
);
return this.timeConverter
.localStringDateTimeToUtcDate(
geoFromDate.date,
geoFromDate.time,
this.timezoneFinder.timezones(
geoFromDate.coordinates.lon,
geoFromDate.coordinates.lat,
this._defaultTimezone,
)[0],
)
.toISOString()
.split('T')[1]
.split(':', 2)
.join(':');
};
/**
* Get the day for a schedule item for a recurrent ad
* The day may change when transforming from local timezone to utc
*/
private recurrentDay = (
day: number,
time: string,
timezone: string,
): number => {
const unixEpochDay = 4; // 1970-01-01 is a thursday !
const utcBaseDay = this.timeConverter.utcUnixEpochDayFromTime(
time,
timezone,
);
if (unixEpochDay == utcBaseDay) return day;
if (unixEpochDay > utcBaseDay) return day > 0 ? day - 1 : 6;
return day < 6 ? day + 1 : 0;
};
}

View File

@ -0,0 +1,46 @@
import { InjectRedis } from '@liaoliaots/nestjs-redis';
import { MatchingRepositoryPort } from '../core/application/ports/matching.repository.port';
import { MatchingEntity } from '../core/domain/matching.entity';
import { Redis } from 'ioredis';
import { MatchingMapper } from '../matching.mapper';
import { ConfigService } from '@nestjs/config';
import { MatchingNotFoundException } from '../core/domain/matching.errors';
const REDIS_MATCHING_TTL = 900;
const REDIS_MATCHING_KEY = 'MATCHER:MATCHING';
export class MatchingRepository implements MatchingRepositoryPort {
private _redisKey: string;
private _redisTtl: number;
constructor(
@InjectRedis() private readonly redis: Redis,
private readonly configService: ConfigService,
private readonly mapper: MatchingMapper,
) {
this._redisKey =
this.configService.get('REDIS_MATCHING_KEY') !== undefined
? (this.configService.get('REDIS_MATCHING_KEY') as string)
: REDIS_MATCHING_KEY;
this._redisTtl =
this.configService.get('REDIS_MATCHING_TTL') !== undefined
? (this.configService.get('REDIS_MATCHING_TTL') as number)
: REDIS_MATCHING_TTL;
}
get = async (matchingId: string): Promise<MatchingEntity> => {
const matching: string | null = await this.redis.get(
`${this._redisKey}:${matchingId}`,
);
if (matching) return this.mapper.toDomain(matching);
throw new MatchingNotFoundException(new Error('Matching not found'));
};
save = async (matching: MatchingEntity): Promise<void> => {
await this.redis.set(
`${this._redisKey}:${matching.id}`,
this.mapper.toPersistence(matching),
'EX',
this._redisTtl,
);
};
}

View File

@ -0,0 +1,116 @@
import { Inject, Injectable } from '@nestjs/common';
import {
DateTimeTransformerPort,
Frequency,
GeoDateTime,
} from '../core/application/ports/datetime-transformer.port';
import { TimeConverterPort } from '../core/application/ports/time-converter.port';
import { TIMEZONE_FINDER, TIME_CONVERTER } from '../ad.di-tokens';
import { TimezoneFinderPort } from '../core/application/ports/timezone-finder.port';
@Injectable()
export class OutputDateTimeTransformer implements DateTimeTransformerPort {
constructor(
@Inject(TIMEZONE_FINDER)
private readonly timezoneFinder: TimezoneFinderPort,
@Inject(TIME_CONVERTER) private readonly timeConverter: TimeConverterPort,
) {}
/**
* Compute the fromDate : if an ad is punctual, the departure date
* is converted from UTC to the local date with the time and timezone
*/
fromDate = (geoFromDate: GeoDateTime, frequency: Frequency): string => {
if (frequency === Frequency.RECURRENT) return geoFromDate.date;
return this.timeConverter
.utcStringDateTimeToLocalIsoString(
geoFromDate.date,
geoFromDate.time,
this.timezoneFinder.timezones(
geoFromDate.coordinates.lon,
geoFromDate.coordinates.lat,
)[0],
)
.split('T')[0];
};
/**
* Get the toDate depending on frequency, time and timezone :
* if the ad is punctual, the toDate is equal to the fromDate
*/
toDate = (
toDate: string,
geoFromDate: GeoDateTime,
frequency: Frequency,
): string => {
if (frequency === Frequency.RECURRENT) return toDate;
return this.fromDate(geoFromDate, frequency);
};
/**
* Get the day for a schedule item :
* - if the ad is punctual, the day is infered from fromDate
* - if the ad is recurrent, the day is computed by converting the time from utc to local time
*/
day = (
day: number,
geoFromDate: GeoDateTime,
frequency: Frequency,
): number => {
if (frequency === Frequency.RECURRENT)
return this.recurrentDay(
day,
geoFromDate.time,
this.timezoneFinder.timezones(
geoFromDate.coordinates.lon,
geoFromDate.coordinates.lat,
)[0],
);
return new Date(this.fromDate(geoFromDate, frequency)).getDay();
};
/**
* Get the utc time
*/
time = (geoFromDate: GeoDateTime, frequency: Frequency): string => {
if (frequency === Frequency.RECURRENT)
return this.timeConverter.utcStringTimeToLocalStringTime(
geoFromDate.time,
this.timezoneFinder.timezones(
geoFromDate.coordinates.lon,
geoFromDate.coordinates.lat,
)[0],
);
return this.timeConverter
.utcStringDateTimeToLocalIsoString(
geoFromDate.date,
geoFromDate.time,
this.timezoneFinder.timezones(
geoFromDate.coordinates.lon,
geoFromDate.coordinates.lat,
)[0],
)
.split('T')[1]
.split(':', 2)
.join(':');
};
/**
* Get the day for a schedule item for a recurrent ad
* The day may change when transforming from utc to local timezone
*/
private recurrentDay = (
day: number,
time: string,
timezone: string,
): number => {
const unixEpochDay = 4; // 1970-01-01 is a thursday !
const localBaseDay = this.timeConverter.localUnixEpochDayFromTime(
time,
timezone,
);
if (unixEpochDay == localBaseDay) return day;
if (unixEpochDay > localBaseDay) return day > 0 ? day - 1 : 6;
return day < 6 ? day + 1 : 0;
};
}

View File

@ -1,21 +1,28 @@
import { Inject, Injectable } from '@nestjs/common';
import { RouteProviderPort } from '../core/application/ports/route-provider.port';
import { Route } from '../core/application/types/route.type';
import { Waypoint } from '../core/application/types/waypoint.type';
import { Role } from '../core/domain/ad.types';
import { GetBasicRouteControllerPort } from '@modules/geography/core/application/ports/get-basic-route-controller.port';
import { AD_GET_BASIC_ROUTE_CONTROLLER } from '../ad.di-tokens';
import { GetRouteControllerPort } from '@modules/geography/core/application/ports/get-route-controller.port';
import {
AD_GET_BASIC_ROUTE_CONTROLLER,
AD_GET_DETAILED_ROUTE_CONTROLLER,
} from '../ad.di-tokens';
import { Point, Route } from '@modules/geography/core/domain/route.types';
@Injectable()
export class RouteProvider implements RouteProviderPort {
constructor(
@Inject(AD_GET_BASIC_ROUTE_CONTROLLER)
private readonly getBasicRouteController: GetBasicRouteControllerPort,
private readonly getBasicRouteController: GetRouteControllerPort,
@Inject(AD_GET_DETAILED_ROUTE_CONTROLLER)
private readonly getDetailedRouteController: GetRouteControllerPort,
) {}
getBasic = async (roles: Role[], waypoints: Waypoint[]): Promise<Route> =>
getBasic = async (waypoints: Point[]): Promise<Route> =>
await this.getBasicRouteController.get({
roles,
waypoints,
});
getDetailed = async (waypoints: Point[]): Promise<Route> =>
await this.getDetailedRouteController.get({
waypoints,
});
}

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { DateTime, TimeZone } from 'timezonecomplete';
import { TimeConverterPort } from '../core/application/ports/time-converter.port';
@Injectable()
export class TimeConverter implements TimeConverterPort {
private readonly UNIX_EPOCH = '1970-01-01';
localStringTimeToUtcStringTime = (time: string, timezone: string): string =>
new DateTime(`${this.UNIX_EPOCH}T${time}`, TimeZone.zone(timezone))
.convert(TimeZone.zone('UTC'))
.format('HH:mm');
utcStringTimeToLocalStringTime = (time: string, timezone: string): string =>
new DateTime(`${this.UNIX_EPOCH}T${time}`, TimeZone.zone('UTC'))
.convert(TimeZone.zone(timezone))
.format('HH:mm');
localStringDateTimeToUtcDate = (
date: string,
time: string,
timezone: string,
dst = false,
): Date =>
new Date(
new DateTime(
`${date}T${time}`,
TimeZone.zone(timezone, dst),
).toIsoString(),
);
utcStringDateTimeToLocalIsoString = (
date: string,
time: string,
timezone: string,
dst = false,
): string =>
new DateTime(`${date}T${time}`, TimeZone.zone('UTC'))
.convert(TimeZone.zone(timezone, dst))
.toIsoString();
utcUnixEpochDayFromTime = (time: string, timezone: string): number =>
new Date(
new DateTime(`${this.UNIX_EPOCH}T${time}`, TimeZone.zone(timezone, false))
.convert(TimeZone.zone('UTC'))
.toIsoString()
.split('T')[0],
).getUTCDay();
localUnixEpochDayFromTime = (time: string, timezone: string): number =>
new Date(
new DateTime(`${this.UNIX_EPOCH}T${time}`, TimeZone.zone('UTC'))
.convert(TimeZone.zone(timezone))
.toIsoString()
.split('T')[0],
).getUTCDay();
}

View File

@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { find } from 'geo-tz';
import { TimezoneFinderPort } from '../core/application/ports/timezone-finder.port';
@Injectable()
export class TimezoneFinder implements TimezoneFinderPort {
timezones = (
lon: number,
lat: number,
defaultTimezone?: string,
): string[] => {
const foundTimezones = find(lat, lon);
if (defaultTimezone && foundTimezones.length == 0) return [defaultTimezone];
return foundTimezones;
};
}

View File

@ -0,0 +1,4 @@
export class ActorResponseDto {
role: string;
target: string;
}

View File

@ -0,0 +1,7 @@
import { PaginatedResponseDto } from '@mobicoop/ddd-library';
export abstract class IdPaginatedResponseDto<
T,
> extends PaginatedResponseDto<T> {
readonly id: string;
}

View File

@ -0,0 +1,8 @@
import { StepResponseDto } from './step.response.dto';
export class JourneyResponseDto {
day: number;
firstDate: string;
lastDate: string;
steps: StepResponseDto[];
}

View File

@ -0,0 +1,17 @@
import { ResponseBase } from '@mobicoop/ddd-library';
import { JourneyResponseDto } from './journey.response.dto';
export class MatchResponseDto extends ResponseBase {
adId: string;
role: string;
frequency: string;
distance: number;
duration: number;
initialDistance: number;
initialDuration: number;
distanceDetour: number;
durationDetour: number;
distanceDetourPercentage: number;
durationDetourPercentage: number;
journeys: JourneyResponseDto[];
}

View File

@ -0,0 +1,11 @@
import { MatchResponseDto } from './match.response.dto';
import { IdPaginatedResponseDto } from './id-paginated.reponse.dto';
export class MatchingPaginatedResponseDto extends IdPaginatedResponseDto<MatchResponseDto> {
readonly id: string;
readonly data: readonly MatchResponseDto[];
constructor(props: IdPaginatedResponseDto<MatchResponseDto>) {
super(props);
this.id = props.id;
}
}

View File

@ -0,0 +1,10 @@
import { ActorResponseDto } from './actor.response.dto';
export class StepResponseDto {
distance: number;
duration: number;
lon: number;
lat: number;
time: string;
actors: ActorResponseDto[];
}

View File

@ -0,0 +1,28 @@
import { IsOptional, IsString } from 'class-validator';
import { CoordinatesDto as CoordinatesDto } from './coordinates.dto';
export class AddressDto extends CoordinatesDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
houseNumber?: string;
@IsOptional()
@IsString()
street?: string;
@IsOptional()
@IsString()
locality?: string;
@IsOptional()
@IsString()
postalCode?: string;
@IsOptional()
@IsString()
country?: string;
}

View File

@ -0,0 +1,9 @@
import { IsLatitude, IsLongitude } from 'class-validator';
export class CoordinatesDto {
@IsLongitude()
lon: number;
@IsLatitude()
lat: number;
}

View File

@ -0,0 +1,130 @@
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDecimal,
IsEnum,
IsISO8601,
IsInt,
IsOptional,
IsUUID,
Max,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { HasDay } from './validators/decorators/has-day.decorator';
import { IsAfterOrEqual } from './validators/decorators/is-after-or-equal.decorator';
import { ScheduleItemDto } from './schedule-item.dto';
import { WaypointDto } from './waypoint.dto';
import { HasValidPositionIndexes } from './validators/decorators/has-valid-position-indexes.decorator';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types';
export class MatchRequestDto {
@IsUUID()
@IsOptional()
id?: string;
@IsOptional()
@IsBoolean()
driver?: boolean;
@IsOptional()
@IsBoolean()
passenger?: boolean;
@IsEnum(Frequency)
@HasDay('schedule', {
message: 'At least a day is required for a recurrent ad',
})
frequency: Frequency;
@IsISO8601({
strict: true,
strictSeparator: true,
})
fromDate: string;
@IsISO8601({
strict: true,
strictSeparator: true,
})
@IsAfterOrEqual('fromDate', {
message: 'toDate must be after or equal to fromDate',
})
toDate: string;
@Type(() => ScheduleItemDto)
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
schedule: ScheduleItemDto[];
@IsOptional()
@IsInt()
seatsProposed?: number;
@IsOptional()
@IsInt()
seatsRequested?: number;
@IsOptional()
@IsBoolean()
strict?: boolean;
@Type(() => WaypointDto)
@IsArray()
@ArrayMinSize(2)
@HasValidPositionIndexes()
@ValidateNested({ each: true })
waypoints: WaypointDto[];
@IsOptional()
@IsEnum(AlgorithmType)
algorithmType?: AlgorithmType;
@IsOptional()
@IsInt()
remoteness?: number;
@IsOptional()
@IsBoolean()
useProportion?: boolean;
@IsOptional()
@IsDecimal()
@Min(0)
@Max(1)
proportion?: number;
@IsOptional()
@IsBoolean()
useAzimuth?: boolean;
@IsOptional()
@IsInt()
@Min(0)
@Max(359)
azimuthMargin?: number;
@IsOptional()
@IsDecimal()
@Min(0)
@Max(1)
maxDetourDistanceRatio?: number;
@IsOptional()
@IsDecimal()
@Min(0)
@Max(1)
maxDetourDurationRatio?: number;
@IsOptional()
@IsInt()
page?: number;
@IsOptional()
@IsInt()
perPage?: number;
}

View File

@ -0,0 +1,16 @@
import { IsMilitaryTime, IsInt, Min, Max, IsOptional } from 'class-validator';
export class ScheduleItemDto {
@IsOptional()
@IsInt()
@Min(0)
@Max(6)
day?: number;
@IsMilitaryTime()
time: string;
@IsOptional()
@IsInt()
margin?: number;
}

View File

@ -0,0 +1,34 @@
import { Frequency } from '@modules/ad/core/domain/ad.types';
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';
export function HasDay(
property: string,
validationOptions?: ValidationOptions,
) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'hasDay',
target: object.constructor,
propertyName: propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
return (
value == Frequency.PUNCTUAL ||
(Array.isArray(relatedValue) &&
relatedValue.some((scheduleItem) =>
scheduleItem.hasOwnProperty('day'),
))
);
},
},
});
};
}

View File

@ -0,0 +1,22 @@
import { ValidateBy, ValidationOptions, buildMessage } from 'class-validator';
import { hasValidPositionIndexes } from '../has-valid-position-indexes.validator';
import { WaypointDto } from '../../waypoint.dto';
export const HasValidPositionIndexes = (
validationOptions?: ValidationOptions,
): PropertyDecorator =>
ValidateBy(
{
name: '',
constraints: [],
validator: {
validate: (waypoints: WaypointDto[]): boolean =>
hasValidPositionIndexes(waypoints),
defaultMessage: buildMessage(
() => `invalid waypoints positions`,
validationOptions,
),
},
},
validationOptions,
);

View File

@ -0,0 +1,43 @@
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
isISO8601,
} from 'class-validator';
export function IsAfterOrEqual(
property: string,
validationOptions?: ValidationOptions,
) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isAfterOrEqual',
target: object.constructor,
propertyName: propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
if (
!(
typeof value === 'string' &&
typeof relatedValue === 'string' &&
isISO8601(value, {
strict: true,
strictSeparator: true,
}) &&
isISO8601(relatedValue, {
strict: true,
strictSeparator: true,
})
)
)
return false;
return new Date(value) >= new Date(relatedValue);
},
},
});
};
}

View File

@ -0,0 +1,11 @@
import { WaypointDto } from '../waypoint.dto';
export const hasValidPositionIndexes = (waypoints: WaypointDto[]): boolean => {
if (waypoints.length == 0) return false;
const positions = Array.from(waypoints, (waypoint) => waypoint.position);
positions.sort();
for (let i = 1; i < positions.length; i++)
if (positions[i] != positions[i - 1] + 1) return false;
return true;
};

View File

@ -0,0 +1,7 @@
import { IsInt } from 'class-validator';
import { AddressDto } from './address.dto';
export class WaypointDto extends AddressDto {
@IsInt()
position: number;
}

View File

@ -0,0 +1,52 @@
import { Controller, Inject, UsePipes } from '@nestjs/common';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { MatchingPaginatedResponseDto } from '../dtos/matching.paginated.response.dto';
import { QueryBus } from '@nestjs/cqrs';
import { MatchRequestDto } from './dtos/match.request.dto';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import { MatchEntity } from '@modules/ad/core/domain/match.entity';
import { AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
import { MatchMapper } from '@modules/ad/match.mapper';
import { MatchingResult } from '@modules/ad/core/application/queries/match/match.query-handler';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class MatchGrpcController {
constructor(
private readonly queryBus: QueryBus,
@Inject(AD_ROUTE_PROVIDER)
private readonly routeProvider: RouteProviderPort,
private readonly matchMapper: MatchMapper,
) {}
@GrpcMethod('MatcherService', 'Match')
async match(data: MatchRequestDto): Promise<MatchingPaginatedResponseDto> {
try {
const matchingResult: MatchingResult = await this.queryBus.execute(
new MatchQuery(data, this.routeProvider),
);
return new MatchingPaginatedResponseDto({
id: matchingResult.id,
data: matchingResult.matches.map((match: MatchEntity) =>
this.matchMapper.toResponse(match),
),
page: matchingResult.page,
perPage: matchingResult.perPage,
total: matchingResult.total,
});
} catch (e) {
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: e.message,
});
}
}
}

View File

@ -0,0 +1,101 @@
syntax = "proto3";
package matcher;
service MatcherService {
rpc Match(MatchRequest) returns (Matches);
}
message MatchRequest {
string id = 1;
bool driver = 2;
bool passenger = 3;
Frequency frequency = 4;
string fromDate = 5;
string toDate = 6;
repeated ScheduleItem schedule = 7;
bool strict = 8;
repeated Waypoint waypoints = 9;
AlgorithmType algorithmType = 10;
int32 remoteness = 11;
bool useProportion = 12;
int32 proportion = 13;
bool useAzimuth = 14;
int32 azimuthMargin = 15;
float maxDetourDistanceRatio = 16;
float maxDetourDurationRatio = 17;
int32 identifier = 18;
optional int32 page = 19;
optional int32 perPage = 20;
}
message ScheduleItem {
int32 day = 1;
string time = 2;
int32 margin = 3;
}
message Waypoint {
int32 position = 1;
double lon = 2;
double lat = 3;
string name = 4;
string houseNumber = 5;
string street = 6;
string locality = 7;
string postalCode = 8;
string country = 9;
}
enum Frequency {
PUNCTUAL = 1;
RECURRENT = 2;
}
enum AlgorithmType {
PASSENGER_ORIENTED = 0;
}
message Match {
string id = 1;
string adId = 2;
string role = 3;
int32 distance = 4;
int32 duration = 5;
int32 initialDistance = 6;
int32 initialDuration = 7;
int32 distanceDetour = 8;
int32 durationDetour = 9;
double distanceDetourPercentage = 10;
double durationDetourPercentage = 11;
repeated Journey journeys = 12;
}
message Journey {
int32 day = 1;
string firstDate = 2;
string lastDate = 3;
repeated Step steps = 4;
}
message Step {
int32 distance = 1;
int32 duration = 2;
double lon = 3;
double lat = 4;
string time = 5;
repeated Actor actors = 6;
}
message Actor {
string role = 1;
string target = 2;
}
message Matches {
string id = 1;
repeated Match data = 2;
int32 total = 3;
int32 page = 4;
int32 perPage = 5;
}

View File

@ -16,7 +16,7 @@ export class AdCreatedMessageHandler {
const createdAd: Ad = JSON.parse(message);
await this.commandBus.execute(
new CreateAdCommand({
id: createdAd.id,
id: createdAd.aggregateId,
driver: createdAd.driver,
passenger: createdAd.passenger,
frequency: createdAd.frequency,

View File

@ -1,8 +1,7 @@
import { Frequency } from '@modules/ad/core/domain/ad.types';
export type Ad = {
id: string;
userId: string;
aggregateId: string;
driver: boolean;
passenger: boolean;
frequency: Frequency;

View File

@ -0,0 +1,80 @@
import { Inject, Injectable } from '@nestjs/common';
import { MatchEntity } from './core/domain/match.entity';
import { MatchResponseDto } from './interface/dtos/match.response.dto';
import { ResponseBase } from '@mobicoop/ddd-library';
import { Journey } from './core/domain/value-objects/journey.value-object';
import { JourneyItem } from './core/domain/value-objects/journey-item.value-object';
import { ActorTime } from './core/domain/value-objects/actor-time.value-object';
import { OUTPUT_DATETIME_TRANSFORMER } from './ad.di-tokens';
import { DateTimeTransformerPort } from './core/application/ports/datetime-transformer.port';
@Injectable()
export class MatchMapper {
constructor(
@Inject(OUTPUT_DATETIME_TRANSFORMER)
private readonly outputDatetimeTransformer: DateTimeTransformerPort,
) {}
toResponse = (match: MatchEntity): MatchResponseDto => {
return {
...new ResponseBase(match),
adId: match.getProps().adId,
role: match.getProps().role,
frequency: match.getProps().frequency,
distance: match.getProps().distance,
duration: match.getProps().duration,
initialDistance: match.getProps().initialDistance,
initialDuration: match.getProps().initialDuration,
distanceDetour: match.getProps().distanceDetour,
durationDetour: match.getProps().durationDetour,
distanceDetourPercentage: match.getProps().distanceDetourPercentage,
durationDetourPercentage: match.getProps().durationDetourPercentage,
journeys: match.getProps().journeys.map((journey: Journey) => ({
day: new Date(
this.outputDatetimeTransformer.fromDate(
{
date: journey.firstDate.toISOString().split('T')[0],
time: journey.firstDriverDepartureTime(),
coordinates: journey.driverOrigin(),
},
match.getProps().frequency,
),
).getDay(),
firstDate: this.outputDatetimeTransformer.fromDate(
{
date: journey.firstDate.toISOString().split('T')[0],
time: journey.firstDriverDepartureTime(),
coordinates: journey.driverOrigin(),
},
match.getProps().frequency,
),
lastDate: this.outputDatetimeTransformer.fromDate(
{
date: journey.lastDate.toISOString().split('T')[0],
time: journey.firstDriverDepartureTime(),
coordinates: journey.driverOrigin(),
},
match.getProps().frequency,
),
steps: journey.journeyItems.map((journeyItem: JourneyItem) => ({
duration: journeyItem.duration,
distance: journeyItem.distance as number,
lon: journeyItem.lon,
lat: journeyItem.lat,
time: this.outputDatetimeTransformer.time(
{
date: journey.firstDate.toISOString().split('T')[0],
time: journeyItem.driverTime(),
coordinates: journey.driverOrigin(),
},
match.getProps().frequency,
),
actors: journeyItem.actorTimes.map((actorTime: ActorTime) => ({
role: actorTime.role,
target: actorTime.target,
})),
})),
})),
};
};
}

View File

@ -0,0 +1,234 @@
import { Injectable } from '@nestjs/common';
import { Mapper } from '@mobicoop/ddd-library';
import { MatchingEntity } from './core/domain/matching.entity';
import { Frequency, Role } from './core/domain/ad.types';
import { MatchEntity } from './core/domain/match.entity';
import { Target } from './core/domain/candidate.types';
import { Waypoint } from './core/application/types/waypoint.type';
import { ScheduleItem } from './core/application/types/schedule-item.type';
import { Journey } from './core/domain/value-objects/journey.value-object';
import { JourneyItem } from './core/domain/value-objects/journey-item.value-object';
import { ActorTime } from './core/domain/value-objects/actor-time.value-object';
@Injectable()
export class MatchingMapper
implements Mapper<MatchingEntity, string, string, undefined>
{
toPersistence = (entity: MatchingEntity): string =>
JSON.stringify(<PersistedMatching>{
id: entity.id,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
matches: entity.getProps().matches.map((match: MatchEntity) => ({
adId: match.getProps().adId,
role: match.getProps().role,
frequency: match.getProps().frequency,
distance: match.getProps().distance,
duration: match.getProps().duration,
initialDistance: match.getProps().initialDistance,
initialDuration: match.getProps().initialDuration,
distanceDetour: match.getProps().distanceDetour,
durationDetour: match.getProps().durationDetour,
distanceDetourPercentage: match.getProps().distanceDetourPercentage,
durationDetourPercentage: match.getProps().durationDetourPercentage,
journeys: match.getProps().journeys.map((journey: Journey) => ({
firstDate: journey.firstDate.toISOString(),
lastDate: journey.lastDate.toISOString(),
journeyItems: journey.journeyItems.map(
(journeyItem: JourneyItem) => ({
lon: journeyItem.lon,
lat: journeyItem.lat,
duration: journeyItem.duration,
distance: journeyItem.distance,
actorTimes: journeyItem.actorTimes.map(
(actorTime: ActorTime) => ({
role: actorTime.role,
target: actorTime.target,
firstDatetime: actorTime.firstDatetime.toISOString(),
firstMinDatetime: actorTime.firstMinDatetime.toISOString(),
firstMaxDatetime: actorTime.firstMaxDatetime.toISOString(),
lastDatetime: actorTime.lastDatetime.toISOString(),
lastMinDatetime: actorTime.lastMinDatetime.toISOString(),
lastMaxDatetime: actorTime.lastMaxDatetime.toISOString(),
}),
),
}),
),
})),
})),
query: {
driver: entity.getProps().query.driver,
passenger: entity.getProps().query.passenger,
frequency: entity.getProps().query.frequency,
fromDate: entity.getProps().query.fromDate,
toDate: entity.getProps().query.toDate,
schedule: entity
.getProps()
.query.schedule.map((scheduleItem: ScheduleItem) => ({
day: scheduleItem.day,
time: scheduleItem.time,
margin: scheduleItem.margin,
})),
seatsProposed: entity.getProps().query.seatsProposed,
seatsRequested: entity.getProps().query.seatsRequested,
strict: entity.getProps().query.strict,
waypoints: entity
.getProps()
.query.waypoints.map((waypoint: Waypoint) => ({
lon: waypoint.lon,
lat: waypoint.lat,
position: waypoint.position,
houseNumber: waypoint.houseNumber,
street: waypoint.street,
postalCode: waypoint.postalCode,
locality: waypoint.locality,
country: waypoint.country,
})),
algorithmType: entity.getProps().query.algorithmType,
remoteness: entity.getProps().query.remoteness,
useProportion: entity.getProps().query.useProportion,
proportion: entity.getProps().query.proportion,
useAzimuth: entity.getProps().query.useAzimuth,
azimuthMargin: entity.getProps().query.azimuthMargin,
maxDetourDistanceRatio: entity.getProps().query.maxDetourDistanceRatio,
maxDetourDurationRatio: entity.getProps().query.maxDetourDurationRatio,
},
});
toDomain = (record: string): MatchingEntity => {
const parsedRecord: PersistedMatching = JSON.parse(record);
const matchingEntity: MatchingEntity = new MatchingEntity({
id: parsedRecord.id,
createdAt: new Date(parsedRecord.createdAt),
updatedAt: new Date(parsedRecord.updatedAt),
props: {
query: parsedRecord.query,
matches: parsedRecord.matches.map((match: PersistedMatch) =>
MatchEntity.create({
adId: match.adId,
role: match.role,
frequency: match.frequency,
distance: match.distance,
duration: match.duration,
initialDistance: match.initialDistance,
initialDuration: match.initialDuration,
journeys: match.journeys.map(
(journey: PersistedJourney) =>
new Journey({
firstDate: new Date(journey.firstDate),
lastDate: new Date(journey.lastDate),
journeyItems: journey.journeyItems.map(
(journeyItem: PersistedJourneyItem) =>
new JourneyItem({
lon: journeyItem.lon,
lat: journeyItem.lat,
duration: journeyItem.duration,
distance: journeyItem.distance,
actorTimes: journeyItem.actorTimes.map(
(actorTime: PersistedActorTime) =>
new ActorTime({
role: actorTime.role,
target: actorTime.target,
firstDatetime: new Date(actorTime.firstDatetime),
firstMinDatetime: new Date(
actorTime.firstMinDatetime,
),
firstMaxDatetime: new Date(
actorTime.firstMaxDatetime,
),
lastDatetime: new Date(actorTime.lastDatetime),
lastMinDatetime: new Date(
actorTime.lastMinDatetime,
),
lastMaxDatetime: new Date(
actorTime.lastMaxDatetime,
),
}),
),
}),
),
}),
),
}),
),
},
});
return matchingEntity;
};
}
type PersistedMatching = {
id: string;
createdAt: string;
updatedAt: string;
matches: PersistedMatch[];
query: {
driver: boolean;
passenger: boolean;
frequency: Frequency;
fromDate: string;
toDate: string;
schedule: {
day: number;
time: string;
margin: number;
}[];
seatsProposed: number;
seatsRequested: number;
strict: boolean;
waypoints: {
houseNumber: string;
street: string;
postalCode: string;
locality: string;
lon: number;
lat: number;
country: string;
position: number;
}[];
algorithmType: string;
remoteness: number;
useProportion: boolean;
proportion: number;
useAzimuth: boolean;
azimuthMargin: number;
maxDetourDistanceRatio: number;
maxDetourDurationRatio: number;
};
};
type PersistedMatch = {
adId: string;
role: Role;
frequency: Frequency;
distance: number;
duration: number;
initialDistance: number;
initialDuration: number;
journeys: PersistedJourney[];
};
type PersistedJourney = {
firstDate: string;
lastDate: string;
journeyItems: PersistedJourneyItem[];
};
type PersistedJourneyItem = {
lon: number;
lat: number;
duration: number;
distance: number;
actorTimes: PersistedActorTime[];
};
type PersistedActorTime = {
role: Role;
target: Target;
firstDatetime: string;
firstMinDatetime: string;
firstMaxDatetime: string;
lastDatetime: string;
lastMinDatetime: string;
lastMaxDatetime: string;
};

View File

@ -89,12 +89,10 @@ describe('Ad Repository', () => {
strict: false,
waypoints: [
{
position: 0,
lon: 43.7102,
lat: 7.262,
},
{
position: 1,
lon: 43.2965,
lat: 5.3698,
},
@ -126,7 +124,7 @@ describe('Ad Repository', () => {
};
const adToCreate: AdEntity = AdEntity.create(createAdProps);
await adRepository.insertWithUnsupportedFields(adToCreate, 'ad');
await adRepository.insertExtra(adToCreate, 'ad');
const afterCount = await prismaService.ad.count();
@ -175,12 +173,10 @@ describe('Ad Repository', () => {
strict: false,
waypoints: [
{
position: 0,
lon: 43.7102,
lat: 7.262,
},
{
position: 1,
lon: 43.2965,
lat: 5.3698,
},
@ -212,7 +208,7 @@ describe('Ad Repository', () => {
};
const adToCreate: AdEntity = AdEntity.create(createAdProps);
await adRepository.insertWithUnsupportedFields(adToCreate, 'ad');
await adRepository.insertExtra(adToCreate, 'ad');
const afterCount = await prismaService.ad.count();

View File

@ -4,7 +4,7 @@ import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import {
AdReadModel,
AdUnsupportedWriteModel,
AdWriteExtraModel,
AdWriteModel,
} from '@modules/ad/infrastructure/ad.repository';
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
@ -28,12 +28,10 @@ const adEntity: AdEntity = new AdEntity({
],
waypoints: [
{
position: 0,
lat: 48.689445,
lon: 6.1765102,
},
{
position: 1,
lat: 48.8566,
lon: 2.3522,
},
@ -84,8 +82,6 @@ const adReadModel: AdReadModel = {
},
],
waypoints: "'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'",
direction:
"'LINESTRING(6.1765102 48.689445,5.12345 48.76543,2.3522 48.8566)'",
driverDistance: 350000,
driverDuration: 14400,
passengerDistance: 350000,
@ -149,8 +145,7 @@ describe('Ad Mapper', () => {
});
it('should map domain entity to unsupported db persistence data', async () => {
const mapped: AdUnsupportedWriteModel =
adMapper.toUnsupportedPersistence(adEntity);
const mapped: AdWriteExtraModel = adMapper.toPersistenceExtra(adEntity);
expect(mapped.waypoints).toBe(
"'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'",
);
@ -165,8 +160,4 @@ describe('Ad Mapper', () => {
expect(mapped.getProps().schedule[0].time).toBe('07:05');
expect(mapped.getProps().waypoints.length).toBe(2);
});
it('should map domain entity to response', async () => {
expect(adMapper.toResponse(adEntity)).toBeUndefined();
});
});

View File

@ -0,0 +1,107 @@
import { ArgumentInvalidException } from '@mobicoop/ddd-library';
import { Role } from '@modules/ad/core/domain/ad.types';
import { Target } from '@modules/ad/core/domain/candidate.types';
import { ActorTime } from '@modules/ad/core/domain/value-objects/actor-time.value-object';
describe('Actor time value object', () => {
it('should create an actor time value object', () => {
const actorTimeVO = new ActorTime({
role: Role.DRIVER,
target: Target.START,
firstDatetime: new Date('2023-09-01T07:00Z'),
firstMinDatetime: new Date('2023-09-01T06:45Z'),
firstMaxDatetime: new Date('2023-09-01T07:15Z'),
lastDatetime: new Date('2024-08-30T07:00Z'),
lastMinDatetime: new Date('2024-08-30T06:45Z'),
lastMaxDatetime: new Date('2024-08-30T07:15Z'),
});
expect(actorTimeVO.role).toBe(Role.DRIVER);
expect(actorTimeVO.target).toBe(Target.START);
expect(actorTimeVO.firstDatetime.getUTCHours()).toBe(7);
expect(actorTimeVO.firstMinDatetime.getUTCMinutes()).toBe(45);
expect(actorTimeVO.firstMaxDatetime.getUTCMinutes()).toBe(15);
expect(actorTimeVO.lastDatetime.getUTCHours()).toBe(7);
expect(actorTimeVO.lastMinDatetime.getUTCMinutes()).toBe(45);
expect(actorTimeVO.lastMaxDatetime.getUTCMinutes()).toBe(15);
});
it('should throw an error if dates are inconsistent', () => {
expect(
() =>
new ActorTime({
role: Role.DRIVER,
target: Target.START,
firstDatetime: new Date('2023-09-01T07:00Z'),
firstMinDatetime: new Date('2023-09-01T07:05Z'),
firstMaxDatetime: new Date('2023-09-01T07:15Z'),
lastDatetime: new Date('2024-08-30T07:00Z'),
lastMinDatetime: new Date('2024-08-30T06:45Z'),
lastMaxDatetime: new Date('2024-08-30T07:15Z'),
}),
).toThrow(ArgumentInvalidException);
expect(
() =>
new ActorTime({
role: Role.DRIVER,
target: Target.START,
firstDatetime: new Date('2023-09-01T07:00Z'),
firstMinDatetime: new Date('2023-09-01T06:45Z'),
firstMaxDatetime: new Date('2023-09-01T06:55Z'),
lastDatetime: new Date('2024-08-30T07:00Z'),
lastMinDatetime: new Date('2024-08-30T06:45Z'),
lastMaxDatetime: new Date('2024-08-30T07:15Z'),
}),
).toThrow(ArgumentInvalidException);
expect(
() =>
new ActorTime({
role: Role.DRIVER,
target: Target.START,
firstDatetime: new Date('2023-09-01T07:00Z'),
firstMinDatetime: new Date('2023-09-01T06:45Z'),
firstMaxDatetime: new Date('2023-09-01T07:15Z'),
lastDatetime: new Date('2024-08-30T07:00Z'),
lastMinDatetime: new Date('2024-08-30T07:05Z'),
lastMaxDatetime: new Date('2024-08-30T07:15Z'),
}),
).toThrow(ArgumentInvalidException);
expect(
() =>
new ActorTime({
role: Role.DRIVER,
target: Target.START,
firstDatetime: new Date('2023-09-01T07:00Z'),
firstMinDatetime: new Date('2023-09-01T06:45Z'),
firstMaxDatetime: new Date('2023-09-01T07:15Z'),
lastDatetime: new Date('2024-08-30T07:00Z'),
lastMinDatetime: new Date('2024-08-30T06:45Z'),
lastMaxDatetime: new Date('2024-08-30T06:35Z'),
}),
).toThrow(ArgumentInvalidException);
expect(
() =>
new ActorTime({
role: Role.DRIVER,
target: Target.START,
firstDatetime: new Date('2024-08-30T07:00Z'),
firstMinDatetime: new Date('2024-08-30T06:45Z'),
firstMaxDatetime: new Date('2024-08-30T07:15Z'),
lastDatetime: new Date('2023-09-01T07:00Z'),
lastMinDatetime: new Date('2023-09-01T06:45Z'),
lastMaxDatetime: new Date('2023-09-01T07:15Z'),
}),
).toThrow(ArgumentInvalidException);
expect(
() =>
new ActorTime({
role: Role.DRIVER,
target: Target.START,
firstDatetime: new Date('2023-09-01T07:00Z'),
firstMinDatetime: new Date('2023-09-01T06:45Z'),
firstMaxDatetime: new Date('2023-09-01T07:15Z'),
lastDatetime: new Date('2024-08-31T07:00Z'),
lastMinDatetime: new Date('2024-08-31T06:45Z'),
lastMaxDatetime: new Date('2024-08-31T06:35Z'),
}),
).toThrow(ArgumentInvalidException);
});
});

View File

@ -0,0 +1,14 @@
import { Role } from '@modules/ad/core/domain/ad.types';
import { Target } from '@modules/ad/core/domain/candidate.types';
import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object';
describe('Actor value object', () => {
it('should create an actor value object', () => {
const actorVO = new Actor({
role: Role.DRIVER,
target: Target.START,
});
expect(actorVO.role).toBe(Role.DRIVER);
expect(actorVO.target).toBe(Target.START);
});
});

View File

@ -1,14 +1,12 @@
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object';
const originWaypointProps: WaypointProps = {
position: 0,
const originPointProps: PointProps = {
lat: 48.689445,
lon: 6.17651,
};
const destinationWaypointProps: WaypointProps = {
position: 1,
const destinationPointProps: PointProps = {
lat: 48.8566,
lon: 2.3522,
};
@ -30,7 +28,7 @@ const createAdProps: CreateAdProps = {
seatsProposed: 3,
seatsRequested: 1,
strict: false,
waypoints: [originWaypointProps, destinationWaypointProps],
waypoints: [originPointProps, destinationPointProps],
driverDistance: 23000,
driverDuration: 900,
passengerDistance: 23000,

View File

@ -0,0 +1,136 @@
import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
import {
Algorithm,
Selector,
} from '@modules/ad/core/application/queries/match/algorithm.abstract';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import { Waypoint } from '@modules/ad/core/application/types/waypoint.type';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { MatchEntity } from '@modules/ad/core/domain/match.entity';
const originWaypoint: Waypoint = {
position: 0,
lat: 48.689445,
lon: 6.17651,
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
};
const destinationWaypoint: Waypoint = {
position: 1,
lat: 48.8566,
lon: 2.3522,
locality: 'Paris',
postalCode: '75000',
country: 'France',
};
const mockRouteProvider: RouteProviderPort = {
getBasic: jest.fn(),
getDetailed: jest.fn(),
};
const matchQuery = new MatchQuery(
{
frequency: Frequency.PUNCTUAL,
fromDate: '2023-08-28',
toDate: '2023-08-28',
schedule: [
{
time: '01:05',
},
],
waypoints: [originWaypoint, destinationWaypoint],
},
mockRouteProvider,
);
const mockAdRepository: AdRepositoryPort = {
insertExtra: jest.fn(),
findOneById: jest.fn(),
findOne: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
updateWhere: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
healthCheck: jest.fn(),
getCandidateAds: jest.fn(),
};
class SomeSelector extends Selector {
select = async (): Promise<CandidateEntity[]> => [
CandidateEntity.create({
id: 'cc260669-1c6d-441f-80a5-19cd59afb777',
role: Role.DRIVER,
frequency: Frequency.PUNCTUAL,
dateInterval: {
lowerDate: '2023-08-28',
higherDate: '2023-08-28',
},
driverWaypoints: [
{
lat: 48.678454,
lon: 6.189745,
},
{
lat: 48.84877,
lon: 2.398457,
},
],
passengerWaypoints: [
{
lat: 48.849445,
lon: 6.68651,
},
{
lat: 47.18746,
lon: 2.89742,
},
],
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: [
{
day: 0,
time: '07:00',
margin: 900,
},
],
passengerSchedule: [
{
day: 0,
time: '07:10',
margin: 900,
},
],
spacetimeDetourRatio: {
maxDistanceDetourRatio: 0.3,
maxDurationDetourRatio: 0.3,
},
}),
];
}
class SomeAlgorithm extends Algorithm {
constructor(
protected readonly query: MatchQuery,
protected readonly repository: AdRepositoryPort,
) {
super(query, repository);
this.selector = new SomeSelector(query, repository);
this.processors = [];
}
}
describe('Abstract Algorithm', () => {
it('should return matches', async () => {
const someAlgorithm = new SomeAlgorithm(matchQuery, mockAdRepository);
const matches: MatchEntity[] = await someAlgorithm.match();
expect(matches).toHaveLength(1);
});
});

View File

@ -0,0 +1,180 @@
import {
CalendarTools,
CalendarToolsException,
} from '@modules/ad/core/domain/calendar-tools.service';
describe('Calendar tools service', () => {
describe('First date', () => {
it('should return the first date for a given week day within a date range', () => {
const firstDate: Date = CalendarTools.firstDate(1, {
lowerDate: '2023-08-31',
higherDate: '2023-09-07',
});
expect(firstDate.getUTCDay()).toBe(1);
expect(firstDate.getUTCDate()).toBe(4);
expect(firstDate.getUTCMonth()).toBe(8);
const secondDate: Date = CalendarTools.firstDate(5, {
lowerDate: '2023-08-31',
higherDate: '2023-09-07',
});
expect(secondDate.getUTCDay()).toBe(5);
expect(secondDate.getUTCDate()).toBe(1);
expect(secondDate.getUTCMonth()).toBe(8);
const thirdDate: Date = CalendarTools.firstDate(4, {
lowerDate: '2023-08-31',
higherDate: '2023-09-07',
});
expect(thirdDate.getUTCDay()).toBe(4);
expect(thirdDate.getUTCDate()).toBe(31);
expect(thirdDate.getUTCMonth()).toBe(7);
});
it('should throw an exception if a given week day is not within a date range', () => {
expect(() => {
CalendarTools.firstDate(1, {
lowerDate: '2023-09-05',
higherDate: '2023-09-07',
});
}).toThrow(CalendarToolsException);
});
it('should throw an exception if a given week day is invalid', () => {
expect(() => {
CalendarTools.firstDate(8, {
lowerDate: '2023-09-05',
higherDate: '2023-09-07',
});
}).toThrow(CalendarToolsException);
});
});
describe('Second date', () => {
it('should return the last date for a given week day within a date range', () => {
const firstDate: Date = CalendarTools.lastDate(0, {
lowerDate: '2023-09-30',
higherDate: '2024-09-30',
});
expect(firstDate.getUTCDay()).toBe(0);
expect(firstDate.getUTCDate()).toBe(29);
expect(firstDate.getUTCMonth()).toBe(8);
const secondDate: Date = CalendarTools.lastDate(5, {
lowerDate: '2023-09-30',
higherDate: '2024-09-30',
});
expect(secondDate.getUTCDay()).toBe(5);
expect(secondDate.getUTCDate()).toBe(27);
expect(secondDate.getUTCMonth()).toBe(8);
const thirdDate: Date = CalendarTools.lastDate(1, {
lowerDate: '2023-09-30',
higherDate: '2024-09-30',
});
expect(thirdDate.getUTCDay()).toBe(1);
expect(thirdDate.getUTCDate()).toBe(30);
expect(thirdDate.getUTCMonth()).toBe(8);
});
it('should throw an exception if a given week day is not within a date range', () => {
expect(() => {
CalendarTools.lastDate(2, {
lowerDate: '2024-09-27',
higherDate: '2024-09-30',
});
}).toThrow(CalendarToolsException);
});
it('should throw an exception if a given week day is invalid', () => {
expect(() => {
CalendarTools.lastDate(8, {
lowerDate: '2023-09-30',
higherDate: '2024-09-30',
});
}).toThrow(CalendarToolsException);
});
});
describe('Datetime from string', () => {
it('should return a date with time from a string without additional seconds', () => {
const datetime: Date = CalendarTools.datetimeWithSeconds(
new Date('2023-09-01'),
'07:12',
);
expect(datetime.getUTCMinutes()).toBe(12);
});
it('should return a date with time from a string with additional seconds', () => {
const datetime: Date = CalendarTools.datetimeWithSeconds(
new Date('2023-09-01'),
'07:12',
60,
);
expect(datetime.getUTCMinutes()).toBe(13);
});
it('should return a date with time from a string with negative additional seconds', () => {
const datetime: Date = CalendarTools.datetimeWithSeconds(
new Date('2023-09-01'),
'07:00',
-60,
);
expect(datetime.getUTCHours()).toBe(6);
expect(datetime.getUTCMinutes()).toBe(59);
});
});
describe('epochDaysFromTime', () => {
it('should return the epoch day for day 1', () => {
const days: Date[] = CalendarTools.epochDaysFromTime(1, '07:00');
expect(days).toHaveLength(1);
expect(days[0].getUTCFullYear()).toBe(1969);
expect(days[0].getUTCMonth()).toBe(11);
expect(days[0].getUTCDate()).toBe(29);
});
it('should return the epoch day for day 2', () => {
const days: Date[] = CalendarTools.epochDaysFromTime(2, '07:00');
expect(days).toHaveLength(1);
expect(days[0].getUTCFullYear()).toBe(1969);
expect(days[0].getUTCMonth()).toBe(11);
expect(days[0].getUTCDate()).toBe(30);
});
it('should return the epoch day for day 3', () => {
const days: Date[] = CalendarTools.epochDaysFromTime(3, '07:00');
expect(days).toHaveLength(1);
expect(days[0].getUTCFullYear()).toBe(1969);
expect(days[0].getUTCMonth()).toBe(11);
expect(days[0].getUTCDate()).toBe(31);
});
it('should return the epoch day for day 4', () => {
const days: Date[] = CalendarTools.epochDaysFromTime(4, '07:00');
expect(days).toHaveLength(1);
expect(days[0].getUTCFullYear()).toBe(1970);
expect(days[0].getUTCMonth()).toBe(0);
expect(days[0].getUTCDate()).toBe(1);
});
it('should return the epoch day for day 5', () => {
const days: Date[] = CalendarTools.epochDaysFromTime(5, '07:00');
expect(days).toHaveLength(1);
expect(days[0].getUTCFullYear()).toBe(1970);
expect(days[0].getUTCMonth()).toBe(0);
expect(days[0].getUTCDate()).toBe(2);
});
it('should return the epoch days for day 0', () => {
const days: Date[] = CalendarTools.epochDaysFromTime(0, '07:00');
expect(days).toHaveLength(2);
expect(days[0].getUTCFullYear()).toBe(1969);
expect(days[0].getUTCMonth()).toBe(11);
expect(days[0].getUTCDate()).toBe(28);
expect(days[1].getUTCFullYear()).toBe(1970);
expect(days[1].getUTCMonth()).toBe(0);
expect(days[1].getUTCDate()).toBe(4);
});
it('should return the epoch days for day 6', () => {
const days: Date[] = CalendarTools.epochDaysFromTime(6, '07:00');
expect(days).toHaveLength(2);
expect(days[0].getUTCFullYear()).toBe(1969);
expect(days[0].getUTCMonth()).toBe(11);
expect(days[0].getUTCDate()).toBe(27);
expect(days[1].getUTCFullYear()).toBe(1970);
expect(days[1].getUTCMonth()).toBe(0);
expect(days[1].getUTCDate()).toBe(3);
});
it('should throw an exception if a given week day is invalid', () => {
expect(() => {
CalendarTools.epochDaysFromTime(8, '07:00');
}).toThrow(CalendarToolsException);
});
});
});

View File

@ -0,0 +1,507 @@
import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import {
SpacetimeDetourRatio,
Target,
} 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 { 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';
const waypointsSet1: PointProps[] = [
{
lat: 48.678454,
lon: 6.189745,
},
{
lat: 48.84877,
lon: 2.398457,
},
];
const waypointsSet2: PointProps[] = [
{
lat: 48.689445,
lon: 6.17651,
},
{
lat: 48.8566,
lon: 2.3522,
},
];
const schedule1: ScheduleItemProps[] = [
{
day: 1,
time: '07:00',
margin: 900,
},
];
const schedule2: ScheduleItemProps[] = [
{
day: 1,
time: '07:10',
margin: 900,
},
];
const schedule3: ScheduleItemProps[] = [
{
day: 1,
time: '06:30',
margin: 900,
},
{
day: 2,
time: '06:30',
margin: 900,
},
{
day: 3,
time: '06:00',
margin: 900,
},
{
day: 4,
time: '06:30',
margin: 900,
},
{
day: 5,
time: '06:30',
margin: 900,
},
];
const schedule4: ScheduleItemProps[] = [
{
day: 1,
time: '06:50',
margin: 900,
},
{
day: 2,
time: '06:50',
margin: 900,
},
{
day: 4,
time: '06:50',
margin: 900,
},
{
day: 5,
time: '06:50',
margin: 900,
},
];
const schedule5: ScheduleItemProps[] = [
{
day: 0,
time: '00:02',
margin: 900,
},
{
day: 1,
time: '07:05',
margin: 900,
},
];
const schedule6: ScheduleItemProps[] = [
{
day: 1,
time: '23:10',
margin: 900,
},
{
day: 6,
time: '23:57',
margin: 900,
},
];
const schedule7: ScheduleItemProps[] = [
{
day: 4,
time: '19:00',
margin: 900,
},
];
const spacetimeDetourRatio: SpacetimeDetourRatio = {
maxDistanceDetourRatio: 0.3,
maxDurationDetourRatio: 0.3,
};
const carpoolPath1: CarpoolPathItemProps[] = [
{
lat: 48.689445,
lon: 6.17651,
actors: [
new Actor({
role: Role.DRIVER,
target: Target.START,
}),
new Actor({
role: Role.PASSENGER,
target: Target.START,
}),
],
},
{
lat: 48.8566,
lon: 2.3522,
actors: [
new Actor({
role: Role.DRIVER,
target: Target.FINISH,
}),
new Actor({
role: Role.PASSENGER,
target: Target.FINISH,
}),
],
},
];
const carpoolPath2: CarpoolPathItemProps[] = [
{
lat: 48.689445,
lon: 6.17651,
actors: [
new Actor({
role: Role.DRIVER,
target: Target.START,
}),
],
},
{
lat: 48.678451,
lon: 6.168784,
actors: [
new Actor({
role: Role.DRIVER,
target: Target.NEUTRAL,
}),
new Actor({
role: Role.PASSENGER,
target: Target.START,
}),
],
},
{
lat: 48.848715,
lon: 2.36985,
actors: [
new Actor({
role: Role.DRIVER,
target: Target.NEUTRAL,
}),
new Actor({
role: Role.PASSENGER,
target: Target.FINISH,
}),
],
},
{
lat: 48.8566,
lon: 2.3522,
actors: [
new Actor({
role: Role.DRIVER,
target: Target.FINISH,
}),
],
},
];
const steps: StepProps[] = [
{
lat: 48.689445,
lon: 6.17651,
duration: 0,
distance: 0,
},
{
lat: 48.678451,
lon: 6.168784,
duration: 1254,
distance: 33462,
},
{
lat: 48.848715,
lon: 2.36985,
duration: 12477,
distance: 343654,
},
{
lat: 48.8566,
lon: 2.3522,
duration: 13548,
distance: 350145,
},
];
describe('Candidate entity', () => {
it('should create a new candidate entity', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({
id: 'cc260669-1c6d-441f-80a5-19cd59afb777',
role: Role.DRIVER,
frequency: Frequency.PUNCTUAL,
dateInterval: {
lowerDate: '2023-08-28',
higherDate: '2023-08-28',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: schedule1,
passengerSchedule: schedule2,
spacetimeDetourRatio,
});
expect(candidateEntity.id.length).toBe(36);
});
it('should set a candidate entity carpool path', () => {
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: waypointsSet2,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: schedule1,
passengerSchedule: schedule2,
spacetimeDetourRatio,
}).setCarpoolPath(carpoolPath1);
expect(candidateEntity.getProps().carpoolPath).toHaveLength(2);
});
it('should create a new candidate entity with spacetime metrics', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({
id: 'cc260669-1c6d-441f-80a5-19cd59afb777',
role: Role.DRIVER,
frequency: Frequency.PUNCTUAL,
dateInterval: {
lowerDate: '2023-08-28',
higherDate: '2023-08-28',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: schedule1,
passengerSchedule: schedule2,
spacetimeDetourRatio,
}).setMetrics(352688, 14587);
expect(candidateEntity.getProps().distance).toBe(352688);
expect(candidateEntity.getProps().duration).toBe(14587);
});
describe('detour validation', () => {
it('should not validate a candidate entity with exceeding distance detour', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({
id: 'cc260669-1c6d-441f-80a5-19cd59afb777',
role: Role.DRIVER,
frequency: Frequency.PUNCTUAL,
dateInterval: {
lowerDate: '2023-08-28',
higherDate: '2023-08-28',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: schedule1,
passengerSchedule: schedule2,
spacetimeDetourRatio,
}).setMetrics(458690, 13980);
expect(candidateEntity.isDetourValid()).toBeFalsy();
});
it('should not validate a candidate entity with exceeding duration detour', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({
id: 'cc260669-1c6d-441f-80a5-19cd59afb777',
role: Role.DRIVER,
frequency: Frequency.PUNCTUAL,
dateInterval: {
lowerDate: '2023-08-28',
higherDate: '2023-08-28',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: schedule1,
passengerSchedule: schedule2,
spacetimeDetourRatio,
}).setMetrics(352368, 18314);
expect(candidateEntity.isDetourValid()).toBeFalsy();
});
});
describe('Journeys', () => {
it('should create journeys for a single date', () => {
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: schedule2,
spacetimeDetourRatio,
})
.setCarpoolPath(carpoolPath2)
.setSteps(steps)
.createJourneys();
expect(candidateEntity.getProps().journeys).toHaveLength(1);
});
it('should create journeys for multiple dates', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({
id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0',
role: Role.PASSENGER,
frequency: Frequency.RECURRENT,
dateInterval: {
lowerDate: '2023-09-01',
higherDate: '2024-09-01',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: schedule3,
passengerSchedule: schedule4,
spacetimeDetourRatio,
})
.setCarpoolPath(carpoolPath2)
.setSteps(steps)
.createJourneys();
expect(candidateEntity.getProps().journeys).toHaveLength(4);
expect(
(
candidateEntity.getProps().journeys as Journey[]
)[0].firstDate.getDate(),
).toBe(4);
expect(
(
candidateEntity.getProps().journeys as Journey[]
)[0].journeyItems[1].actorTimes[1].firstMinDatetime.getDate(),
).toBe(4);
expect(
(
candidateEntity.getProps().journeys as Journey[]
)[1].journeyItems[1].actorTimes[1].firstMinDatetime.getDate(),
).toBe(5);
});
it('should create journeys for multiple dates, including week edges (saturday/sunday)', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({
id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0',
role: Role.PASSENGER,
frequency: Frequency.RECURRENT,
dateInterval: {
lowerDate: '2023-09-01',
higherDate: '2024-09-01',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: schedule5,
passengerSchedule: schedule6,
spacetimeDetourRatio,
})
.setCarpoolPath(carpoolPath2)
.setSteps(steps)
.createJourneys();
expect(candidateEntity.getProps().journeys).toHaveLength(1);
expect(
(candidateEntity.getProps().journeys as Journey[])[0].journeyItems[1]
.actorTimes[0].target,
).toBe(Target.NEUTRAL);
expect(
(
candidateEntity.getProps().journeys as Journey[]
)[0].journeyItems[1].actorTimes[0].firstDatetime.getUTCHours(),
).toBe(0);
expect(
(
candidateEntity.getProps().journeys as Journey[]
)[0].journeyItems[1].actorTimes[0].firstDatetime.getUTCMinutes(),
).toBe(22);
expect(
(
candidateEntity.getProps().journeys as Journey[]
)[0].journeyItems[1].actorTimes[1].firstMinDatetime.getUTCHours(),
).toBe(23);
expect(
(
candidateEntity.getProps().journeys as Journey[]
)[0].journeyItems[1].actorTimes[1].firstMinDatetime.getUTCMinutes(),
).toBe(42);
});
it('should not create journeys if dates does not match', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({
id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0',
role: Role.PASSENGER,
frequency: Frequency.RECURRENT,
dateInterval: {
lowerDate: '2023-09-01',
higherDate: '2024-09-01',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: schedule1,
passengerSchedule: schedule7,
spacetimeDetourRatio,
})
.setCarpoolPath(carpoolPath2)
.setSteps(steps)
.createJourneys();
expect(candidateEntity.getProps().journeys).toHaveLength(0);
expect(candidateEntity.hasJourneys()).toBeFalsy();
});
it('should not verify journeys if journeys is undefined', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({
id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0',
role: Role.PASSENGER,
frequency: Frequency.RECURRENT,
dateInterval: {
lowerDate: '2023-09-01',
higherDate: '2024-09-01',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: schedule1,
passengerSchedule: schedule7,
spacetimeDetourRatio,
})
.setCarpoolPath(carpoolPath2)
.setSteps(steps);
expect(candidateEntity.hasJourneys()).toBeFalsy();
});
});
});

View File

@ -0,0 +1,113 @@
import { CarpoolPathCreator } from '@modules/ad/core/domain/carpool-path-creator.service';
import { CarpoolPathCreatorException } from '@modules/ad/core/domain/match.errors';
import { Point } from '@modules/ad/core/domain/value-objects/point.value-object';
import { CarpoolPathItem } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object';
const waypoint1: Point = new Point({
lat: 0,
lon: 0,
});
const waypoint2: Point = new Point({
lat: 2,
lon: 2,
});
const waypoint3: Point = new Point({
lat: 5,
lon: 5,
});
const waypoint4: Point = new Point({
lat: 6,
lon: 6,
});
const waypoint5: Point = new Point({
lat: 8,
lon: 8,
});
const waypoint6: Point = new Point({
lat: 10,
lon: 10,
});
describe('Carpool Path Creator Service', () => {
it('should create a simple carpool path', () => {
const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator(
[waypoint1, waypoint6],
[waypoint2, waypoint5],
);
const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath();
expect(carpoolPath).toHaveLength(4);
expect(carpoolPath[0].actors.length).toBe(1);
});
it('should create a simple carpool path with same destination for driver and passenger', () => {
const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator(
[waypoint1, waypoint6],
[waypoint2, waypoint6],
);
const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath();
expect(carpoolPath).toHaveLength(3);
expect(carpoolPath[0].actors.length).toBe(1);
expect(carpoolPath[1].actors.length).toBe(2);
expect(carpoolPath[2].actors.length).toBe(2);
});
it('should create a simple carpool path with same waypoints for driver and passenger', () => {
const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator(
[waypoint1, waypoint6],
[waypoint1, waypoint6],
);
const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath();
expect(carpoolPath).toHaveLength(2);
expect(carpoolPath[0].actors.length).toBe(2);
expect(carpoolPath[1].actors.length).toBe(2);
});
it('should create a complex carpool path with 3 driver waypoints', () => {
const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator(
[waypoint1, waypoint3, waypoint6],
[waypoint2, waypoint5],
);
const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath();
expect(carpoolPath).toHaveLength(5);
expect(carpoolPath[0].actors.length).toBe(1);
expect(carpoolPath[1].actors.length).toBe(2);
expect(carpoolPath[2].actors.length).toBe(1);
expect(carpoolPath[3].actors.length).toBe(2);
expect(carpoolPath[4].actors.length).toBe(1);
});
it('should create a complex carpool path with 4 driver waypoints', () => {
const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator(
[waypoint1, waypoint3, waypoint4, waypoint6],
[waypoint2, waypoint5],
);
const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath();
expect(carpoolPath).toHaveLength(6);
expect(carpoolPath[0].actors.length).toBe(1);
expect(carpoolPath[1].actors.length).toBe(2);
expect(carpoolPath[2].actors.length).toBe(1);
expect(carpoolPath[3].actors.length).toBe(1);
expect(carpoolPath[4].actors.length).toBe(2);
expect(carpoolPath[5].actors.length).toBe(1);
});
it('should create a alternate complex carpool path with 4 driver waypoints', () => {
const carpoolPathCreator: CarpoolPathCreator = new CarpoolPathCreator(
[waypoint1, waypoint2, waypoint5, waypoint6],
[waypoint3, waypoint4],
);
const carpoolPath: CarpoolPathItem[] = carpoolPathCreator.carpoolPath();
expect(carpoolPath).toHaveLength(6);
expect(carpoolPath[0].actors.length).toBe(1);
expect(carpoolPath[1].actors.length).toBe(1);
expect(carpoolPath[2].actors.length).toBe(2);
expect(carpoolPath[3].actors.length).toBe(2);
expect(carpoolPath[4].actors.length).toBe(1);
expect(carpoolPath[5].actors.length).toBe(1);
});
it('should throw an exception if less than 2 driver waypoints are given', () => {
expect(() => {
new CarpoolPathCreator([waypoint1], [waypoint3, waypoint4]);
}).toThrow(CarpoolPathCreatorException);
});
it('should throw an exception if less than 2 passenger waypoints are given', () => {
expect(() => {
new CarpoolPathCreator([waypoint1, waypoint6], [waypoint3]);
}).toThrow(CarpoolPathCreatorException);
});
});

View File

@ -0,0 +1,54 @@
import { ArgumentOutOfRangeException } from '@mobicoop/ddd-library';
import { Role } from '@modules/ad/core/domain/ad.types';
import { Target } from '@modules/ad/core/domain/candidate.types';
import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object';
import { CarpoolPathItem } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object';
describe('Carpool Path Item value object', () => {
it('should create a path item value object', () => {
const carpoolPathItemVO = new CarpoolPathItem({
lat: 48.689445,
lon: 6.17651,
actors: [
new Actor({
role: Role.DRIVER,
target: Target.NEUTRAL,
}),
new Actor({
role: Role.PASSENGER,
target: Target.START,
}),
],
});
expect(carpoolPathItemVO.lon).toBe(6.17651);
expect(carpoolPathItemVO.lat).toBe(48.689445);
expect(carpoolPathItemVO.actors).toHaveLength(2);
});
it('should throw an exception if actors is empty', () => {
expect(() => {
new CarpoolPathItem({
lat: 48.689445,
lon: 6.17651,
actors: [],
});
}).toThrow(ArgumentOutOfRangeException);
});
it('should throw an exception if actors contains more than one driver', () => {
expect(() => {
new CarpoolPathItem({
lat: 48.689445,
lon: 6.17651,
actors: [
new Actor({
role: Role.DRIVER,
target: Target.NEUTRAL,
}),
new Actor({
role: Role.DRIVER,
target: Target.START,
}),
],
});
}).toThrow(ArgumentOutOfRangeException);
});
});

View File

@ -7,16 +7,14 @@ import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service';
import { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object';
const originWaypoint: WaypointProps = {
position: 0,
const originWaypoint: PointProps = {
lat: 48.689445,
lon: 6.17651,
};
const destinationWaypoint: WaypointProps = {
position: 1,
const destinationWaypoint: PointProps = {
lat: 48.8566,
lon: 2.3522,
};
@ -48,9 +46,10 @@ const createAdProps: CreateAdProps = {
};
const mockAdRepository = {
insertWithUnsupportedFields: jest
insertExtra: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new Error();
})
@ -60,11 +59,22 @@ const mockAdRepository = {
};
const mockRouteProvider: RouteProviderPort = {
getBasic: jest.fn().mockImplementation(() => ({
driverDistance: 350101,
driverDuration: 14422,
passengerDistance: 350101,
passengerDuration: 14422,
getBasic: jest
.fn()
.mockImplementationOnce(() => {
throw new Error();
})
.mockImplementationOnce(() => ({
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: undefined,
}))
.mockImplementation(() => ({
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
@ -83,6 +93,7 @@ const mockRouteProvider: RouteProviderPort = {
},
],
})),
getDetailed: jest.fn(),
};
describe('create-ad.service', () => {
@ -112,7 +123,17 @@ describe('create-ad.service', () => {
describe('execution', () => {
const createAdCommand = new CreateAdCommand(createAdProps);
it('should create a new ad', async () => {
it('should throw an error if route cant be computed', async () => {
await expect(
createAdService.execute(createAdCommand),
).rejects.toBeInstanceOf(Error);
});
it('should throw an error if route is corrupted', async () => {
await expect(
createAdService.execute(createAdCommand),
).rejects.toBeInstanceOf(Error);
});
it('should create a new ad as driver and passenger', async () => {
AdEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
@ -121,18 +142,22 @@ describe('create-ad.service', () => {
);
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
});
it('should throw an error if something bad happens', async () => {
it('should create a new ad as passenger', async () => {
AdEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
const result: AggregateID = await createAdService.execute({
...createAdCommand,
driver: false,
});
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
});
it('should throw an error if something bad happens', async () => {
await expect(
createAdService.execute(createAdCommand),
).rejects.toBeInstanceOf(Error);
});
it('should throw an exception if Ad already exists', async () => {
AdEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
createAdService.execute(createAdCommand),
).rejects.toBeInstanceOf(AdAlreadyExistsException);

View File

@ -0,0 +1,47 @@
import { ArgumentOutOfRangeException } from '@mobicoop/ddd-library';
import { Role } from '@modules/ad/core/domain/ad.types';
import { Target } from '@modules/ad/core/domain/candidate.types';
import { ActorTime } from '@modules/ad/core/domain/value-objects/actor-time.value-object';
import { JourneyItem } from '@modules/ad/core/domain/value-objects/journey-item.value-object';
describe('Journey item value object', () => {
it('should create a journey item value object', () => {
const journeyItemVO: JourneyItem = new JourneyItem({
lat: 48.689445,
lon: 6.17651,
duration: 1545,
distance: 48754,
actorTimes: [
new ActorTime({
role: Role.DRIVER,
target: Target.NEUTRAL,
firstDatetime: new Date('2023-09-01 07:00'),
firstMinDatetime: new Date('2023-09-01 06:45'),
firstMaxDatetime: new Date('2023-09-01 07:15'),
lastDatetime: new Date('2024-08-30 07:00'),
lastMinDatetime: new Date('2024-08-30 06:45'),
lastMaxDatetime: new Date('2024-08-30 07:15'),
}),
],
});
expect(journeyItemVO.duration).toBe(1545);
expect(journeyItemVO.distance).toBe(48754);
expect(journeyItemVO.lon).toBe(6.17651);
expect(journeyItemVO.lat).toBe(48.689445);
expect(journeyItemVO.actorTimes[0].firstMaxDatetime.getUTCMinutes()).toBe(
15,
);
});
it('should throw an error if actorTimes is too short', () => {
expect(
() =>
new JourneyItem({
lat: 48.689445,
lon: 6.17651,
duration: 1545,
distance: 48754,
actorTimes: [],
}),
).toThrow(ArgumentOutOfRangeException);
});
});

View File

@ -0,0 +1,152 @@
import { JourneyCompleter } from '@modules/ad/core/application/queries/match/completer/journey.completer';
import { MatchQuery } 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, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Target } from '@modules/ad/core/domain/candidate.types';
import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object';
const originWaypoint: Waypoint = {
position: 0,
lat: 48.689445,
lon: 6.17651,
houseNumber: '5',
street: 'Avenue Foch',
locality: 'Nancy',
postalCode: '54000',
country: 'France',
};
const destinationWaypoint: Waypoint = {
position: 1,
lat: 48.8566,
lon: 2.3522,
locality: 'Paris',
postalCode: '75000',
country: 'France',
};
const matchQuery = new MatchQuery(
{
algorithmType: AlgorithmType.PASSENGER_ORIENTED,
driver: true,
passenger: true,
frequency: Frequency.PUNCTUAL,
fromDate: '2023-08-28',
toDate: '2023-08-28',
schedule: [
{
time: '07:05',
},
],
strict: false,
waypoints: [originWaypoint, destinationWaypoint],
},
{
getBasic: jest.fn().mockImplementation(() => ({
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
})),
getDetailed: jest.fn().mockImplementation(() => ({
distance: 350102,
duration: 14423,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
})),
},
);
const candidate: CandidateEntity = CandidateEntity.create({
id: 'cc260669-1c6d-441f-80a5-19cd59afb777',
role: Role.DRIVER,
frequency: Frequency.PUNCTUAL,
dateInterval: {
lowerDate: '2023-08-28',
higherDate: '2023-08-28',
},
driverWaypoints: [
{
lat: 48.678454,
lon: 6.189745,
},
{
lat: 48.84877,
lon: 2.398457,
},
],
passengerWaypoints: [
{
lat: 48.689445,
lon: 6.17651,
},
{
lat: 48.8566,
lon: 2.3522,
},
],
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: [
{
day: 1,
time: '07:00',
margin: 900,
},
],
passengerSchedule: [
{
day: 1,
time: '07:10',
margin: 900,
},
],
spacetimeDetourRatio: {
maxDistanceDetourRatio: 0.3,
maxDurationDetourRatio: 0.3,
},
}).setCarpoolPath([
{
lat: 48.689445,
lon: 6.17651,
actors: [
new Actor({
role: Role.DRIVER,
target: Target.START,
}),
new Actor({
role: Role.PASSENGER,
target: Target.START,
}),
],
},
{
lat: 48.8566,
lon: 2.3522,
actors: [
new Actor({
role: Role.DRIVER,
target: Target.FINISH,
}),
new Actor({
role: Role.PASSENGER,
target: Target.FINISH,
}),
],
},
]);
candidate.createJourneys = jest.fn().mockImplementation(() => candidate);
describe('Journey completer', () => {
it('should complete candidates with their journey', async () => {
const journeyCompleter: JourneyCompleter = new JourneyCompleter(matchQuery);
const completedCandidates: CandidateEntity[] =
await journeyCompleter.complete([candidate]);
expect(completedCandidates.length).toBe(1);
});
});

Some files were not shown because too many files have changed in this diff Show More