Merge branch 'matcherModule' into 'main'
Matcher module See merge request v3/service/matcher!11
This commit is contained in:
commit
fb34757463
30
.env.dist
30
.env.dist
|
@ -15,32 +15,25 @@ MESSAGE_BROKER_EXCHANGE=mobicoop
|
||||||
REDIS_HOST=v3-redis
|
REDIS_HOST=v3-redis
|
||||||
REDIS_PASSWORD=redis
|
REDIS_PASSWORD=redis
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
REDIS_MATCHING_KEY=MATCHER:MATCHING
|
||||||
|
REDIS_MATCHING_TTL=900
|
||||||
|
|
||||||
# CACHE
|
# CACHE
|
||||||
CACHE_TTL=5000
|
CACHE_TTL=5000
|
||||||
|
|
||||||
# DEFAULT CONFIGURATION
|
# 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 type
|
||||||
ALGORITHM=CLASSIC
|
ALGORITHM=PASSENGER_ORIENTED
|
||||||
# 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
|
|
||||||
# max distance in metres between driver
|
# max distance in metres between driver
|
||||||
# route and passenger pick-up / drop-off
|
# route and passenger pick-up / drop-off
|
||||||
REMOTENESS=15000
|
REMOTENESS=15000
|
||||||
# use passenger proportion
|
# use passenger proportion
|
||||||
USE_PROPORTION=1
|
USE_PROPORTION=true
|
||||||
# minimal driver proportion
|
# minimal driver proportion
|
||||||
PROPORTION=0.3
|
PROPORTION=0.3
|
||||||
# use azimuth calculation
|
# use azimuth calculation
|
||||||
USE_AZIMUTH=1
|
USE_AZIMUTH=true
|
||||||
# azimuth margin
|
# azimuth margin
|
||||||
AZIMUTH_MARGIN=10
|
AZIMUTH_MARGIN=10
|
||||||
# margin duration in seconds
|
# margin duration in seconds
|
||||||
|
@ -54,3 +47,16 @@ MAX_DETOUR_DURATION_RATIO=0.3
|
||||||
GEOROUTER_TYPE=graphhopper
|
GEOROUTER_TYPE=graphhopper
|
||||||
# georouter url
|
# georouter url
|
||||||
GEOROUTER_URL=http://localhost:8989
|
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
229
README.md
|
@ -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).
|
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).
|
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).
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
{
|
{
|
||||||
"name": "@mobicoop/matcher",
|
"name": "@mobicoop/matcher",
|
||||||
"version": "0.0.2",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@mobicoop/matcher",
|
"name": "@mobicoop/matcher",
|
||||||
"version": "0.0.2",
|
"version": "1.0.0",
|
||||||
"license": "AGPL",
|
"license": "AGPL",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.8.14",
|
"@grpc/grpc-js": "^1.8.14",
|
||||||
"@grpc/proto-loader": "^0.7.6",
|
"@grpc/proto-loader": "^0.7.6",
|
||||||
"@liaoliaots/nestjs-redis": "^9.0.5",
|
"@liaoliaots/nestjs-redis": "^9.0.5",
|
||||||
"@mobicoop/configuration-module": "^1.2.0",
|
"@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/health-module": "^2.0.0",
|
||||||
"@mobicoop/message-broker-module": "^1.2.0",
|
"@mobicoop/message-broker-module": "^1.2.0",
|
||||||
"@nestjs/axios": "^2.0.0",
|
"@nestjs/axios": "^2.0.0",
|
||||||
|
@ -1505,9 +1505,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mobicoop/ddd-library": {
|
"node_modules/@mobicoop/ddd-library": {
|
||||||
"version": "1.1.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-1.5.0.tgz",
|
||||||
"integrity": "sha512-x4X7j2CJYZQPDZgLuZP5TFk59fle1wTPdX++Z2YyD7VwwV+yOmVvMIRfTyLRFUTzLObrd6FKs8mh+g59n9jUlA==",
|
"integrity": "sha512-CX/V2+vSXrGtKobsyBfVpMW323ZT8tHrgUl1qrvU1XjRKNShvwsKyC7739x7CNgkJ9sr3XV+75JrOXEnqU83zw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/event-emitter": "^1.4.2",
|
"@nestjs/event-emitter": "^1.4.2",
|
||||||
"@nestjs/microservices": "^9.4.0",
|
"@nestjs/microservices": "^9.4.0",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mobicoop/matcher",
|
"name": "@mobicoop/matcher",
|
||||||
"version": "0.0.2",
|
"version": "1.0.0",
|
||||||
"description": "Mobicoop V3 Matcher",
|
"description": "Mobicoop V3 Matcher",
|
||||||
"author": "sbriat",
|
"author": "sbriat",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
@ -34,7 +34,7 @@
|
||||||
"@grpc/proto-loader": "^0.7.6",
|
"@grpc/proto-loader": "^0.7.6",
|
||||||
"@liaoliaots/nestjs-redis": "^9.0.5",
|
"@liaoliaots/nestjs-redis": "^9.0.5",
|
||||||
"@mobicoop/configuration-module": "^1.2.0",
|
"@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/health-module": "^2.0.0",
|
||||||
"@mobicoop/message-broker-module": "^1.2.0",
|
"@mobicoop/message-broker-module": "^1.2.0",
|
||||||
"@nestjs/axios": "^2.0.0",
|
"@nestjs/axios": "^2.0.0",
|
||||||
|
|
|
@ -26,15 +26,19 @@ import { GeographyModule } from '@modules/geography/geography.module';
|
||||||
useFactory: async (
|
useFactory: async (
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
): Promise<ConfigurationModuleOptions> => ({
|
): Promise<ConfigurationModuleOptions> => ({
|
||||||
domain: configService.get<string>('SERVICE_CONFIGURATION_DOMAIN'),
|
domain: configService.get<string>(
|
||||||
|
'SERVICE_CONFIGURATION_DOMAIN',
|
||||||
|
) as string,
|
||||||
messageBroker: {
|
messageBroker: {
|
||||||
uri: configService.get<string>('MESSAGE_BROKER_URI'),
|
uri: configService.get<string>('MESSAGE_BROKER_URI') as string,
|
||||||
exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE'),
|
exchange: configService.get<string>(
|
||||||
|
'MESSAGE_BROKER_EXCHANGE',
|
||||||
|
) as string,
|
||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
host: configService.get<string>('REDIS_HOST'),
|
host: configService.get<string>('REDIS_HOST') as string,
|
||||||
password: configService.get<string>('REDIS_PASSWORD'),
|
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',
|
setConfigurationBrokerQueue: 'matcher-configuration-create-update',
|
||||||
deleteConfigurationQueue: 'matcher-configuration-delete',
|
deleteConfigurationQueue: 'matcher-configuration-delete',
|
||||||
|
|
11
src/main.ts
11
src/main.ts
|
@ -11,14 +11,17 @@ async function bootstrap() {
|
||||||
app.connectMicroservice<MicroserviceOptions>({
|
app.connectMicroservice<MicroserviceOptions>({
|
||||||
transport: Transport.GRPC,
|
transport: Transport.GRPC,
|
||||||
options: {
|
options: {
|
||||||
package: ['health'],
|
package: ['matcher', 'health'],
|
||||||
protoPath: [join(__dirname, 'health.proto')],
|
protoPath: [
|
||||||
|
join(__dirname, 'modules/ad/interface/grpc-controllers/matcher.proto'),
|
||||||
|
join(__dirname, 'health.proto'),
|
||||||
|
],
|
||||||
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
|
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
|
||||||
loader: { keepCase: true },
|
loader: { keepCase: true, enums: String },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.startAllMicroservices();
|
await app.startAllMicroservices();
|
||||||
await app.listen(process.env.HEALTH_SERVICE_PORT);
|
await app.listen(process.env.HEALTH_SERVICE_PORT as string);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
|
@ -1,7 +1,18 @@
|
||||||
export const AD_REPOSITORY = Symbol('AD_REPOSITORY');
|
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_DIRECTION_ENCODER = Symbol('AD_DIRECTION_ENCODER');
|
||||||
export const AD_MESSAGE_PUBLISHER = Symbol('AD_MESSAGE_PUBLISHER');
|
export const AD_MESSAGE_PUBLISHER = Symbol('AD_MESSAGE_PUBLISHER');
|
||||||
export const AD_GET_BASIC_ROUTE_CONTROLLER = Symbol(
|
export const AD_GET_BASIC_ROUTE_CONTROLLER = Symbol(
|
||||||
'AD_GET_BASIC_ROUTE_CONTROLLER',
|
'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 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',
|
||||||
|
);
|
||||||
|
|
|
@ -4,11 +4,13 @@ import {
|
||||||
AdWriteModel,
|
AdWriteModel,
|
||||||
AdReadModel,
|
AdReadModel,
|
||||||
ScheduleItemModel,
|
ScheduleItemModel,
|
||||||
AdUnsupportedWriteModel,
|
AdWriteExtraModel,
|
||||||
} from './infrastructure/ad.repository';
|
} from './infrastructure/ad.repository';
|
||||||
import { Frequency } from './core/domain/ad.types';
|
|
||||||
import { v4 } from 'uuid';
|
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 { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
|
||||||
import { AD_DIRECTION_ENCODER } from './ad.di-tokens';
|
import { AD_DIRECTION_ENCODER } from './ad.di-tokens';
|
||||||
import { ExtendedMapper } from '@mobicoop/ddd-library';
|
import { ExtendedMapper } from '@mobicoop/ddd-library';
|
||||||
|
@ -27,7 +29,7 @@ export class AdMapper
|
||||||
AdEntity,
|
AdEntity,
|
||||||
AdReadModel,
|
AdReadModel,
|
||||||
AdWriteModel,
|
AdWriteModel,
|
||||||
AdUnsupportedWriteModel,
|
AdWriteExtraModel,
|
||||||
undefined
|
undefined
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
|
@ -77,28 +79,12 @@ export class AdMapper
|
||||||
return record;
|
return record;
|
||||||
};
|
};
|
||||||
|
|
||||||
toDomain = (record: AdReadModel): AdEntity => {
|
toDomain = (record: AdReadModel): AdEntity =>
|
||||||
const entity = new AdEntity({
|
new AdEntity({
|
||||||
id: record.uuid,
|
id: record.uuid,
|
||||||
createdAt: new Date(record.createdAt),
|
createdAt: new Date(record.createdAt),
|
||||||
updatedAt: new Date(record.updatedAt),
|
updatedAt: new Date(record.updatedAt),
|
||||||
props: {
|
props: {
|
||||||
driver: record.driver,
|
|
||||||
passenger: record.passenger,
|
|
||||||
frequency: Frequency[record.frequency],
|
|
||||||
fromDate: record.fromDate.toISOString().split('T')[0],
|
|
||||||
toDate: record.toDate.toISOString().split('T')[0],
|
|
||||||
schedule: record.schedule.map((scheduleItem: ScheduleItemModel) => ({
|
|
||||||
day: scheduleItem.day,
|
|
||||||
time: `${scheduleItem.time
|
|
||||||
.getUTCHours()
|
|
||||||
.toString()
|
|
||||||
.padStart(2, '0')}:${scheduleItem.time
|
|
||||||
.getUTCMinutes()
|
|
||||||
.toString()
|
|
||||||
.padStart(2, '0')}`,
|
|
||||||
margin: scheduleItem.margin,
|
|
||||||
})),
|
|
||||||
seatsProposed: record.seatsProposed,
|
seatsProposed: record.seatsProposed,
|
||||||
seatsRequested: record.seatsRequested,
|
seatsRequested: record.seatsRequested,
|
||||||
strict: record.strict,
|
strict: record.strict,
|
||||||
|
@ -106,6 +92,25 @@ export class AdMapper
|
||||||
driverDistance: record.driverDistance,
|
driverDistance: record.driverDistance,
|
||||||
passengerDuration: record.passengerDuration,
|
passengerDuration: record.passengerDuration,
|
||||||
passengerDistance: record.passengerDistance,
|
passengerDistance: record.passengerDistance,
|
||||||
|
driver: record.driver,
|
||||||
|
passenger: record.passenger,
|
||||||
|
frequency: record.frequency,
|
||||||
|
fromDate: record.fromDate.toISOString().split('T')[0],
|
||||||
|
toDate: record.toDate.toISOString().split('T')[0],
|
||||||
|
schedule: record.schedule.map(
|
||||||
|
(scheduleItem: ScheduleItemModel) =>
|
||||||
|
new ScheduleItem({
|
||||||
|
day: scheduleItem.day,
|
||||||
|
time: `${scheduleItem.time
|
||||||
|
.getUTCHours()
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}:${scheduleItem.time
|
||||||
|
.getUTCMinutes()
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}`,
|
||||||
|
margin: scheduleItem.margin,
|
||||||
|
}),
|
||||||
|
),
|
||||||
waypoints: this.directionEncoder
|
waypoints: this.directionEncoder
|
||||||
.decode(record.waypoints)
|
.decode(record.waypoints)
|
||||||
.map((coordinates, index) => ({
|
.map((coordinates, index) => ({
|
||||||
|
@ -117,15 +122,8 @@ export class AdMapper
|
||||||
points: [],
|
points: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return entity;
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
toPersistenceExtra = (entity: AdEntity): AdWriteExtraModel => ({
|
||||||
toResponse = (entity: AdEntity): undefined => {
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
toUnsupportedPersistence = (entity: AdEntity): AdUnsupportedWriteModel => ({
|
|
||||||
waypoints: this.directionEncoder.encode(entity.getProps().waypoints),
|
waypoints: this.directionEncoder.encode(entity.getProps().waypoints),
|
||||||
direction: this.directionEncoder.encode(entity.getProps().points),
|
direction: this.directionEncoder.encode(entity.getProps().points),
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,6 +6,13 @@ import {
|
||||||
AD_DIRECTION_ENCODER,
|
AD_DIRECTION_ENCODER,
|
||||||
AD_ROUTE_PROVIDER,
|
AD_ROUTE_PROVIDER,
|
||||||
AD_GET_BASIC_ROUTE_CONTROLLER,
|
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';
|
} from './ad.di-tokens';
|
||||||
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
|
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
|
||||||
import { AdRepository } from './infrastructure/ad.repository';
|
import { AdRepository } from './infrastructure/ad.repository';
|
||||||
|
@ -17,18 +24,37 @@ import { GetBasicRouteController } from '@modules/geography/interface/controller
|
||||||
import { RouteProvider } from './infrastructure/route-provider';
|
import { RouteProvider } from './infrastructure/route-provider';
|
||||||
import { GeographyModule } from '@modules/geography/geography.module';
|
import { GeographyModule } from '@modules/geography/geography.module';
|
||||||
import { CreateAdService } from './core/application/commands/create-ad/create-ad.service';
|
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 messageHandlers = [AdCreatedMessageHandler];
|
||||||
|
|
||||||
const commandHandlers: Provider[] = [CreateAdService];
|
const commandHandlers: Provider[] = [CreateAdService];
|
||||||
|
|
||||||
const mappers: Provider[] = [AdMapper];
|
const queryHandlers: Provider[] = [MatchQueryHandler];
|
||||||
|
|
||||||
|
const mappers: Provider[] = [AdMapper, MatchMapper, MatchingMapper];
|
||||||
|
|
||||||
const repositories: Provider[] = [
|
const repositories: Provider[] = [
|
||||||
{
|
{
|
||||||
provide: AD_REPOSITORY,
|
provide: AD_REPOSITORY,
|
||||||
useClass: AdRepository,
|
useClass: AdRepository,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: MATCHING_REPOSITORY,
|
||||||
|
useClass: MatchingRepository,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const messagePublishers: Provider[] = [
|
const messagePublishers: Provider[] = [
|
||||||
|
@ -53,19 +79,51 @@ const adapters: Provider[] = [
|
||||||
provide: AD_GET_BASIC_ROUTE_CONTROLLER,
|
provide: AD_GET_BASIC_ROUTE_CONTROLLER,
|
||||||
useClass: GetBasicRouteController,
|
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({
|
@Module({
|
||||||
imports: [CqrsModule, GeographyModule],
|
imports: [CqrsModule, GeographyModule],
|
||||||
|
controllers: [...grpcControllers],
|
||||||
providers: [
|
providers: [
|
||||||
...messageHandlers,
|
...messageHandlers,
|
||||||
...commandHandlers,
|
...commandHandlers,
|
||||||
|
...queryHandlers,
|
||||||
...mappers,
|
...mappers,
|
||||||
...repositories,
|
...repositories,
|
||||||
...messagePublishers,
|
...messagePublishers,
|
||||||
...orms,
|
...orms,
|
||||||
...adapters,
|
...adapters,
|
||||||
],
|
],
|
||||||
exports: [PrismaService, AdMapper, AD_REPOSITORY, AD_DIRECTION_ENCODER],
|
exports: [
|
||||||
|
PrismaService,
|
||||||
|
AdMapper,
|
||||||
|
AD_REPOSITORY,
|
||||||
|
AD_DIRECTION_ENCODER,
|
||||||
|
AD_MESSAGE_PUBLISHER,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AdModule {}
|
export class AdModule {}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Frequency } from '@modules/ad/core/domain/ad.types';
|
import { Frequency } from '@modules/ad/core/domain/ad.types';
|
||||||
import { Command, CommandProps } from '@mobicoop/ddd-library';
|
import { Command, CommandProps } from '@mobicoop/ddd-library';
|
||||||
import { ScheduleItem } from '../../types/schedule-item.type';
|
import { ScheduleItem } from '../../types/schedule-item.type';
|
||||||
import { Waypoint } from '../../types/waypoint.type';
|
import { Address } from '../../types/address.type';
|
||||||
|
|
||||||
export class CreateAdCommand extends Command {
|
export class CreateAdCommand extends Command {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
@ -14,11 +14,11 @@ export class CreateAdCommand extends Command {
|
||||||
readonly seatsProposed: number;
|
readonly seatsProposed: number;
|
||||||
readonly seatsRequested: number;
|
readonly seatsRequested: number;
|
||||||
readonly strict: boolean;
|
readonly strict: boolean;
|
||||||
readonly waypoints: Waypoint[];
|
readonly waypoints: Address[];
|
||||||
|
|
||||||
constructor(props: CommandProps<CreateAdCommand>) {
|
constructor(props: CommandProps<CreateAdCommand>) {
|
||||||
super(props);
|
super(props);
|
||||||
this.id = props.id;
|
this.id = props.id as string;
|
||||||
this.driver = props.driver;
|
this.driver = props.driver;
|
||||||
this.passenger = props.passenger;
|
this.passenger = props.passenger;
|
||||||
this.frequency = props.frequency;
|
this.frequency = props.frequency;
|
||||||
|
|
|
@ -8,7 +8,15 @@ import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
|
||||||
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
|
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
|
||||||
import { RouteProviderPort } from '../../ports/route-provider.port';
|
import { RouteProviderPort } from '../../ports/route-provider.port';
|
||||||
import { Role } from '@modules/ad/core/domain/ad.types';
|
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)
|
@CommandHandler(CreateAdCommand)
|
||||||
export class CreateAdService implements ICommandHandler {
|
export class CreateAdService implements ICommandHandler {
|
||||||
|
@ -23,10 +31,70 @@ export class CreateAdService implements ICommandHandler {
|
||||||
const roles: Role[] = [];
|
const roles: Role[] = [];
|
||||||
if (command.driver) roles.push(Role.DRIVER);
|
if (command.driver) roles.push(Role.DRIVER);
|
||||||
if (command.passenger) roles.push(Role.PASSENGER);
|
if (command.passenger) roles.push(Role.PASSENGER);
|
||||||
const route: Route = await this.routeProvider.getBasic(
|
|
||||||
|
const pathCreator: PathCreator = new PathCreator(
|
||||||
roles,
|
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({
|
const ad = AdEntity.create({
|
||||||
id: command.id,
|
id: command.id,
|
||||||
driver: command.driver,
|
driver: command.driver,
|
||||||
|
@ -39,17 +107,16 @@ export class CreateAdService implements ICommandHandler {
|
||||||
seatsRequested: command.seatsRequested,
|
seatsRequested: command.seatsRequested,
|
||||||
strict: command.strict,
|
strict: command.strict,
|
||||||
waypoints: command.waypoints,
|
waypoints: command.waypoints,
|
||||||
points: route.points,
|
points: points as PointValueObject[],
|
||||||
driverDistance: route.driverDistance,
|
driverDistance,
|
||||||
driverDuration: route.driverDuration,
|
driverDuration,
|
||||||
passengerDistance: route.passengerDistance,
|
passengerDistance,
|
||||||
passengerDuration: route.passengerDuration,
|
passengerDuration,
|
||||||
fwdAzimuth: route.fwdAzimuth,
|
fwdAzimuth: fwdAzimuth as number,
|
||||||
backAzimuth: route.backAzimuth,
|
backAzimuth: backAzimuth as number,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.repository.insertWithUnsupportedFields(ad, 'ad');
|
await this.repository.insertExtra(ad, 'ad');
|
||||||
return ad.id;
|
return ad.id;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof ConflictException) {
|
if (error instanceof ConflictException) {
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import { ExtendedRepositoryPort } from '@mobicoop/ddd-library';
|
import { ExtendedRepositoryPort } from '@mobicoop/ddd-library';
|
||||||
import { AdEntity } from '../../domain/ad.entity';
|
import { AdEntity } from '../../domain/ad.entity';
|
||||||
|
|
||||||
export type AdRepositoryPort = ExtendedRepositoryPort<AdEntity>;
|
export type AdRepositoryPort = ExtendedRepositoryPort<AdEntity> & {
|
||||||
|
getCandidateAds(queryString: string): Promise<AdEntity[]>;
|
||||||
|
};
|
||||||
|
|
|
@ -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',
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { DefaultParams } from './default-params.type';
|
||||||
|
|
||||||
|
export interface DefaultParamsProviderPort {
|
||||||
|
getParams(): DefaultParams;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { MatchingEntity } from '../../domain/matching.entity';
|
||||||
|
|
||||||
|
export type MatchingRepositoryPort = {
|
||||||
|
get(id: string): Promise<MatchingEntity>;
|
||||||
|
save(matching: MatchingEntity): Promise<void>;
|
||||||
|
};
|
|
@ -1,10 +1,19 @@
|
||||||
import { Role } from '../../domain/ad.types';
|
import { Point } from '../types/point.type';
|
||||||
import { Waypoint } from '../types/waypoint.type';
|
|
||||||
import { Route } from '../types/route.type';
|
import { Route } from '../types/route.type';
|
||||||
|
|
||||||
export interface RouteProviderPort {
|
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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface TimezoneFinderPort {
|
||||||
|
timezones(lon: number, lat: number, defaultTimezone?: string): string[];
|
||||||
|
}
|
|
@ -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[]>;
|
||||||
|
}
|
|
@ -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[]>;
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
|
@ -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',
|
||||||
|
}
|
|
@ -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[]>;
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
|
@ -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(),
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
|
@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
|
@ -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;
|
||||||
|
};
|
|
@ -1,4 +1,4 @@
|
||||||
export type Coordinates = {
|
export type Point = {
|
||||||
lon: number;
|
lon: number;
|
||||||
lat: number;
|
lat: number;
|
||||||
};
|
};
|
|
@ -1,11 +1,12 @@
|
||||||
import { Coordinates } from './coordinates.type';
|
import { Point } from './point.type';
|
||||||
|
import { Step } from './step.type';
|
||||||
|
|
||||||
export type Route = {
|
export type Route = {
|
||||||
driverDistance?: number;
|
distance: number;
|
||||||
driverDuration?: number;
|
duration: number;
|
||||||
passengerDistance?: number;
|
|
||||||
passengerDuration?: number;
|
|
||||||
fwdAzimuth: number;
|
fwdAzimuth: number;
|
||||||
backAzimuth: number;
|
backAzimuth: number;
|
||||||
points: Coordinates[];
|
distanceAzimuth: number;
|
||||||
|
points: Point[];
|
||||||
|
steps?: Step[];
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { Point } from './point.type';
|
||||||
|
|
||||||
|
export type Step = Point & {
|
||||||
|
duration: number;
|
||||||
|
distance?: number;
|
||||||
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
import { Coordinates } from './coordinates.type';
|
import { Address } from './address.type';
|
||||||
|
|
||||||
export type Waypoint = {
|
export type Waypoint = {
|
||||||
position: number;
|
position: number;
|
||||||
} & Coordinates;
|
} & Address;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { PointProps } from './value-objects/point.value-object';
|
import { PointProps } from './value-objects/point.value-object';
|
||||||
import { ScheduleItemProps } from './value-objects/schedule-item.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
|
// All properties that an Ad has
|
||||||
export interface AdProps {
|
export interface AdProps {
|
||||||
|
@ -17,7 +16,7 @@ export interface AdProps {
|
||||||
driverDistance?: number;
|
driverDistance?: number;
|
||||||
passengerDuration?: number;
|
passengerDuration?: number;
|
||||||
passengerDistance?: number;
|
passengerDistance?: number;
|
||||||
waypoints: WaypointProps[];
|
waypoints: PointProps[];
|
||||||
points: PointProps[];
|
points: PointProps[];
|
||||||
fwdAzimuth: number;
|
fwdAzimuth: number;
|
||||||
backAzimuth: number;
|
backAzimuth: number;
|
||||||
|
@ -35,7 +34,7 @@ export interface CreateAdProps {
|
||||||
seatsProposed: number;
|
seatsProposed: number;
|
||||||
seatsRequested: number;
|
seatsRequested: number;
|
||||||
strict: boolean;
|
strict: boolean;
|
||||||
waypoints: WaypointProps[];
|
waypoints: PointProps[];
|
||||||
driverDuration?: number;
|
driverDuration?: number;
|
||||||
driverDistance?: number;
|
driverDistance?: number;
|
||||||
passengerDuration?: number;
|
passengerDuration?: number;
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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[];
|
||||||
|
}
|
|
@ -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',
|
||||||
|
}
|
|
@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ export interface ScheduleItemProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ScheduleItem extends ValueObject<ScheduleItemProps> {
|
export class ScheduleItem extends ValueObject<ScheduleItemProps> {
|
||||||
get day(): number | undefined {
|
get day(): number {
|
||||||
return this.props.day;
|
return this.props.day;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,11 +24,10 @@ export class ScheduleItem extends ValueObject<ScheduleItemProps> {
|
||||||
return this.props.time;
|
return this.props.time;
|
||||||
}
|
}
|
||||||
|
|
||||||
get margin(): number | undefined {
|
get margin(): number {
|
||||||
return this.props.margin;
|
return this.props.margin;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
protected validate(props: ScheduleItemProps): void {
|
protected validate(props: ScheduleItemProps): void {
|
||||||
if (props.day < 0 || props.day > 6)
|
if (props.day < 0 || props.day > 6)
|
||||||
throw new ArgumentOutOfRangeException('day must be between 0 and 6');
|
throw new ArgumentOutOfRangeException('day must be between 0 and 6');
|
||||||
|
|
|
@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -7,53 +7,75 @@ import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens';
|
||||||
import { AdEntity } from '../core/domain/ad.entity';
|
import { AdEntity } from '../core/domain/ad.entity';
|
||||||
import { AdMapper } from '../ad.mapper';
|
import { AdMapper } from '../ad.mapper';
|
||||||
import { ExtendedPrismaRepositoryBase } from '@mobicoop/ddd-library/dist/db/prisma-repository.base';
|
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;
|
uuid: string;
|
||||||
driver: boolean;
|
driver: boolean;
|
||||||
passenger: boolean;
|
passenger: boolean;
|
||||||
frequency: string;
|
frequency: Frequency;
|
||||||
fromDate: Date;
|
fromDate: Date;
|
||||||
toDate: Date;
|
toDate: Date;
|
||||||
seatsProposed: number;
|
seatsProposed: number;
|
||||||
seatsRequested: number;
|
seatsRequested: number;
|
||||||
strict: boolean;
|
strict: boolean;
|
||||||
driverDuration: number;
|
driverDuration?: number;
|
||||||
driverDistance: number;
|
driverDistance?: number;
|
||||||
passengerDuration: number;
|
passengerDuration?: number;
|
||||||
passengerDistance: number;
|
passengerDistance?: number;
|
||||||
fwdAzimuth: number;
|
fwdAzimuth: number;
|
||||||
backAzimuth: number;
|
backAzimuth: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdReadModel = AdBaseModel & {
|
/**
|
||||||
|
* The record as returned by the persistence system
|
||||||
|
*/
|
||||||
|
export type AdReadModel = AdModel & {
|
||||||
waypoints: string;
|
waypoints: string;
|
||||||
direction: string;
|
|
||||||
schedule: ScheduleItemModel[];
|
schedule: ScheduleItemModel[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdWriteModel = AdBaseModel & {
|
/**
|
||||||
|
* The record ready to be sent to the peristence system
|
||||||
|
*/
|
||||||
|
export type AdWriteModel = AdModel & {
|
||||||
schedule: {
|
schedule: {
|
||||||
create: ScheduleItemModel[];
|
create: ScheduleItemModel[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdUnsupportedWriteModel = {
|
export type AdWriteExtraModel = {
|
||||||
waypoints: string;
|
waypoints: string;
|
||||||
direction: string;
|
direction: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScheduleItemModel = {
|
export type ScheduleItem = {
|
||||||
uuid: string;
|
|
||||||
day: number;
|
day: number;
|
||||||
time: Date;
|
time: Date;
|
||||||
margin: number;
|
margin: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScheduleItemModel = ScheduleItem & {
|
||||||
|
uuid: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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
|
* Repository is used for retrieving/saving domain entities
|
||||||
* */
|
* */
|
||||||
|
@ -63,7 +85,7 @@ export class AdRepository
|
||||||
AdEntity,
|
AdEntity,
|
||||||
AdReadModel,
|
AdReadModel,
|
||||||
AdWriteModel,
|
AdWriteModel,
|
||||||
AdUnsupportedWriteModel
|
AdWriteExtraModel
|
||||||
>
|
>
|
||||||
implements AdRepositoryPort
|
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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
|
@ -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,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,21 +1,28 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { RouteProviderPort } from '../core/application/ports/route-provider.port';
|
import { RouteProviderPort } from '../core/application/ports/route-provider.port';
|
||||||
import { Route } from '../core/application/types/route.type';
|
import { GetRouteControllerPort } from '@modules/geography/core/application/ports/get-route-controller.port';
|
||||||
import { Waypoint } from '../core/application/types/waypoint.type';
|
import {
|
||||||
import { Role } from '../core/domain/ad.types';
|
AD_GET_BASIC_ROUTE_CONTROLLER,
|
||||||
import { GetBasicRouteControllerPort } from '@modules/geography/core/application/ports/get-basic-route-controller.port';
|
AD_GET_DETAILED_ROUTE_CONTROLLER,
|
||||||
import { AD_GET_BASIC_ROUTE_CONTROLLER } from '../ad.di-tokens';
|
} from '../ad.di-tokens';
|
||||||
|
import { Point, Route } from '@modules/geography/core/domain/route.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RouteProvider implements RouteProviderPort {
|
export class RouteProvider implements RouteProviderPort {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(AD_GET_BASIC_ROUTE_CONTROLLER)
|
@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({
|
await this.getBasicRouteController.get({
|
||||||
roles,
|
waypoints,
|
||||||
|
});
|
||||||
|
|
||||||
|
getDetailed = async (waypoints: Point[]): Promise<Route> =>
|
||||||
|
await this.getDetailedRouteController.get({
|
||||||
waypoints,
|
waypoints,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export class ActorResponseDto {
|
||||||
|
role: string;
|
||||||
|
target: string;
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { PaginatedResponseDto } from '@mobicoop/ddd-library';
|
||||||
|
|
||||||
|
export abstract class IdPaginatedResponseDto<
|
||||||
|
T,
|
||||||
|
> extends PaginatedResponseDto<T> {
|
||||||
|
readonly id: string;
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { StepResponseDto } from './step.response.dto';
|
||||||
|
|
||||||
|
export class JourneyResponseDto {
|
||||||
|
day: number;
|
||||||
|
firstDate: string;
|
||||||
|
lastDate: string;
|
||||||
|
steps: StepResponseDto[];
|
||||||
|
}
|
|
@ -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[];
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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[];
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { IsLatitude, IsLongitude } from 'class-validator';
|
||||||
|
|
||||||
|
export class CoordinatesDto {
|
||||||
|
@IsLongitude()
|
||||||
|
lon: number;
|
||||||
|
|
||||||
|
@IsLatitude()
|
||||||
|
lat: number;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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'),
|
||||||
|
))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -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,
|
||||||
|
);
|
|
@ -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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { IsInt } from 'class-validator';
|
||||||
|
import { AddressDto } from './address.dto';
|
||||||
|
|
||||||
|
export class WaypointDto extends AddressDto {
|
||||||
|
@IsInt()
|
||||||
|
position: number;
|
||||||
|
}
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ export class AdCreatedMessageHandler {
|
||||||
const createdAd: Ad = JSON.parse(message);
|
const createdAd: Ad = JSON.parse(message);
|
||||||
await this.commandBus.execute(
|
await this.commandBus.execute(
|
||||||
new CreateAdCommand({
|
new CreateAdCommand({
|
||||||
id: createdAd.id,
|
id: createdAd.aggregateId,
|
||||||
driver: createdAd.driver,
|
driver: createdAd.driver,
|
||||||
passenger: createdAd.passenger,
|
passenger: createdAd.passenger,
|
||||||
frequency: createdAd.frequency,
|
frequency: createdAd.frequency,
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { Frequency } from '@modules/ad/core/domain/ad.types';
|
import { Frequency } from '@modules/ad/core/domain/ad.types';
|
||||||
|
|
||||||
export type Ad = {
|
export type Ad = {
|
||||||
id: string;
|
aggregateId: string;
|
||||||
userId: string;
|
|
||||||
driver: boolean;
|
driver: boolean;
|
||||||
passenger: boolean;
|
passenger: boolean;
|
||||||
frequency: Frequency;
|
frequency: Frequency;
|
||||||
|
|
|
@ -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,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -89,12 +89,10 @@ describe('Ad Repository', () => {
|
||||||
strict: false,
|
strict: false,
|
||||||
waypoints: [
|
waypoints: [
|
||||||
{
|
{
|
||||||
position: 0,
|
|
||||||
lon: 43.7102,
|
lon: 43.7102,
|
||||||
lat: 7.262,
|
lat: 7.262,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
position: 1,
|
|
||||||
lon: 43.2965,
|
lon: 43.2965,
|
||||||
lat: 5.3698,
|
lat: 5.3698,
|
||||||
},
|
},
|
||||||
|
@ -126,7 +124,7 @@ describe('Ad Repository', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const adToCreate: AdEntity = AdEntity.create(createAdProps);
|
const adToCreate: AdEntity = AdEntity.create(createAdProps);
|
||||||
await adRepository.insertWithUnsupportedFields(adToCreate, 'ad');
|
await adRepository.insertExtra(adToCreate, 'ad');
|
||||||
|
|
||||||
const afterCount = await prismaService.ad.count();
|
const afterCount = await prismaService.ad.count();
|
||||||
|
|
||||||
|
@ -175,12 +173,10 @@ describe('Ad Repository', () => {
|
||||||
strict: false,
|
strict: false,
|
||||||
waypoints: [
|
waypoints: [
|
||||||
{
|
{
|
||||||
position: 0,
|
|
||||||
lon: 43.7102,
|
lon: 43.7102,
|
||||||
lat: 7.262,
|
lat: 7.262,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
position: 1,
|
|
||||||
lon: 43.2965,
|
lon: 43.2965,
|
||||||
lat: 5.3698,
|
lat: 5.3698,
|
||||||
},
|
},
|
||||||
|
@ -212,7 +208,7 @@ describe('Ad Repository', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const adToCreate: AdEntity = AdEntity.create(createAdProps);
|
const adToCreate: AdEntity = AdEntity.create(createAdProps);
|
||||||
await adRepository.insertWithUnsupportedFields(adToCreate, 'ad');
|
await adRepository.insertExtra(adToCreate, 'ad');
|
||||||
|
|
||||||
const afterCount = await prismaService.ad.count();
|
const afterCount = await prismaService.ad.count();
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { AdEntity } from '@modules/ad/core/domain/ad.entity';
|
||||||
import { Frequency } from '@modules/ad/core/domain/ad.types';
|
import { Frequency } from '@modules/ad/core/domain/ad.types';
|
||||||
import {
|
import {
|
||||||
AdReadModel,
|
AdReadModel,
|
||||||
AdUnsupportedWriteModel,
|
AdWriteExtraModel,
|
||||||
AdWriteModel,
|
AdWriteModel,
|
||||||
} from '@modules/ad/infrastructure/ad.repository';
|
} from '@modules/ad/infrastructure/ad.repository';
|
||||||
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
|
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
|
||||||
|
@ -28,12 +28,10 @@ const adEntity: AdEntity = new AdEntity({
|
||||||
],
|
],
|
||||||
waypoints: [
|
waypoints: [
|
||||||
{
|
{
|
||||||
position: 0,
|
|
||||||
lat: 48.689445,
|
lat: 48.689445,
|
||||||
lon: 6.1765102,
|
lon: 6.1765102,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
position: 1,
|
|
||||||
lat: 48.8566,
|
lat: 48.8566,
|
||||||
lon: 2.3522,
|
lon: 2.3522,
|
||||||
},
|
},
|
||||||
|
@ -84,8 +82,6 @@ const adReadModel: AdReadModel = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
waypoints: "'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'",
|
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,
|
driverDistance: 350000,
|
||||||
driverDuration: 14400,
|
driverDuration: 14400,
|
||||||
passengerDistance: 350000,
|
passengerDistance: 350000,
|
||||||
|
@ -149,8 +145,7 @@ describe('Ad Mapper', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should map domain entity to unsupported db persistence data', async () => {
|
it('should map domain entity to unsupported db persistence data', async () => {
|
||||||
const mapped: AdUnsupportedWriteModel =
|
const mapped: AdWriteExtraModel = adMapper.toPersistenceExtra(adEntity);
|
||||||
adMapper.toUnsupportedPersistence(adEntity);
|
|
||||||
expect(mapped.waypoints).toBe(
|
expect(mapped.waypoints).toBe(
|
||||||
"'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'",
|
"'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().schedule[0].time).toBe('07:05');
|
||||||
expect(mapped.getProps().waypoints.length).toBe(2);
|
expect(mapped.getProps().waypoints.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should map domain entity to response', async () => {
|
|
||||||
expect(adMapper.toResponse(adEntity)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,14 +1,12 @@
|
||||||
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
|
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
|
||||||
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
|
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 = {
|
const originPointProps: PointProps = {
|
||||||
position: 0,
|
|
||||||
lat: 48.689445,
|
lat: 48.689445,
|
||||||
lon: 6.17651,
|
lon: 6.17651,
|
||||||
};
|
};
|
||||||
const destinationWaypointProps: WaypointProps = {
|
const destinationPointProps: PointProps = {
|
||||||
position: 1,
|
|
||||||
lat: 48.8566,
|
lat: 48.8566,
|
||||||
lon: 2.3522,
|
lon: 2.3522,
|
||||||
};
|
};
|
||||||
|
@ -30,7 +28,7 @@ const createAdProps: CreateAdProps = {
|
||||||
seatsProposed: 3,
|
seatsProposed: 3,
|
||||||
seatsRequested: 1,
|
seatsRequested: 1,
|
||||||
strict: false,
|
strict: false,
|
||||||
waypoints: [originWaypointProps, destinationWaypointProps],
|
waypoints: [originPointProps, destinationPointProps],
|
||||||
driverDistance: 23000,
|
driverDistance: 23000,
|
||||||
driverDuration: 900,
|
driverDuration: 900,
|
||||||
passengerDistance: 23000,
|
passengerDistance: 23000,
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 { 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 { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command';
|
||||||
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
|
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 { 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 = {
|
const originWaypoint: PointProps = {
|
||||||
position: 0,
|
|
||||||
lat: 48.689445,
|
lat: 48.689445,
|
||||||
lon: 6.17651,
|
lon: 6.17651,
|
||||||
};
|
};
|
||||||
const destinationWaypoint: WaypointProps = {
|
const destinationWaypoint: PointProps = {
|
||||||
position: 1,
|
|
||||||
lat: 48.8566,
|
lat: 48.8566,
|
||||||
lon: 2.3522,
|
lon: 2.3522,
|
||||||
};
|
};
|
||||||
|
@ -48,9 +46,10 @@ const createAdProps: CreateAdProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockAdRepository = {
|
const mockAdRepository = {
|
||||||
insertWithUnsupportedFields: jest
|
insertExtra: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementationOnce(() => ({}))
|
.mockImplementationOnce(() => ({}))
|
||||||
|
.mockImplementationOnce(() => ({}))
|
||||||
.mockImplementationOnce(() => {
|
.mockImplementationOnce(() => {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
})
|
})
|
||||||
|
@ -60,29 +59,41 @@ const mockAdRepository = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockRouteProvider: RouteProviderPort = {
|
const mockRouteProvider: RouteProviderPort = {
|
||||||
getBasic: jest.fn().mockImplementation(() => ({
|
getBasic: jest
|
||||||
driverDistance: 350101,
|
.fn()
|
||||||
driverDuration: 14422,
|
.mockImplementationOnce(() => {
|
||||||
passengerDistance: 350101,
|
throw new Error();
|
||||||
passengerDuration: 14422,
|
})
|
||||||
fwdAzimuth: 273,
|
.mockImplementationOnce(() => ({
|
||||||
backAzimuth: 93,
|
distance: 350101,
|
||||||
distanceAzimuth: 336544,
|
duration: 14422,
|
||||||
points: [
|
fwdAzimuth: 273,
|
||||||
{
|
backAzimuth: 93,
|
||||||
lon: 6.1765102,
|
distanceAzimuth: 336544,
|
||||||
lat: 48.689445,
|
points: undefined,
|
||||||
},
|
}))
|
||||||
{
|
.mockImplementation(() => ({
|
||||||
lon: 4.984578,
|
distance: 350101,
|
||||||
lat: 48.725687,
|
duration: 14422,
|
||||||
},
|
fwdAzimuth: 273,
|
||||||
{
|
backAzimuth: 93,
|
||||||
lon: 2.3522,
|
distanceAzimuth: 336544,
|
||||||
lat: 48.8566,
|
points: [
|
||||||
},
|
{
|
||||||
],
|
lon: 6.1765102,
|
||||||
})),
|
lat: 48.689445,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lon: 4.984578,
|
||||||
|
lat: 48.725687,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lon: 2.3522,
|
||||||
|
lat: 48.8566,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
getDetailed: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('create-ad.service', () => {
|
describe('create-ad.service', () => {
|
||||||
|
@ -112,7 +123,17 @@ describe('create-ad.service', () => {
|
||||||
|
|
||||||
describe('execution', () => {
|
describe('execution', () => {
|
||||||
const createAdCommand = new CreateAdCommand(createAdProps);
|
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({
|
AdEntity.create = jest.fn().mockReturnValue({
|
||||||
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
|
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
|
||||||
});
|
});
|
||||||
|
@ -121,18 +142,22 @@ describe('create-ad.service', () => {
|
||||||
);
|
);
|
||||||
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
|
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({
|
AdEntity.create = jest.fn().mockReturnValue({
|
||||||
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
|
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(
|
await expect(
|
||||||
createAdService.execute(createAdCommand),
|
createAdService.execute(createAdCommand),
|
||||||
).rejects.toBeInstanceOf(Error);
|
).rejects.toBeInstanceOf(Error);
|
||||||
});
|
});
|
||||||
it('should throw an exception if Ad already exists', async () => {
|
it('should throw an exception if Ad already exists', async () => {
|
||||||
AdEntity.create = jest.fn().mockReturnValue({
|
|
||||||
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
|
|
||||||
});
|
|
||||||
await expect(
|
await expect(
|
||||||
createAdService.execute(createAdCommand),
|
createAdService.execute(createAdCommand),
|
||||||
).rejects.toBeInstanceOf(AdAlreadyExistsException);
|
).rejects.toBeInstanceOf(AdAlreadyExistsException);
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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
Loading…
Reference in New Issue