Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 0 additions & 29 deletions phase-1-foundation.md

This file was deleted.

1 change: 1 addition & 0 deletions src/common/config/config-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
export const ConfigKey = {
Database: {
Url: 'DATABASE_URL',
TransactionTimeoutMs: 'PRISMA_TX_TIMEOUT_MS',
},
Redis: {
Url: 'REDIS_URL',
Expand Down
1 change: 1 addition & 0 deletions src/common/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class EnvSchema {
@IsOptional() @IsInt() SEND_WINDOW?: number;
@IsOptional() @IsInt() BASE_BLOCK_TIME?: number;
@IsOptional() @IsInt() CORRIDOR_WIDTH?: number;
@IsOptional() @IsInt() PRISMA_TX_TIMEOUT_MS?: number;

@IsOptional() @IsString() AWS_REGION?: string;

Expand Down
111 changes: 111 additions & 0 deletions src/modules/package/matching.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ConfigService } from '@nestjs/config';
import { PrismaClient, TripStatusEnum } from '../../../generated/prisma';
import { TurfService } from '../turf/turf.service';
import { PrismaService } from '../prisma/prisma.service';
import { ConfigKey } from '../../common/config/config-names';
import {
PackageWithLocations,
TripWithLocations,
Expand Down Expand Up @@ -654,5 +655,115 @@ describe('MatchingService', () => {
it('should handle corridor width configuration', () => {
expect(service['corridorWidth']).toBe(2);
});

it('should read corridor width via ConfigKey', () => {
expect(ConfigKey.Pricing.CorridorWidth).toBe('CORRIDOR_WIDTH');
expect(configService.get).toHaveBeenCalledWith(
ConfigKey.Pricing.CorridorWidth,
10,
);
});

it('should default corridor width to 10 when config is missing', async () => {
const emptyConfig = mockDeep<ConfigService>();
emptyConfig.get.mockImplementation(
(_key: string, defaultValue?: unknown) => defaultValue,
);
const module: TestingModule = await Test.createTestingModule({
providers: [
MatchingService,
{ provide: ConfigService, useValue: emptyConfig },
{ provide: PrismaService, useValue: prismaService },
{ provide: TurfService, useValue: turfService },
],
}).compile();

expect(
module.get<MatchingService>(MatchingService)['corridorWidth'],
).toBe(10);
});

it('should log via Logger and skip trips that fail analysis', async () => {
const candidateTrips = [
mockTripData,
{ ...mockTripData, id: 'trip-456' },
];
const validMatchResult: MatchResult = {
tripId: 'trip-123',
isRequestSent: false,
score: 400,
originDistance: 500,
destinationDistance: 300,
isOnCorridor: true,
};

jest
.spyOn(service as any, 'getPreFilteredTrips')
.mockResolvedValue(candidateTrips);
jest
.spyOn(service as any, 'analyzeTrip')
.mockResolvedValueOnce(validMatchResult)
.mockRejectedValueOnce(new Error('Analysis failed'));
const loggerSpy = jest
.spyOn((service as any).logger, 'error')
.mockImplementation(() => undefined);
const freshSession = { packages: [] } as any;

const result = await service.findMatchedTrips(
mockPackageData,
freshSession,
20,
prismaService,
);

expect(result).toEqual([validMatchResult]);
expect(loggerSpy).toHaveBeenCalledTimes(1);
expect(loggerSpy).toHaveBeenCalledWith(
'Error analyzing trip trip-456:',
expect.any(Error),
);
});

it('should delegate scoring to MatchingScorer without drift', () => {
expect(service['calculateMatchingScore'](2000, 2000, true)).toBe(2000);
expect(service['calculateMatchingScore'](100, 100, true)).toBe(0);
});

it('should use default maxResults and tx when not provided', async () => {
jest.spyOn(service as any, 'getPreFilteredTrips').mockResolvedValue([]);

const freshSession = { packages: [] } as any;
const result = await service.findMatchedTrips(
mockPackageData,
freshSession,
);

expect(result).toEqual([]);
expect(service['getPreFilteredTrips']).toHaveBeenCalledWith(
mockPackageData,
undefined,
prismaService,
);
expect(freshSession.packages[0].matchResults).toEqual([]);
});

it('should use configured corridor width when analyzeTrip width is omitted', async () => {
const analyzerSpy = jest
.spyOn((service as any).analyzer, 'analyzeTrip')
.mockResolvedValue(null);

await service['analyzeTrip'](
mockTripData,
mockPackageData.originAddress,
mockPackageData.recipient.address,
);

expect(analyzerSpy).toHaveBeenCalledWith(
mockTripData,
mockPackageData.originAddress,
mockPackageData.recipient.address,
2,
);
});
});
});
129 changes: 33 additions & 96 deletions src/modules/package/matching.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma, TripStatusEnum } from '../../../generated/prisma';
import { Location } from '../map/map.types';
import { ConfigService } from '@nestjs/config';
import { SessionData } from 'express-session';
Expand All @@ -11,17 +10,29 @@ import {
} from './matching.types';
import { PrismaTransaction } from '../prisma/prisma.types';
import { TurfService } from '../turf/turf.service';
import { ConfigKey } from '../../common/config/config-names';
import { MatchingScorer } from './matching/matching-scorer';
import { CorridorAnalyzer } from './matching/corridor-analyzer';
import { buildTripCandidateWhere } from './matching/trip-candidate-query';

@Injectable()
export class MatchingService {
private corridorWidth: number;
private readonly logger = new Logger(MatchingService.name);
private readonly corridorWidth: number;
private readonly scorer: MatchingScorer;
private readonly analyzer: CorridorAnalyzer;

constructor(
config: ConfigService,
private prisma: PrismaService,
private turfService: TurfService,
turfService: TurfService,
) {
this.corridorWidth = config.get<number>('CORRIDOR_WIDTH', 10);
this.corridorWidth = config.get<number>(
ConfigKey.Pricing.CorridorWidth,
10,
);
this.scorer = new MatchingScorer();
this.analyzer = new CorridorAnalyzer(turfService, this.scorer);
}

async findMatchedTrips(
Expand Down Expand Up @@ -61,7 +72,7 @@ export class MatchingService {
packageData.recipient.address,
this.corridorWidth,
).catch((error) => {
console.error(`Error analyzing trip ${trip.id}:`, error);
this.logger.error(`Error analyzing trip ${trip.id}:`, error);
return null;
}),
);
Expand Down Expand Up @@ -100,25 +111,10 @@ export class MatchingService {
lastCheckMatching?: Date,
tx: PrismaTransaction = this.prisma,
) {
const whereClause: Prisma.TripWhereInput = {
isActive: true,
status: TripStatusEnum.scheduled,
};

// Just check new trips after lastCheckMatching
if (lastCheckMatching) {
whereClause.updatedAt = {
gte: lastCheckMatching,
};
}

// Filter by weight capacity
if (packageData.weight) {
whereClause.OR = [
{ maxPackageWeightGr: { gte: packageData.weight } },
{ maxPackageWeightGr: null },
];
}
const whereClause = buildTripCandidateWhere({
weight: packageData.weight,
lastCheckMatching,
});

// TODO: Filter by departure time

Expand Down Expand Up @@ -161,85 +157,26 @@ export class MatchingService {
packageDestination: Location,
corridorWidthKm: number = this.corridorWidth,
): Promise<MatchResult | null> {
const tripRoute = this.turfService.createRoute(
trip.origin,
trip.destination,
trip.waypoints,
);

const packageOriginPoint = this.turfService.createPoint(packageOrigin);
const packageDestinationPoint =
this.turfService.createPoint(packageDestination);

// Calculate distances from package points to trip route
const originDistance = this.turfService.getDistanceToRoute(
packageOriginPoint,
tripRoute,
return this.analyzer.analyzeTrip(
trip,
packageOrigin,
packageDestination,
corridorWidthKm,
);
const destinationDistance = this.turfService.getDistanceToRoute(
packageDestinationPoint,
tripRoute,
);

// Check if both points are within corridor
const corridorWidthMeters = corridorWidthKm * 1000;
const isOnCorridor =
originDistance <= corridorWidthMeters &&
destinationDistance <= corridorWidthMeters;

if (!isOnCorridor) {
return null;
}

// Check package and trip are in the same direction.
const isDirectionCompatible = this.turfService.checkDirectionCompatibility(
tripRoute,
packageOriginPoint,
packageDestinationPoint,
);

if (!isDirectionCompatible) {
return null;
}

const score = this.calculateMatchingScore(
originDistance,
destinationDistance,
isOnCorridor,
);

return {
tripId: trip.id,
isRequestSent: false,
score,
originDistance,
destinationDistance,
isOnCorridor,
};
}

// Note: Score lower is better.
// MVP version
// MVP version — kept as a thin delegate so existing callers/tests keep
// working; new code should use `MatchingScorer` directly.
private calculateMatchingScore(
originDistance: number,
destinationDistance: number,
isOnCorridor: boolean,
): number {
// Base score
let score = (originDistance + destinationDistance) / 2;

// Lower score for packages not on corridor
if (!isOnCorridor) {
score += 100_000;
}

// Score for trips that start/end very close to package points
if (originDistance < 1000) score -= 500;
if (destinationDistance < 1000) score -= 500;

// TODO: Add time-based scoring (preferred times)
// TODO: Add transporter rating scoring

return Math.max(0, score); // Ensure non-negative score
return this.scorer.calculateMatchingScore(
originDistance,
destinationDistance,
isOnCorridor,
);
}
}
Loading
Loading