extract path creator

This commit is contained in:
sbriat 2023-09-11 12:34:31 +02:00
parent 59a2644bb4
commit 2058bfce4c
18 changed files with 382 additions and 239 deletions

View File

@ -8,7 +8,13 @@ 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/carpool-route.type';
import {
Path,
PathCreator,
PathType,
TypedRoute,
} from '@modules/ad/core/domain/patch-creator.service';
import { Point } from '../../types/point.type';
@CommandHandler(CreateAdCommand)
export class CreateAdService implements ICommandHandler {
@ -23,7 +29,43 @@ 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(command.waypoints);
const pathCreator: PathCreator = new PathCreator(roles, command.waypoints);
let typedRoutes: TypedRoute[];
try {
typedRoutes = await Promise.all(
pathCreator.getPaths().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: Point[] | undefined;
let fwdAzimuth: number | undefined;
let backAzimuth: number | undefined;
typedRoutes.forEach((typedRoute: TypedRoute) => {
if (typedRoute.type !== PathType.PASSENGER) {
driverDistance = typedRoute.route.distance;
driverDuration = typedRoute.route.duration;
points = typedRoute.route.points;
fwdAzimuth = typedRoute.route.fwdAzimuth;
backAzimuth = typedRoute.route.backAzimuth;
}
if (typedRoute.type !== PathType.DRIVER) {
passengerDistance = typedRoute.route.distance;
passengerDuration = typedRoute.route.duration;
if (!points) points = typedRoute.route.points;
if (!fwdAzimuth) fwdAzimuth = typedRoute.route.fwdAzimuth;
if (!backAzimuth) backAzimuth = typedRoute.route.backAzimuth;
}
});
if (points && fwdAzimuth && backAzimuth) {
const ad = AdEntity.create({
id: command.id,
driver: command.driver,
@ -36,15 +78,14 @@ 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,
driverDistance,
driverDuration,
passengerDistance,
passengerDuration,
fwdAzimuth,
backAzimuth,
});
try {
await this.repository.insertExtra(ad, 'ad');
return ad.id;
@ -55,4 +96,6 @@ export class CreateAdService implements ICommandHandler {
throw error;
}
}
throw new Error('Route error');
}
}

View File

@ -1,10 +1,10 @@
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { MatchEntity } from '../../../domain/match.entity';
import { Candidate } from '../../types/algorithm.types';
import { MatchQuery } from './match.query';
import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port';
export abstract class Algorithm {
protected candidates: Candidate[];
protected candidates: CandidateEntity[];
protected selector: Selector;
protected processors: Processor[];
constructor(
@ -20,8 +20,9 @@ export abstract class Algorithm {
for (const processor of this.processors) {
this.candidates = await processor.execute(this.candidates);
}
return this.candidates.map((candidate: Candidate) =>
MatchEntity.create({ adId: candidate.ad.id }),
// console.log(this.candidates);
return this.candidates.map((candidate: CandidateEntity) =>
MatchEntity.create({ adId: candidate.id }),
);
};
}
@ -36,7 +37,7 @@ export abstract class Selector {
this.query = query;
this.repository = repository;
}
abstract select(): Promise<Candidate[]>;
abstract select(): Promise<CandidateEntity[]>;
}
/**
@ -47,5 +48,5 @@ export abstract class Processor {
constructor(query: MatchQuery) {
this.query = query;
}
abstract execute(candidates: Candidate[]): Promise<Candidate[]>;
abstract execute(candidates: CandidateEntity[]): Promise<CandidateEntity[]>;
}

View File

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

View File

@ -1,7 +1,20 @@
import { Candidate } from '../../../types/algorithm.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Completer } from './completer.abstract';
/**
* Complete candidates by setting driver and crew waypoints
*/
export class PassengerOrientedWaypointsCompleter extends Completer {
complete = async (candidates: Candidate[]): Promise<Candidate[]> =>
candidates;
complete = async (
candidates: CandidateEntity[],
): Promise<CandidateEntity[]> => candidates;
}
// complete = async (candidates: Candidate[]): Promise<Candidate[]> => {
// candidates.forEach( (candidate: Candidate) => {
// if (candidate.role == Role.DRIVER) {
// candidate.driverWaypoints = th
// }
// return candidates;
// }

View File

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

View File

@ -1,6 +1,7 @@
import { Candidate } from '../../../types/algorithm.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Filter } from './filter.abstract';
export class PassengerOrientedGeoFilter extends Filter {
filter = async (candidates: Candidate[]): Promise<Candidate[]> => candidates;
filter = async (candidates: CandidateEntity[]): Promise<CandidateEntity[]> =>
candidates;
}

View File

@ -1,11 +1,17 @@
import { QueryBase } from '@mobicoop/ddd-library';
import { AlgorithmType } from '../../types/algorithm.types';
import { Waypoint } from '../../types/waypoint.type';
import { Frequency } from '@modules/ad/core/domain/ad.types';
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 { Route } from '@modules/geography/core/domain/route.types';
import {
Path,
PathCreator,
PathType,
TypedRoute,
} from '@modules/ad/core/domain/patch-creator.service';
export class MatchQuery extends QueryBase {
driver?: boolean;
@ -168,10 +174,14 @@ export class MatchQuery extends QueryBase {
};
setRoutes = async (routeProvider: RouteProviderPort): 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);
try {
(
await Promise.all(
this._getPaths().map(async (path: Path) => ({
pathCreator.getPaths().map(async (path: Path) => ({
type: path.type,
route: await routeProvider.getBasic(path.waypoints),
})),
@ -192,43 +202,6 @@ export class MatchQuery extends QueryBase {
}
return this;
};
private _getPaths = (): Path[] => {
const paths: Path[] = [];
if (this.driver && this.passenger) {
if (this.waypoints.length == 2) {
// 2 points => same route for driver and passenger
paths.push(this._createGenericPath(this.waypoints));
} else {
paths.push(
this._createDriverPath(this.waypoints),
this._createPassengerPath(this.waypoints),
);
}
} else if (this.driver) {
paths.push(this._createDriverPath(this.waypoints));
} else if (this.passenger) {
paths.push(this._createPassengerPath(this.waypoints));
}
return paths;
};
private _createGenericPath = (waypoints: Waypoint[]): Path =>
this._createPath(waypoints, PathType.GENERIC);
private _createDriverPath = (waypoints: Waypoint[]): Path =>
this._createPath(waypoints, PathType.DRIVER);
private _createPassengerPath = (waypoints: Waypoint[]): Path =>
this._createPath(
[waypoints[0], waypoints[waypoints.length - 1]],
PathType.PASSENGER,
);
private _createPath = (waypoints: Waypoint[], type: PathType): Path => ({
type,
waypoints,
});
}
export type ScheduleItem = {
@ -254,19 +227,3 @@ interface DefaultAlgorithmParameters {
maxDetourDistanceRatio: number;
maxDetourDurationRatio: number;
}
type Path = {
type: PathType;
waypoints: Waypoint[];
};
enum PathType {
GENERIC = 'generic',
DRIVER = 'driver',
PASSENGER = 'passenger',
}
type TypedRoute = {
type: PathType;
route: Route;
};

View File

@ -1,13 +1,13 @@
import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { Candidate } from '../../../types/algorithm.types';
import { Selector } from '../algorithm.abstract';
import { AdReadModel } from '@modules/ad/infrastructure/ad.repository';
import { ScheduleItem } from '../match.query';
import { Waypoint } from '../../../types/waypoint.type';
import { Point } from '../../../types/point.type';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
export class PassengerOrientedSelector extends Selector {
select = async (): Promise<Candidate[]> => {
select = async (): Promise<CandidateEntity[]> => {
const queryStringRoles: QueryStringRole[] = [];
if (this.query.driver)
queryStringRoles.push({
@ -32,14 +32,11 @@ export class PassengerOrientedSelector extends Selector {
)
)
.map((adsRole: AdsRole) =>
adsRole.ads.map(
(adReadModel: AdReadModel) =>
<Candidate>{
ad: {
adsRole.ads.map((adReadModel: AdReadModel) =>
CandidateEntity.create({
id: adReadModel.uuid,
},
role: adsRole.role,
},
}),
),
)
.flat();

View File

@ -11,7 +11,8 @@ export enum AlgorithmType {
export type Candidate = {
ad: Ad;
role: Role;
waypoints: Waypoint[];
driverWaypoints: Waypoint[];
crewWaypoints: Waypoint[];
};
export type Ad = {

View File

@ -1,14 +0,0 @@
import { Point } from './point.type';
/**
* A carpool route is a route with distance and duration as driver and / or passenger
*/
export type Route = {
driverDistance?: number;
driverDuration?: number;
passengerDistance?: number;
passengerDuration?: number;
fwdAzimuth: number;
backAzimuth: number;
points: Point[];
};

View File

@ -0,0 +1,15 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { CandidateProps, CreateCandidateProps } from './candidate.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 });
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

@ -0,0 +1,18 @@
import { Role } from './ad.types';
// All properties that a Candidate has
export interface CandidateProps {
role: Role;
}
// Properties that are needed for a Candidate creation
export interface CreateCandidateProps {
id: string;
role: Role;
}
export type Waypoint = {
lon: number;
lat: number;
position: number;
};

View File

@ -0,0 +1,77 @@
import { Route } from '@modules/geography/core/domain/route.types';
import { Role } from './ad.types';
import { Waypoint } from './candidate.types';
export class PathCreator {
constructor(
private readonly roles: Role[],
private readonly waypoints: Waypoint[],
) {}
public getPaths = (): 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 = (): Waypoint =>
this.waypoints.find(
(waypoint: Waypoint) => waypoint.position == 0,
) as Waypoint;
private _lastWaypoint = (): Waypoint =>
this.waypoints.find(
(waypoint: Waypoint) =>
waypoint.position ==
Math.max(
...this.waypoints.map((waypoint: Waypoint) => waypoint.position),
),
) as Waypoint;
private _createPath = (waypoints: Waypoint[], type: PathType): Path => ({
type,
waypoints,
});
}
export type Path = {
type: PathType;
waypoints: Waypoint[];
};
export type TypedRoute = {
type: PathType;
route: Route;
};
export enum PathType {
GENERIC = 'generic',
DRIVER = 'driver',
PASSENGER = 'passenger',
}

View File

@ -60,7 +60,20 @@ const mockAdRepository = {
};
const mockRouteProvider: RouteProviderPort = {
getBasic: jest.fn().mockImplementation(() => ({
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,
@ -110,6 +123,16 @@ describe('create-ad.service', () => {
describe('execution', () => {
const createAdCommand = new CreateAdCommand(createAdProps);
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', async () => {
AdEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
@ -120,17 +143,11 @@ describe('create-ad.service', () => {
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
});
it('should throw an error if something bad happens', async () => {
AdEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
createAdService.execute(createAdCommand),
).rejects.toBeInstanceOf(Error);
});
it('should throw an exception if Ad already exists', async () => {
AdEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
createAdService.execute(createAdCommand),
).rejects.toBeInstanceOf(AdAlreadyExistsException);

View File

@ -1,11 +1,9 @@
import { PassengerOrientedGeoFilter } from '@modules/ad/core/application/queries/match/filter/passenger-oriented-geo.filter';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import {
AlgorithmType,
Candidate,
} from '@modules/ad/core/application/types/algorithm.types';
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';
const originWaypoint: Waypoint = {
position: 0,
@ -42,50 +40,22 @@ const matchQuery = new MatchQuery({
waypoints: [originWaypoint, destinationWaypoint],
});
const candidates: Candidate[] = [
{
ad: {
const candidates: CandidateEntity[] = [
CandidateEntity.create({
id: 'cc260669-1c6d-441f-80a5-19cd59afb777',
},
role: Role.DRIVER,
waypoints: [
{
position: 0,
lat: 48.68874,
lon: 6.18546,
},
{
position: 1,
lat: 48.87845,
lon: 2.36547,
},
],
},
{
ad: {
}),
CandidateEntity.create({
id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0',
},
role: Role.PASSENGER,
waypoints: [
{
position: 0,
lat: 48.69844,
lon: 6.168484,
},
{
position: 1,
lat: 48.855648,
lon: 2.34645,
},
],
},
}),
];
describe('Passenger oriented geo filter', () => {
it('should filter candidates', async () => {
const passengerOrientedGeoFilter: PassengerOrientedGeoFilter =
new PassengerOrientedGeoFilter(matchQuery);
const filteredCandidates: Candidate[] =
const filteredCandidates: CandidateEntity[] =
await passengerOrientedGeoFilter.filter(candidates);
expect(filteredCandidates.length).toBe(2);
});

View File

@ -1,12 +1,10 @@
import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import { PassengerOrientedSelector } from '@modules/ad/core/application/queries/match/selector/passenger-oriented.selector';
import {
AlgorithmType,
Candidate,
} from '@modules/ad/core/application/types/algorithm.types';
import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types';
import { Waypoint } from '@modules/ad/core/application/types/waypoint.type';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
const originWaypoint: Waypoint = {
position: 0,
@ -136,7 +134,8 @@ describe('Passenger oriented selector', () => {
it('should select candidates', async () => {
const passengerOrientedSelector: PassengerOrientedSelector =
new PassengerOrientedSelector(matchQuery, mockMatcherRepository);
const candidates: Candidate[] = await passengerOrientedSelector.select();
const candidates: CandidateEntity[] =
await passengerOrientedSelector.select();
expect(candidates.length).toBe(2);
});
});

View File

@ -1,11 +1,9 @@
import { PassengerOrientedWaypointsCompleter } from '@modules/ad/core/application/queries/match/completer/passenger-oriented-waypoints.completer';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import {
AlgorithmType,
Candidate,
} from '@modules/ad/core/application/types/algorithm.types';
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';
const originWaypoint: Waypoint = {
position: 0,
@ -42,50 +40,22 @@ const matchQuery = new MatchQuery({
waypoints: [originWaypoint, destinationWaypoint],
});
const candidates: Candidate[] = [
{
ad: {
const candidates: CandidateEntity[] = [
CandidateEntity.create({
id: 'cc260669-1c6d-441f-80a5-19cd59afb777',
},
role: Role.DRIVER,
waypoints: [
{
position: 0,
lat: 48.69,
lon: 6.18,
},
{
position: 1,
lat: 48.87,
lon: 2.37,
},
],
},
{
ad: {
}),
CandidateEntity.create({
id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0',
},
role: Role.PASSENGER,
waypoints: [
{
position: 0,
lat: 48.63584,
lon: 6.148754,
},
{
position: 1,
lat: 48.89874,
lon: 2.368745,
},
],
},
}),
];
describe('Passenger oriented waypoints completer', () => {
it('should complete candidates', async () => {
const passengerOrientedWaypointsCompleter: PassengerOrientedWaypointsCompleter =
new PassengerOrientedWaypointsCompleter(matchQuery);
const completedCandidates: Candidate[] =
const completedCandidates: CandidateEntity[] =
await passengerOrientedWaypointsCompleter.complete(candidates);
expect(completedCandidates.length).toBe(2);
});

View File

@ -0,0 +1,78 @@
import { Role } from '@modules/ad/core/domain/ad.types';
import { Waypoint } from '@modules/ad/core/domain/candidate.types';
import {
Path,
PathCreator,
PathType,
} from '@modules/ad/core/domain/patch-creator.service';
const originWaypoint: Waypoint = {
position: 0,
lat: 48.689445,
lon: 6.17651,
};
const destinationWaypoint: Waypoint = {
position: 1,
lat: 48.8566,
lon: 2.3522,
};
const intermediateWaypoint: Waypoint = {
position: 1,
lat: 48.74488,
lon: 4.8972,
};
describe('Path Creator Service', () => {
it('should create a path for a driver only', () => {
const pathCreator: PathCreator = new PathCreator(
[Role.DRIVER],
[originWaypoint, destinationWaypoint],
);
const paths: Path[] = pathCreator.getPaths();
expect(paths).toHaveLength(1);
expect(paths[0].type).toBe(PathType.DRIVER);
});
it('should create a path for a passenger only', () => {
const pathCreator: PathCreator = new PathCreator(
[Role.PASSENGER],
[originWaypoint, destinationWaypoint],
);
const paths: Path[] = pathCreator.getPaths();
expect(paths).toHaveLength(1);
expect(paths[0].type).toBe(PathType.PASSENGER);
});
it('should create a single path for a driver and passenger', () => {
const pathCreator: PathCreator = new PathCreator(
[Role.DRIVER, Role.PASSENGER],
[originWaypoint, destinationWaypoint],
);
const paths: Path[] = pathCreator.getPaths();
expect(paths).toHaveLength(1);
expect(paths[0].type).toBe(PathType.GENERIC);
});
it('should create two different paths for a driver and passenger with intermediate waypoint', () => {
const pathCreator: PathCreator = new PathCreator(
[Role.DRIVER, Role.PASSENGER],
[
originWaypoint,
intermediateWaypoint,
{ ...destinationWaypoint, position: 2 },
],
);
const paths: Path[] = pathCreator.getPaths();
expect(paths).toHaveLength(2);
expect(
paths.filter((path: Path) => path.type == PathType.DRIVER),
).toHaveLength(1);
expect(
paths.filter((path: Path) => path.type == PathType.DRIVER)[0].waypoints,
).toHaveLength(3);
expect(
paths.filter((path: Path) => path.type == PathType.PASSENGER),
).toHaveLength(1);
expect(
paths.filter((path: Path) => path.type == PathType.PASSENGER)[0]
.waypoints,
).toHaveLength(2);
});
});