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_PASSWORD=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_MATCHING_KEY=MATCHER:MATCHING
|
||||
REDIS_MATCHING_TTL=900
|
||||
|
||||
# CACHE
|
||||
CACHE_TTL=5000
|
||||
|
||||
# DEFAULT CONFIGURATION
|
||||
|
||||
# default identifier used for match requests
|
||||
DEFAULT_UUID=00000000-0000-0000-0000-000000000000
|
||||
# default number of seats proposed as driver
|
||||
DEFAULT_SEATS=3
|
||||
# algorithm type
|
||||
ALGORITHM=CLASSIC
|
||||
# strict algorithm (if relevant with the algorithm type)
|
||||
# if set to true, matches are made so that
|
||||
# punctual ads match only with punctual ads and
|
||||
# recurrent ads match only with recurrent ads
|
||||
STRICT_ALGORITHM=0
|
||||
ALGORITHM=PASSENGER_ORIENTED
|
||||
# max distance in metres between driver
|
||||
# route and passenger pick-up / drop-off
|
||||
REMOTENESS=15000
|
||||
# use passenger proportion
|
||||
USE_PROPORTION=1
|
||||
USE_PROPORTION=true
|
||||
# minimal driver proportion
|
||||
PROPORTION=0.3
|
||||
# use azimuth calculation
|
||||
USE_AZIMUTH=1
|
||||
USE_AZIMUTH=true
|
||||
# azimuth margin
|
||||
AZIMUTH_MARGIN=10
|
||||
# margin duration in seconds
|
||||
|
@ -54,3 +47,16 @@ MAX_DETOUR_DURATION_RATIO=0.3
|
|||
GEOROUTER_TYPE=graphhopper
|
||||
# georouter url
|
||||
GEOROUTER_URL=http://localhost:8989
|
||||
# default carpool departure time margin (in seconds)
|
||||
DEPARTURE_TIME_MARGIN=900
|
||||
# default role
|
||||
ROLE=passenger
|
||||
# seats proposes as driver / requested as passenger
|
||||
SEATS_PROPOSED=3
|
||||
SEATS_REQUESTED=1
|
||||
# accept only same frequency requests
|
||||
STRICT_FREQUENCY=false
|
||||
# default timezone
|
||||
TIMEZONE=Europe/Paris
|
||||
# number of matching results per page
|
||||
PER_PAGE=10
|
||||
|
|
229
README.md
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).
|
||||
|
||||
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",
|
||||
"version": "0.0.2",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@mobicoop/matcher",
|
||||
"version": "0.0.2",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL",
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.8.14",
|
||||
"@grpc/proto-loader": "^0.7.6",
|
||||
"@liaoliaots/nestjs-redis": "^9.0.5",
|
||||
"@mobicoop/configuration-module": "^1.2.0",
|
||||
"@mobicoop/ddd-library": "^1.1.0",
|
||||
"@mobicoop/ddd-library": "^1.5.0",
|
||||
"@mobicoop/health-module": "^2.0.0",
|
||||
"@mobicoop/message-broker-module": "^1.2.0",
|
||||
"@nestjs/axios": "^2.0.0",
|
||||
|
@ -1505,9 +1505,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@mobicoop/ddd-library": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-1.1.0.tgz",
|
||||
"integrity": "sha512-x4X7j2CJYZQPDZgLuZP5TFk59fle1wTPdX++Z2YyD7VwwV+yOmVvMIRfTyLRFUTzLObrd6FKs8mh+g59n9jUlA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-1.5.0.tgz",
|
||||
"integrity": "sha512-CX/V2+vSXrGtKobsyBfVpMW323ZT8tHrgUl1qrvU1XjRKNShvwsKyC7739x7CNgkJ9sr3XV+75JrOXEnqU83zw==",
|
||||
"dependencies": {
|
||||
"@nestjs/event-emitter": "^1.4.2",
|
||||
"@nestjs/microservices": "^9.4.0",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mobicoop/matcher",
|
||||
"version": "0.0.2",
|
||||
"version": "1.0.0",
|
||||
"description": "Mobicoop V3 Matcher",
|
||||
"author": "sbriat",
|
||||
"private": true,
|
||||
|
@ -34,7 +34,7 @@
|
|||
"@grpc/proto-loader": "^0.7.6",
|
||||
"@liaoliaots/nestjs-redis": "^9.0.5",
|
||||
"@mobicoop/configuration-module": "^1.2.0",
|
||||
"@mobicoop/ddd-library": "^1.1.0",
|
||||
"@mobicoop/ddd-library": "^1.5.0",
|
||||
"@mobicoop/health-module": "^2.0.0",
|
||||
"@mobicoop/message-broker-module": "^1.2.0",
|
||||
"@nestjs/axios": "^2.0.0",
|
||||
|
|
|
@ -26,15 +26,19 @@ import { GeographyModule } from '@modules/geography/geography.module';
|
|||
useFactory: async (
|
||||
configService: ConfigService,
|
||||
): Promise<ConfigurationModuleOptions> => ({
|
||||
domain: configService.get<string>('SERVICE_CONFIGURATION_DOMAIN'),
|
||||
domain: configService.get<string>(
|
||||
'SERVICE_CONFIGURATION_DOMAIN',
|
||||
) as string,
|
||||
messageBroker: {
|
||||
uri: configService.get<string>('MESSAGE_BROKER_URI'),
|
||||
exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE'),
|
||||
uri: configService.get<string>('MESSAGE_BROKER_URI') as string,
|
||||
exchange: configService.get<string>(
|
||||
'MESSAGE_BROKER_EXCHANGE',
|
||||
) as string,
|
||||
},
|
||||
redis: {
|
||||
host: configService.get<string>('REDIS_HOST'),
|
||||
host: configService.get<string>('REDIS_HOST') as string,
|
||||
password: configService.get<string>('REDIS_PASSWORD'),
|
||||
port: configService.get<number>('REDIS_PORT'),
|
||||
port: configService.get<number>('REDIS_PORT') as number,
|
||||
},
|
||||
setConfigurationBrokerQueue: 'matcher-configuration-create-update',
|
||||
deleteConfigurationQueue: 'matcher-configuration-delete',
|
||||
|
|
11
src/main.ts
11
src/main.ts
|
@ -11,14 +11,17 @@ async function bootstrap() {
|
|||
app.connectMicroservice<MicroserviceOptions>({
|
||||
transport: Transport.GRPC,
|
||||
options: {
|
||||
package: ['health'],
|
||||
protoPath: [join(__dirname, 'health.proto')],
|
||||
package: ['matcher', 'health'],
|
||||
protoPath: [
|
||||
join(__dirname, 'modules/ad/interface/grpc-controllers/matcher.proto'),
|
||||
join(__dirname, 'health.proto'),
|
||||
],
|
||||
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
|
||||
loader: { keepCase: true },
|
||||
loader: { keepCase: true, enums: String },
|
||||
},
|
||||
});
|
||||
|
||||
await app.startAllMicroservices();
|
||||
await app.listen(process.env.HEALTH_SERVICE_PORT);
|
||||
await app.listen(process.env.HEALTH_SERVICE_PORT as string);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
export const AD_REPOSITORY = Symbol('AD_REPOSITORY');
|
||||
export const MATCHING_REPOSITORY = Symbol('MATCHING_REPOSITORY');
|
||||
export const AD_DIRECTION_ENCODER = Symbol('AD_DIRECTION_ENCODER');
|
||||
export const AD_MESSAGE_PUBLISHER = Symbol('AD_MESSAGE_PUBLISHER');
|
||||
export const AD_GET_BASIC_ROUTE_CONTROLLER = Symbol(
|
||||
'AD_GET_BASIC_ROUTE_CONTROLLER',
|
||||
);
|
||||
export const AD_GET_DETAILED_ROUTE_CONTROLLER = Symbol(
|
||||
'AD_GET_DETAILED_ROUTE_CONTROLLER',
|
||||
);
|
||||
export const AD_ROUTE_PROVIDER = Symbol('AD_ROUTE_PROVIDER');
|
||||
export const PARAMS_PROVIDER = Symbol('PARAMS_PROVIDER');
|
||||
export const TIMEZONE_FINDER = Symbol('TIMEZONE_FINDER');
|
||||
export const TIME_CONVERTER = Symbol('TIME_CONVERTER');
|
||||
export const INPUT_DATETIME_TRANSFORMER = Symbol('INPUT_DATETIME_TRANSFORMER');
|
||||
export const OUTPUT_DATETIME_TRANSFORMER = Symbol(
|
||||
'OUTPUT_DATETIME_TRANSFORMER',
|
||||
);
|
||||
|
|
|
@ -4,11 +4,13 @@ import {
|
|||
AdWriteModel,
|
||||
AdReadModel,
|
||||
ScheduleItemModel,
|
||||
AdUnsupportedWriteModel,
|
||||
AdWriteExtraModel,
|
||||
} from './infrastructure/ad.repository';
|
||||
import { Frequency } from './core/domain/ad.types';
|
||||
import { v4 } from 'uuid';
|
||||
import { ScheduleItemProps } from './core/domain/value-objects/schedule-item.value-object';
|
||||
import {
|
||||
ScheduleItem,
|
||||
ScheduleItemProps,
|
||||
} from './core/domain/value-objects/schedule-item.value-object';
|
||||
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
|
||||
import { AD_DIRECTION_ENCODER } from './ad.di-tokens';
|
||||
import { ExtendedMapper } from '@mobicoop/ddd-library';
|
||||
|
@ -27,7 +29,7 @@ export class AdMapper
|
|||
AdEntity,
|
||||
AdReadModel,
|
||||
AdWriteModel,
|
||||
AdUnsupportedWriteModel,
|
||||
AdWriteExtraModel,
|
||||
undefined
|
||||
>
|
||||
{
|
||||
|
@ -77,28 +79,12 @@ export class AdMapper
|
|||
return record;
|
||||
};
|
||||
|
||||
toDomain = (record: AdReadModel): AdEntity => {
|
||||
const entity = new AdEntity({
|
||||
toDomain = (record: AdReadModel): AdEntity =>
|
||||
new AdEntity({
|
||||
id: record.uuid,
|
||||
createdAt: new Date(record.createdAt),
|
||||
updatedAt: new Date(record.updatedAt),
|
||||
props: {
|
||||
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,
|
||||
seatsRequested: record.seatsRequested,
|
||||
strict: record.strict,
|
||||
|
@ -106,6 +92,25 @@ export class AdMapper
|
|||
driverDistance: record.driverDistance,
|
||||
passengerDuration: record.passengerDuration,
|
||||
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
|
||||
.decode(record.waypoints)
|
||||
.map((coordinates, index) => ({
|
||||
|
@ -117,15 +122,8 @@ export class AdMapper
|
|||
points: [],
|
||||
},
|
||||
});
|
||||
return entity;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
toResponse = (entity: AdEntity): undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
toUnsupportedPersistence = (entity: AdEntity): AdUnsupportedWriteModel => ({
|
||||
toPersistenceExtra = (entity: AdEntity): AdWriteExtraModel => ({
|
||||
waypoints: this.directionEncoder.encode(entity.getProps().waypoints),
|
||||
direction: this.directionEncoder.encode(entity.getProps().points),
|
||||
});
|
||||
|
|
|
@ -6,6 +6,13 @@ import {
|
|||
AD_DIRECTION_ENCODER,
|
||||
AD_ROUTE_PROVIDER,
|
||||
AD_GET_BASIC_ROUTE_CONTROLLER,
|
||||
PARAMS_PROVIDER,
|
||||
TIMEZONE_FINDER,
|
||||
TIME_CONVERTER,
|
||||
INPUT_DATETIME_TRANSFORMER,
|
||||
AD_GET_DETAILED_ROUTE_CONTROLLER,
|
||||
OUTPUT_DATETIME_TRANSFORMER,
|
||||
MATCHING_REPOSITORY,
|
||||
} from './ad.di-tokens';
|
||||
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
|
||||
import { AdRepository } from './infrastructure/ad.repository';
|
||||
|
@ -17,18 +24,37 @@ import { GetBasicRouteController } from '@modules/geography/interface/controller
|
|||
import { RouteProvider } from './infrastructure/route-provider';
|
||||
import { GeographyModule } from '@modules/geography/geography.module';
|
||||
import { CreateAdService } from './core/application/commands/create-ad/create-ad.service';
|
||||
import { MatchGrpcController } from './interface/grpc-controllers/match.grpc-controller';
|
||||
import { MatchQueryHandler } from './core/application/queries/match/match.query-handler';
|
||||
import { DefaultParamsProvider } from './infrastructure/default-params-provider';
|
||||
import { TimezoneFinder } from './infrastructure/timezone-finder';
|
||||
import { TimeConverter } from './infrastructure/time-converter';
|
||||
import { InputDateTimeTransformer } from './infrastructure/input-datetime-transformer';
|
||||
import { GetDetailedRouteController } from '@modules/geography/interface/controllers/get-detailed-route.controller';
|
||||
import { MatchMapper } from './match.mapper';
|
||||
import { OutputDateTimeTransformer } from './infrastructure/output-datetime-transformer';
|
||||
import { MatchingRepository } from './infrastructure/matching.repository';
|
||||
import { MatchingMapper } from './matching.mapper';
|
||||
|
||||
const grpcControllers = [MatchGrpcController];
|
||||
|
||||
const messageHandlers = [AdCreatedMessageHandler];
|
||||
|
||||
const commandHandlers: Provider[] = [CreateAdService];
|
||||
|
||||
const mappers: Provider[] = [AdMapper];
|
||||
const queryHandlers: Provider[] = [MatchQueryHandler];
|
||||
|
||||
const mappers: Provider[] = [AdMapper, MatchMapper, MatchingMapper];
|
||||
|
||||
const repositories: Provider[] = [
|
||||
{
|
||||
provide: AD_REPOSITORY,
|
||||
useClass: AdRepository,
|
||||
},
|
||||
{
|
||||
provide: MATCHING_REPOSITORY,
|
||||
useClass: MatchingRepository,
|
||||
},
|
||||
];
|
||||
|
||||
const messagePublishers: Provider[] = [
|
||||
|
@ -53,19 +79,51 @@ const adapters: Provider[] = [
|
|||
provide: AD_GET_BASIC_ROUTE_CONTROLLER,
|
||||
useClass: GetBasicRouteController,
|
||||
},
|
||||
{
|
||||
provide: AD_GET_DETAILED_ROUTE_CONTROLLER,
|
||||
useClass: GetDetailedRouteController,
|
||||
},
|
||||
{
|
||||
provide: PARAMS_PROVIDER,
|
||||
useClass: DefaultParamsProvider,
|
||||
},
|
||||
{
|
||||
provide: TIMEZONE_FINDER,
|
||||
useClass: TimezoneFinder,
|
||||
},
|
||||
{
|
||||
provide: TIME_CONVERTER,
|
||||
useClass: TimeConverter,
|
||||
},
|
||||
{
|
||||
provide: INPUT_DATETIME_TRANSFORMER,
|
||||
useClass: InputDateTimeTransformer,
|
||||
},
|
||||
{
|
||||
provide: OUTPUT_DATETIME_TRANSFORMER,
|
||||
useClass: OutputDateTimeTransformer,
|
||||
},
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, GeographyModule],
|
||||
controllers: [...grpcControllers],
|
||||
providers: [
|
||||
...messageHandlers,
|
||||
...commandHandlers,
|
||||
...queryHandlers,
|
||||
...mappers,
|
||||
...repositories,
|
||||
...messagePublishers,
|
||||
...orms,
|
||||
...adapters,
|
||||
],
|
||||
exports: [PrismaService, AdMapper, AD_REPOSITORY, AD_DIRECTION_ENCODER],
|
||||
exports: [
|
||||
PrismaService,
|
||||
AdMapper,
|
||||
AD_REPOSITORY,
|
||||
AD_DIRECTION_ENCODER,
|
||||
AD_MESSAGE_PUBLISHER,
|
||||
],
|
||||
})
|
||||
export class AdModule {}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Frequency } from '@modules/ad/core/domain/ad.types';
|
||||
import { Command, CommandProps } from '@mobicoop/ddd-library';
|
||||
import { ScheduleItem } from '../../types/schedule-item.type';
|
||||
import { Waypoint } from '../../types/waypoint.type';
|
||||
import { Address } from '../../types/address.type';
|
||||
|
||||
export class CreateAdCommand extends Command {
|
||||
readonly id: string;
|
||||
|
@ -14,11 +14,11 @@ export class CreateAdCommand extends Command {
|
|||
readonly seatsProposed: number;
|
||||
readonly seatsRequested: number;
|
||||
readonly strict: boolean;
|
||||
readonly waypoints: Waypoint[];
|
||||
readonly waypoints: Address[];
|
||||
|
||||
constructor(props: CommandProps<CreateAdCommand>) {
|
||||
super(props);
|
||||
this.id = props.id;
|
||||
this.id = props.id as string;
|
||||
this.driver = props.driver;
|
||||
this.passenger = props.passenger;
|
||||
this.frequency = props.frequency;
|
||||
|
|
|
@ -8,7 +8,15 @@ import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
|
|||
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
|
||||
import { RouteProviderPort } from '../../ports/route-provider.port';
|
||||
import { Role } from '@modules/ad/core/domain/ad.types';
|
||||
import { Route } from '../../types/route.type';
|
||||
import {
|
||||
Path,
|
||||
PathCreator,
|
||||
PathType,
|
||||
TypedRoute,
|
||||
} from '@modules/ad/core/domain/path-creator.service';
|
||||
import { Waypoint } from '../../types/waypoint.type';
|
||||
import { Point as PointValueObject } from '@modules/ad/core/domain/value-objects/point.value-object';
|
||||
import { Point } from '@modules/geography/core/domain/route.types';
|
||||
|
||||
@CommandHandler(CreateAdCommand)
|
||||
export class CreateAdService implements ICommandHandler {
|
||||
|
@ -23,10 +31,70 @@ export class CreateAdService implements ICommandHandler {
|
|||
const roles: Role[] = [];
|
||||
if (command.driver) roles.push(Role.DRIVER);
|
||||
if (command.passenger) roles.push(Role.PASSENGER);
|
||||
const route: Route = await this.routeProvider.getBasic(
|
||||
|
||||
const pathCreator: PathCreator = new PathCreator(
|
||||
roles,
|
||||
command.waypoints,
|
||||
command.waypoints.map(
|
||||
(waypoint: Waypoint) =>
|
||||
new PointValueObject({
|
||||
lon: waypoint.lon,
|
||||
lat: waypoint.lat,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
let typedRoutes: TypedRoute[];
|
||||
try {
|
||||
typedRoutes = await Promise.all(
|
||||
pathCreator.getBasePaths().map(async (path: Path) => ({
|
||||
type: path.type,
|
||||
route: await this.routeProvider.getBasic(path.waypoints),
|
||||
})),
|
||||
);
|
||||
} catch (e: any) {
|
||||
throw new Error('Unable to find a route for given waypoints');
|
||||
}
|
||||
|
||||
let driverDistance: number | undefined;
|
||||
let driverDuration: number | undefined;
|
||||
let passengerDistance: number | undefined;
|
||||
let passengerDuration: number | undefined;
|
||||
let points: PointValueObject[] | undefined;
|
||||
let fwdAzimuth: number | undefined;
|
||||
let backAzimuth: number | undefined;
|
||||
try {
|
||||
typedRoutes.forEach((typedRoute: TypedRoute) => {
|
||||
if ([PathType.DRIVER, PathType.GENERIC].includes(typedRoute.type)) {
|
||||
driverDistance = typedRoute.route.distance;
|
||||
driverDuration = typedRoute.route.duration;
|
||||
points = typedRoute.route.points.map(
|
||||
(point: Point) =>
|
||||
new PointValueObject({
|
||||
lon: point.lon,
|
||||
lat: point.lat,
|
||||
}),
|
||||
);
|
||||
fwdAzimuth = typedRoute.route.fwdAzimuth;
|
||||
backAzimuth = typedRoute.route.backAzimuth;
|
||||
}
|
||||
if ([PathType.PASSENGER, PathType.GENERIC].includes(typedRoute.type)) {
|
||||
passengerDistance = typedRoute.route.distance;
|
||||
passengerDuration = typedRoute.route.duration;
|
||||
if (!points)
|
||||
points = typedRoute.route.points.map(
|
||||
(point: Point) =>
|
||||
new PointValueObject({
|
||||
lon: point.lon,
|
||||
lat: point.lat,
|
||||
}),
|
||||
);
|
||||
if (!fwdAzimuth) fwdAzimuth = typedRoute.route.fwdAzimuth;
|
||||
if (!backAzimuth) backAzimuth = typedRoute.route.backAzimuth;
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
throw new Error('Invalid route');
|
||||
}
|
||||
const ad = AdEntity.create({
|
||||
id: command.id,
|
||||
driver: command.driver,
|
||||
|
@ -39,17 +107,16 @@ export class CreateAdService implements ICommandHandler {
|
|||
seatsRequested: command.seatsRequested,
|
||||
strict: command.strict,
|
||||
waypoints: command.waypoints,
|
||||
points: route.points,
|
||||
driverDistance: route.driverDistance,
|
||||
driverDuration: route.driverDuration,
|
||||
passengerDistance: route.passengerDistance,
|
||||
passengerDuration: route.passengerDuration,
|
||||
fwdAzimuth: route.fwdAzimuth,
|
||||
backAzimuth: route.backAzimuth,
|
||||
points: points as PointValueObject[],
|
||||
driverDistance,
|
||||
driverDuration,
|
||||
passengerDistance,
|
||||
passengerDuration,
|
||||
fwdAzimuth: fwdAzimuth as number,
|
||||
backAzimuth: backAzimuth as number,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.repository.insertWithUnsupportedFields(ad, 'ad');
|
||||
await this.repository.insertExtra(ad, 'ad');
|
||||
return ad.id;
|
||||
} catch (error: any) {
|
||||
if (error instanceof ConflictException) {
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { ExtendedRepositoryPort } from '@mobicoop/ddd-library';
|
||||
import { AdEntity } from '../../domain/ad.entity';
|
||||
|
||||
export type AdRepositoryPort = ExtendedRepositoryPort<AdEntity>;
|
||||
export type AdRepositoryPort = ExtendedRepositoryPort<AdEntity> & {
|
||||
getCandidateAds(queryString: string): Promise<AdEntity[]>;
|
||||
};
|
||||
|
|
|
@ -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 { Waypoint } from '../types/waypoint.type';
|
||||
import { Point } from '../types/point.type';
|
||||
import { Route } from '../types/route.type';
|
||||
|
||||
export interface RouteProviderPort {
|
||||
/**
|
||||
* Get a basic route with points and overall duration / distance
|
||||
* Get a basic route :
|
||||
* - simple points (coordinates only)
|
||||
* - overall duration
|
||||
* - overall distance
|
||||
*/
|
||||
getBasic(roles: Role[], waypoints: Waypoint[]): Promise<Route>;
|
||||
getBasic(waypoints: Point[]): Promise<Route>;
|
||||
/**
|
||||
* Get a detailed route :
|
||||
* - detailed points (coordinates and time / distance to reach the point)
|
||||
* - overall duration
|
||||
* - overall distance
|
||||
*/
|
||||
getDetailed(waypoints: Point[]): Promise<Route>;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
lat: number;
|
||||
};
|
|
@ -1,11 +1,12 @@
|
|||
import { Coordinates } from './coordinates.type';
|
||||
import { Point } from './point.type';
|
||||
import { Step } from './step.type';
|
||||
|
||||
export type Route = {
|
||||
driverDistance?: number;
|
||||
driverDuration?: number;
|
||||
passengerDistance?: number;
|
||||
passengerDuration?: number;
|
||||
distance: number;
|
||||
duration: number;
|
||||
fwdAzimuth: number;
|
||||
backAzimuth: number;
|
||||
points: Coordinates[];
|
||||
distanceAzimuth: number;
|
||||
points: Point[];
|
||||
steps?: Step[];
|
||||
};
|
||||
|
|
|
@ -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 = {
|
||||
position: number;
|
||||
} & Coordinates;
|
||||
} & Address;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { PointProps } from './value-objects/point.value-object';
|
||||
import { ScheduleItemProps } from './value-objects/schedule-item.value-object';
|
||||
import { WaypointProps } from './value-objects/waypoint.value-object';
|
||||
|
||||
// All properties that an Ad has
|
||||
export interface AdProps {
|
||||
|
@ -17,7 +16,7 @@ export interface AdProps {
|
|||
driverDistance?: number;
|
||||
passengerDuration?: number;
|
||||
passengerDistance?: number;
|
||||
waypoints: WaypointProps[];
|
||||
waypoints: PointProps[];
|
||||
points: PointProps[];
|
||||
fwdAzimuth: number;
|
||||
backAzimuth: number;
|
||||
|
@ -35,7 +34,7 @@ export interface CreateAdProps {
|
|||
seatsProposed: number;
|
||||
seatsRequested: number;
|
||||
strict: boolean;
|
||||
waypoints: WaypointProps[];
|
||||
waypoints: PointProps[];
|
||||
driverDuration?: number;
|
||||
driverDistance?: number;
|
||||
passengerDuration?: number;
|
||||
|
|
|
@ -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> {
|
||||
get day(): number | undefined {
|
||||
get day(): number {
|
||||
return this.props.day;
|
||||
}
|
||||
|
||||
|
@ -24,11 +24,10 @@ export class ScheduleItem extends ValueObject<ScheduleItemProps> {
|
|||
return this.props.time;
|
||||
}
|
||||
|
||||
get margin(): number | undefined {
|
||||
get margin(): number {
|
||||
return this.props.margin;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected validate(props: ScheduleItemProps): void {
|
||||
if (props.day < 0 || props.day > 6)
|
||||
throw new ArgumentOutOfRangeException('day must be between 0 and 6');
|
||||
|
|
|
@ -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 { AdMapper } from '../ad.mapper';
|
||||
import { ExtendedPrismaRepositoryBase } from '@mobicoop/ddd-library/dist/db/prisma-repository.base';
|
||||
import { Frequency } from '../core/domain/ad.types';
|
||||
|
||||
export type AdBaseModel = {
|
||||
export type AdModel = {
|
||||
uuid: string;
|
||||
driver: boolean;
|
||||
passenger: boolean;
|
||||
frequency: string;
|
||||
frequency: Frequency;
|
||||
fromDate: Date;
|
||||
toDate: Date;
|
||||
seatsProposed: number;
|
||||
seatsRequested: number;
|
||||
strict: boolean;
|
||||
driverDuration: number;
|
||||
driverDistance: number;
|
||||
passengerDuration: number;
|
||||
passengerDistance: number;
|
||||
driverDuration?: number;
|
||||
driverDistance?: number;
|
||||
passengerDuration?: number;
|
||||
passengerDistance?: number;
|
||||
fwdAzimuth: number;
|
||||
backAzimuth: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type AdReadModel = AdBaseModel & {
|
||||
/**
|
||||
* The record as returned by the persistence system
|
||||
*/
|
||||
export type AdReadModel = AdModel & {
|
||||
waypoints: string;
|
||||
direction: string;
|
||||
schedule: ScheduleItemModel[];
|
||||
};
|
||||
|
||||
export type AdWriteModel = AdBaseModel & {
|
||||
/**
|
||||
* The record ready to be sent to the peristence system
|
||||
*/
|
||||
export type AdWriteModel = AdModel & {
|
||||
schedule: {
|
||||
create: ScheduleItemModel[];
|
||||
};
|
||||
};
|
||||
|
||||
export type AdUnsupportedWriteModel = {
|
||||
export type AdWriteExtraModel = {
|
||||
waypoints: string;
|
||||
direction: string;
|
||||
};
|
||||
|
||||
export type ScheduleItemModel = {
|
||||
uuid: string;
|
||||
export type ScheduleItem = {
|
||||
day: number;
|
||||
time: Date;
|
||||
margin: number;
|
||||
};
|
||||
|
||||
export type ScheduleItemModel = ScheduleItem & {
|
||||
uuid: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type UngroupedAdModel = AdModel &
|
||||
ScheduleItem & {
|
||||
scheduleItemUuid: string;
|
||||
scheduleItemCreatedAt: Date;
|
||||
scheduleItemUpdatedAt: Date;
|
||||
waypoints: string;
|
||||
};
|
||||
|
||||
export type GroupedAdModel = AdModel & {
|
||||
schedule: ScheduleItemModel[];
|
||||
waypoints: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Repository is used for retrieving/saving domain entities
|
||||
* */
|
||||
|
@ -63,7 +85,7 @@ export class AdRepository
|
|||
AdEntity,
|
||||
AdReadModel,
|
||||
AdWriteModel,
|
||||
AdUnsupportedWriteModel
|
||||
AdWriteExtraModel
|
||||
>
|
||||
implements AdRepositoryPort
|
||||
{
|
||||
|
@ -86,4 +108,64 @@ export class AdRepository
|
|||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getCandidateAds = async (queryString: string): Promise<AdEntity[]> =>
|
||||
this._toAdReadModels(
|
||||
(await this.prismaRaw.$queryRawUnsafe(queryString)) as UngroupedAdModel[],
|
||||
)
|
||||
.map((adReadModel: AdReadModel) => {
|
||||
if (this.mapper.toDomain) return this.mapper.toDomain(adReadModel);
|
||||
})
|
||||
.filter(
|
||||
(adEntity: AdEntity | undefined) => adEntity !== undefined,
|
||||
) as AdEntity[];
|
||||
|
||||
private _toAdReadModels = (
|
||||
ungroupedAds: UngroupedAdModel[],
|
||||
): AdReadModel[] => {
|
||||
const groupedAdModels: GroupedAdModel[] = ungroupedAds.map(
|
||||
(ungroupedAd: UngroupedAdModel) => ({
|
||||
uuid: ungroupedAd.uuid,
|
||||
driver: ungroupedAd.driver,
|
||||
passenger: ungroupedAd.passenger,
|
||||
frequency: ungroupedAd.frequency,
|
||||
fromDate: ungroupedAd.fromDate,
|
||||
toDate: ungroupedAd.toDate,
|
||||
schedule: [
|
||||
{
|
||||
uuid: ungroupedAd.scheduleItemUuid,
|
||||
day: ungroupedAd.day,
|
||||
time: ungroupedAd.time,
|
||||
margin: ungroupedAd.margin,
|
||||
createdAt: ungroupedAd.scheduleItemCreatedAt,
|
||||
updatedAt: ungroupedAd.scheduleItemUpdatedAt,
|
||||
},
|
||||
],
|
||||
seatsProposed: ungroupedAd.seatsProposed,
|
||||
seatsRequested: ungroupedAd.seatsRequested,
|
||||
strict: ungroupedAd.strict,
|
||||
driverDuration: ungroupedAd.driverDuration,
|
||||
driverDistance: ungroupedAd.driverDistance,
|
||||
passengerDuration: ungroupedAd.passengerDuration,
|
||||
passengerDistance: ungroupedAd.passengerDistance,
|
||||
fwdAzimuth: ungroupedAd.fwdAzimuth,
|
||||
backAzimuth: ungroupedAd.backAzimuth,
|
||||
waypoints: ungroupedAd.waypoints,
|
||||
createdAt: ungroupedAd.createdAt,
|
||||
updatedAt: ungroupedAd.updatedAt,
|
||||
}),
|
||||
);
|
||||
const adReadModels: AdReadModel[] = [];
|
||||
groupedAdModels.forEach((groupdeAdModel: GroupedAdModel) => {
|
||||
const adReadModel: AdReadModel | undefined = adReadModels.find(
|
||||
(arm: AdReadModel) => arm.uuid == groupdeAdModel.uuid,
|
||||
);
|
||||
if (adReadModel) {
|
||||
adReadModel.schedule.push(...groupdeAdModel.schedule);
|
||||
} else {
|
||||
adReadModels.push(groupdeAdModel);
|
||||
}
|
||||
});
|
||||
return adReadModels;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 { RouteProviderPort } from '../core/application/ports/route-provider.port';
|
||||
import { Route } from '../core/application/types/route.type';
|
||||
import { Waypoint } from '../core/application/types/waypoint.type';
|
||||
import { Role } from '../core/domain/ad.types';
|
||||
import { GetBasicRouteControllerPort } from '@modules/geography/core/application/ports/get-basic-route-controller.port';
|
||||
import { AD_GET_BASIC_ROUTE_CONTROLLER } from '../ad.di-tokens';
|
||||
import { GetRouteControllerPort } from '@modules/geography/core/application/ports/get-route-controller.port';
|
||||
import {
|
||||
AD_GET_BASIC_ROUTE_CONTROLLER,
|
||||
AD_GET_DETAILED_ROUTE_CONTROLLER,
|
||||
} from '../ad.di-tokens';
|
||||
import { Point, Route } from '@modules/geography/core/domain/route.types';
|
||||
|
||||
@Injectable()
|
||||
export class RouteProvider implements RouteProviderPort {
|
||||
constructor(
|
||||
@Inject(AD_GET_BASIC_ROUTE_CONTROLLER)
|
||||
private readonly getBasicRouteController: GetBasicRouteControllerPort,
|
||||
private readonly getBasicRouteController: GetRouteControllerPort,
|
||||
@Inject(AD_GET_DETAILED_ROUTE_CONTROLLER)
|
||||
private readonly getDetailedRouteController: GetRouteControllerPort,
|
||||
) {}
|
||||
|
||||
getBasic = async (roles: Role[], waypoints: Waypoint[]): Promise<Route> =>
|
||||
getBasic = async (waypoints: Point[]): Promise<Route> =>
|
||||
await this.getBasicRouteController.get({
|
||||
roles,
|
||||
waypoints,
|
||||
});
|
||||
|
||||
getDetailed = async (waypoints: Point[]): Promise<Route> =>
|
||||
await this.getDetailedRouteController.get({
|
||||
waypoints,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
await this.commandBus.execute(
|
||||
new CreateAdCommand({
|
||||
id: createdAd.id,
|
||||
id: createdAd.aggregateId,
|
||||
driver: createdAd.driver,
|
||||
passenger: createdAd.passenger,
|
||||
frequency: createdAd.frequency,
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { Frequency } from '@modules/ad/core/domain/ad.types';
|
||||
|
||||
export type Ad = {
|
||||
id: string;
|
||||
userId: string;
|
||||
aggregateId: string;
|
||||
driver: boolean;
|
||||
passenger: boolean;
|
||||
frequency: Frequency;
|
||||
|
|
|
@ -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,
|
||||
waypoints: [
|
||||
{
|
||||
position: 0,
|
||||
lon: 43.7102,
|
||||
lat: 7.262,
|
||||
},
|
||||
{
|
||||
position: 1,
|
||||
lon: 43.2965,
|
||||
lat: 5.3698,
|
||||
},
|
||||
|
@ -126,7 +124,7 @@ describe('Ad Repository', () => {
|
|||
};
|
||||
|
||||
const adToCreate: AdEntity = AdEntity.create(createAdProps);
|
||||
await adRepository.insertWithUnsupportedFields(adToCreate, 'ad');
|
||||
await adRepository.insertExtra(adToCreate, 'ad');
|
||||
|
||||
const afterCount = await prismaService.ad.count();
|
||||
|
||||
|
@ -175,12 +173,10 @@ describe('Ad Repository', () => {
|
|||
strict: false,
|
||||
waypoints: [
|
||||
{
|
||||
position: 0,
|
||||
lon: 43.7102,
|
||||
lat: 7.262,
|
||||
},
|
||||
{
|
||||
position: 1,
|
||||
lon: 43.2965,
|
||||
lat: 5.3698,
|
||||
},
|
||||
|
@ -212,7 +208,7 @@ describe('Ad Repository', () => {
|
|||
};
|
||||
|
||||
const adToCreate: AdEntity = AdEntity.create(createAdProps);
|
||||
await adRepository.insertWithUnsupportedFields(adToCreate, 'ad');
|
||||
await adRepository.insertExtra(adToCreate, 'ad');
|
||||
|
||||
const afterCount = await prismaService.ad.count();
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { AdEntity } from '@modules/ad/core/domain/ad.entity';
|
|||
import { Frequency } from '@modules/ad/core/domain/ad.types';
|
||||
import {
|
||||
AdReadModel,
|
||||
AdUnsupportedWriteModel,
|
||||
AdWriteExtraModel,
|
||||
AdWriteModel,
|
||||
} from '@modules/ad/infrastructure/ad.repository';
|
||||
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
|
||||
|
@ -28,12 +28,10 @@ const adEntity: AdEntity = new AdEntity({
|
|||
],
|
||||
waypoints: [
|
||||
{
|
||||
position: 0,
|
||||
lat: 48.689445,
|
||||
lon: 6.1765102,
|
||||
},
|
||||
{
|
||||
position: 1,
|
||||
lat: 48.8566,
|
||||
lon: 2.3522,
|
||||
},
|
||||
|
@ -84,8 +82,6 @@ const adReadModel: AdReadModel = {
|
|||
},
|
||||
],
|
||||
waypoints: "'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'",
|
||||
direction:
|
||||
"'LINESTRING(6.1765102 48.689445,5.12345 48.76543,2.3522 48.8566)'",
|
||||
driverDistance: 350000,
|
||||
driverDuration: 14400,
|
||||
passengerDistance: 350000,
|
||||
|
@ -149,8 +145,7 @@ describe('Ad Mapper', () => {
|
|||
});
|
||||
|
||||
it('should map domain entity to unsupported db persistence data', async () => {
|
||||
const mapped: AdUnsupportedWriteModel =
|
||||
adMapper.toUnsupportedPersistence(adEntity);
|
||||
const mapped: AdWriteExtraModel = adMapper.toPersistenceExtra(adEntity);
|
||||
expect(mapped.waypoints).toBe(
|
||||
"'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'",
|
||||
);
|
||||
|
@ -165,8 +160,4 @@ describe('Ad Mapper', () => {
|
|||
expect(mapped.getProps().schedule[0].time).toBe('07:05');
|
||||
expect(mapped.getProps().waypoints.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should map domain entity to response', async () => {
|
||||
expect(adMapper.toResponse(adEntity)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
|
||||
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
|
||||
import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object';
|
||||
|
||||
const originWaypointProps: WaypointProps = {
|
||||
position: 0,
|
||||
const originPointProps: PointProps = {
|
||||
lat: 48.689445,
|
||||
lon: 6.17651,
|
||||
};
|
||||
const destinationWaypointProps: WaypointProps = {
|
||||
position: 1,
|
||||
const destinationPointProps: PointProps = {
|
||||
lat: 48.8566,
|
||||
lon: 2.3522,
|
||||
};
|
||||
|
@ -30,7 +28,7 @@ const createAdProps: CreateAdProps = {
|
|||
seatsProposed: 3,
|
||||
seatsRequested: 1,
|
||||
strict: false,
|
||||
waypoints: [originWaypointProps, destinationWaypointProps],
|
||||
waypoints: [originPointProps, destinationPointProps],
|
||||
driverDistance: 23000,
|
||||
driverDuration: 900,
|
||||
passengerDistance: 23000,
|
||||
|
|
|
@ -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 { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command';
|
||||
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
|
||||
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
|
||||
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
|
||||
import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object';
|
||||
|
||||
const originWaypoint: WaypointProps = {
|
||||
position: 0,
|
||||
const originWaypoint: PointProps = {
|
||||
lat: 48.689445,
|
||||
lon: 6.17651,
|
||||
};
|
||||
const destinationWaypoint: WaypointProps = {
|
||||
position: 1,
|
||||
const destinationWaypoint: PointProps = {
|
||||
lat: 48.8566,
|
||||
lon: 2.3522,
|
||||
};
|
||||
|
@ -48,9 +46,10 @@ const createAdProps: CreateAdProps = {
|
|||
};
|
||||
|
||||
const mockAdRepository = {
|
||||
insertWithUnsupportedFields: jest
|
||||
insertExtra: jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => ({}))
|
||||
.mockImplementationOnce(() => ({}))
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error();
|
||||
})
|
||||
|
@ -60,29 +59,41 @@ const mockAdRepository = {
|
|||
};
|
||||
|
||||
const mockRouteProvider: RouteProviderPort = {
|
||||
getBasic: jest.fn().mockImplementation(() => ({
|
||||
driverDistance: 350101,
|
||||
driverDuration: 14422,
|
||||
passengerDistance: 350101,
|
||||
passengerDuration: 14422,
|
||||
fwdAzimuth: 273,
|
||||
backAzimuth: 93,
|
||||
distanceAzimuth: 336544,
|
||||
points: [
|
||||
{
|
||||
lon: 6.1765102,
|
||||
lat: 48.689445,
|
||||
},
|
||||
{
|
||||
lon: 4.984578,
|
||||
lat: 48.725687,
|
||||
},
|
||||
{
|
||||
lon: 2.3522,
|
||||
lat: 48.8566,
|
||||
},
|
||||
],
|
||||
})),
|
||||
getBasic: jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error();
|
||||
})
|
||||
.mockImplementationOnce(() => ({
|
||||
distance: 350101,
|
||||
duration: 14422,
|
||||
fwdAzimuth: 273,
|
||||
backAzimuth: 93,
|
||||
distanceAzimuth: 336544,
|
||||
points: undefined,
|
||||
}))
|
||||
.mockImplementation(() => ({
|
||||
distance: 350101,
|
||||
duration: 14422,
|
||||
fwdAzimuth: 273,
|
||||
backAzimuth: 93,
|
||||
distanceAzimuth: 336544,
|
||||
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', () => {
|
||||
|
@ -112,7 +123,17 @@ describe('create-ad.service', () => {
|
|||
|
||||
describe('execution', () => {
|
||||
const createAdCommand = new CreateAdCommand(createAdProps);
|
||||
it('should create a new ad', async () => {
|
||||
it('should throw an error if route cant be computed', async () => {
|
||||
await expect(
|
||||
createAdService.execute(createAdCommand),
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
it('should throw an error if route is corrupted', async () => {
|
||||
await expect(
|
||||
createAdService.execute(createAdCommand),
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
it('should create a new ad as driver and passenger', async () => {
|
||||
AdEntity.create = jest.fn().mockReturnValue({
|
||||
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
|
||||
});
|
||||
|
@ -121,18 +142,22 @@ describe('create-ad.service', () => {
|
|||
);
|
||||
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
|
||||
});
|
||||
it('should throw an error if something bad happens', async () => {
|
||||
it('should create a new ad as passenger', async () => {
|
||||
AdEntity.create = jest.fn().mockReturnValue({
|
||||
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
|
||||
});
|
||||
const result: AggregateID = await createAdService.execute({
|
||||
...createAdCommand,
|
||||
driver: false,
|
||||
});
|
||||
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
|
||||
});
|
||||
it('should throw an error if something bad happens', async () => {
|
||||
await expect(
|
||||
createAdService.execute(createAdCommand),
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
it('should throw an exception if Ad already exists', async () => {
|
||||
AdEntity.create = jest.fn().mockReturnValue({
|
||||
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
|
||||
});
|
||||
await expect(
|
||||
createAdService.execute(createAdCommand),
|
||||
).rejects.toBeInstanceOf(AdAlreadyExistsException);
|
||||
|
|
|
@ -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