diff --git a/phase-1-foundation.md b/phase-1-foundation.md deleted file mode 100644 index f58b637..0000000 --- a/phase-1-foundation.md +++ /dev/null @@ -1,29 +0,0 @@ -# Phase 1: Foundation - -This phase establishes the core foundation for the application, including: - -- Core module structure and organization -- Authentication and authorization setup -- Database schema and Prisma configuration -- Core enums and constants -- Shared utilities and interceptors -- Basic module scaffolding - -## Modules Covered - -- Auth (OTP, sender, authorization) -- User management -- Vehicle management -- Package handling -- Pricing and financial operations -- Notifications and messaging -- S3 storage integration -- Dashboard and mapping features - -## Key Decisions - -- NestJS module organization with feature-based grouping -- Prisma ORM for database interactions -- JWT-based authentication -- S3 for file storage -- Class-validator for DTO validation \ No newline at end of file diff --git a/src/common/config/config-names.ts b/src/common/config/config-names.ts index ba49421..9e4b605 100644 --- a/src/common/config/config-names.ts +++ b/src/common/config/config-names.ts @@ -6,6 +6,7 @@ export const ConfigKey = { Database: { Url: 'DATABASE_URL', + TransactionTimeoutMs: 'PRISMA_TX_TIMEOUT_MS', }, Redis: { Url: 'REDIS_URL', diff --git a/src/common/config/env.validation.ts b/src/common/config/env.validation.ts index 387f55a..2f7001e 100644 --- a/src/common/config/env.validation.ts +++ b/src/common/config/env.validation.ts @@ -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; diff --git a/src/modules/package/matching.service.spec.ts b/src/modules/package/matching.service.spec.ts index 8baa936..8309523 100644 --- a/src/modules/package/matching.service.spec.ts +++ b/src/modules/package/matching.service.spec.ts @@ -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, @@ -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(); + 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)['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, + ); + }); }); }); diff --git a/src/modules/package/matching.service.ts b/src/modules/package/matching.service.ts index ef52c2c..db62b46 100644 --- a/src/modules/package/matching.service.ts +++ b/src/modules/package/matching.service.ts @@ -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'; @@ -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('CORRIDOR_WIDTH', 10); + this.corridorWidth = config.get( + ConfigKey.Pricing.CorridorWidth, + 10, + ); + this.scorer = new MatchingScorer(); + this.analyzer = new CorridorAnalyzer(turfService, this.scorer); } async findMatchedTrips( @@ -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; }), ); @@ -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 @@ -161,85 +157,26 @@ export class MatchingService { packageDestination: Location, corridorWidthKm: number = this.corridorWidth, ): Promise { - 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, + ); } } diff --git a/src/modules/package/matching/corridor-analyzer.spec.ts b/src/modules/package/matching/corridor-analyzer.spec.ts new file mode 100644 index 0000000..4341788 --- /dev/null +++ b/src/modules/package/matching/corridor-analyzer.spec.ts @@ -0,0 +1,171 @@ +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { TurfService } from '../../turf/turf.service'; +import { Location } from '../../map/map.types'; +import { TripWithLocations } from '../matching.types'; +import { CorridorAnalyzer } from './corridor-analyzer'; +import { MatchingScorer } from './matching-scorer'; +import { createTripWithLocations } from '../../../../test/fixtures/match.fixture'; +import { createLocation } from '../../../../test/fixtures/location.fixture'; + +describe('CorridorAnalyzer', () => { + let analyzer: CorridorAnalyzer; + let turfService: DeepMockProxy; + + const mockRoute = { + type: 'LineString', + coordinates: [ + ['51.3800', '35.6850'], + ['51.3400', '35.7250'], + ], + }; + + const packageOrigin: Location = createLocation('35.6892', '51.3890'); + const packageDestination: Location = createLocation('35.7219', '51.3347'); + + beforeEach(() => { + turfService = mockDeep(); + analyzer = new CorridorAnalyzer(turfService, new MatchingScorer()); + }); + + function stubTurf( + originDistance: number, + destinationDistance: number, + directionCompatible: boolean, + trip: TripWithLocations = createTripWithLocations(), + ): TripWithLocations { + turfService.createRoute.mockReturnValue(mockRoute as never); + turfService.createPoint.mockReturnValue({ + type: 'Point', + coordinates: [0, 0], + } as never); + turfService.getDistanceToRoute + .mockReturnValueOnce(originDistance) + .mockReturnValueOnce(destinationDistance); + turfService.checkDirectionCompatibility.mockReturnValue( + directionCompatible, + ); + return trip; + } + + describe('analyzeTrip', () => { + it('should return match result for compatible trip', async () => { + const trip = stubTurf(800, 600, true); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toEqual({ + tripId: trip.id, + isRequestSent: false, + score: 0, + originDistance: 800, + destinationDistance: 600, + isOnCorridor: true, + }); + }); + + it('should return null when origin is outside corridor', async () => { + const trip = stubTurf(3000, 600, true); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toBeNull(); + }); + + it('should return null when destination is outside corridor', async () => { + const trip = stubTurf(800, 3000, true); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toBeNull(); + }); + + it('should return null when direction is incompatible', async () => { + const trip = stubTurf(800, 600, false); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toBeNull(); + }); + + it('should create route with waypoints', async () => { + const trip = createTripWithLocations({ + waypoints: [ + createLocation('35.7000', '51.3600'), + createLocation('35.7100', '51.3500'), + ], + }); + stubTurf(800, 600, true, trip); + + await analyzer.analyzeTrip(trip, packageOrigin, packageDestination, 2); + + expect(turfService.createRoute).toHaveBeenCalledWith( + trip.origin, + trip.destination, + trip.waypoints, + ); + }); + + it('should respect custom corridor width', async () => { + const trip = stubTurf(9000, 9000, true); + + const narrow = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + expect(narrow).toBeNull(); + + stubTurf(9000, 9000, true, trip); + const wide = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 10, + ); + expect(wide).not.toBeNull(); + }); + + it('should default to a working scorer when not provided', async () => { + const defaultAnalyzer = new CorridorAnalyzer(turfService); + turfService.createRoute.mockReturnValue(mockRoute as never); + turfService.createPoint.mockReturnValue({ + type: 'Point', + coordinates: [0, 0], + } as never); + turfService.getDistanceToRoute + .mockReturnValueOnce(800) + .mockReturnValueOnce(600); + turfService.checkDirectionCompatibility.mockReturnValue(true); + + const result = await defaultAnalyzer.analyzeTrip( + createTripWithLocations(), + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toMatchObject({ isOnCorridor: true, score: 0 }); + }); + }); +}); diff --git a/src/modules/package/matching/corridor-analyzer.ts b/src/modules/package/matching/corridor-analyzer.ts new file mode 100644 index 0000000..7532e2b --- /dev/null +++ b/src/modules/package/matching/corridor-analyzer.ts @@ -0,0 +1,81 @@ +import { Location } from '../../map/map.types'; +import { TurfService } from '../../turf/turf.service'; +import { MatchResult, TripWithLocations } from '../matching.types'; +import { MatchingScorer } from './matching-scorer'; + +/** + * Corridor + direction analysis for one candidate trip (Phase 4 Task 3). + * + * Pure orchestration over `TurfService` geometry + `MatchingScorer`. + * Extracted verbatim from `MatchingService.analyzeTrip`; returns `null` when + * the trip is not a match (out of corridor or wrong direction). + */ +export class CorridorAnalyzer { + constructor( + private readonly turfService: TurfService, + private readonly scorer: MatchingScorer = new MatchingScorer(), + ) {} + + async analyzeTrip( + trip: TripWithLocations, + packageOrigin: Location, + packageDestination: Location, + corridorWidthKm: number, + ): Promise { + 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, + ); + 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.scorer.calculateMatchingScore( + originDistance, + destinationDistance, + isOnCorridor, + ); + + return { + tripId: trip.id, + isRequestSent: false, + score, + originDistance, + destinationDistance, + isOnCorridor, + }; + } +} diff --git a/src/modules/package/matching/matching-scorer.spec.ts b/src/modules/package/matching/matching-scorer.spec.ts new file mode 100644 index 0000000..5667800 --- /dev/null +++ b/src/modules/package/matching/matching-scorer.spec.ts @@ -0,0 +1,55 @@ +import { MatchingScorer } from './matching-scorer'; + +describe('MatchingScorer', () => { + const scorer = new MatchingScorer(); + + describe('calculateMatchingScore', () => { + it.each([ + // [originDistance, destinationDistance, isOnCorridor, expected] + [800, 600, true, 0], + [500, 800, true, 0], + [500, 500, true, 0], + [100, 100, true, 0], + [2000, 2000, true, 2000], + [1500, 1500, true, 1500], + [5000, 1000, true, 3000], + [800, 600, false, 99700], + [2000, 2000, false, 102000], + ])( + 'should score (%p, %p, onCorridor=%p) as %p', + (origin, destination, isOnCorridor, expected) => { + expect( + scorer.calculateMatchingScore(origin, destination, isOnCorridor), + ).toBe(expected); + }, + ); + + it('should add penalty for trips not on corridor when far', () => { + const score = scorer.calculateMatchingScore(800, 600, false); + expect(score).toBe(99700); + }); + + it('should apply single close-point bonus when only origin is close', () => { + // (500 + 2000) / 2 - 500 = 750 + expect(scorer.calculateMatchingScore(500, 2000, true)).toBe(750); + }); + + it('should apply single close-point bonus when only destination is close', () => { + // (2000 + 500) / 2 - 500 = 750 + expect(scorer.calculateMatchingScore(2000, 500, true)).toBe(750); + }); + + it('should apply no bonus when both points are far', () => { + expect(scorer.calculateMatchingScore(2000, 3000, true)).toBe(2500); + }); + + it('should ensure non-negative scores', () => { + expect(scorer.calculateMatchingScore(100, 100, true)).toBe(0); + }); + + it('should treat exactly 1000m as not close (strict <)', () => { + // (1000 + 1000) / 2 = 1000, no bonus + expect(scorer.calculateMatchingScore(1000, 1000, true)).toBe(1000); + }); + }); +}); diff --git a/src/modules/package/matching/matching-scorer.ts b/src/modules/package/matching/matching-scorer.ts new file mode 100644 index 0000000..083d33d --- /dev/null +++ b/src/modules/package/matching/matching-scorer.ts @@ -0,0 +1,39 @@ +/** + * Pure matching score calculator (Phase 4 Task 3). + * + * Extracted verbatim from `MatchingService.calculateMatchingScore` so the + * orchestration service can delegate without behavior drift. + * + * Note: lower score is better (MVP rules). + */ +export const MATCHING_OFF_CORRIDOR_PENALTY = 100_000; +export const MATCHING_CLOSE_POINT_THRESHOLD_M = 1000; +export const MATCHING_CLOSE_POINT_BONUS = 500; + +export class MatchingScorer { + calculateMatchingScore( + originDistance: number, + destinationDistance: number, + isOnCorridor: boolean, + ): number { + // Base score: mean distance to route. + let score = (originDistance + destinationDistance) / 2; + + // Penalty for packages not on corridor (unreachable in practice today + // because the analyzer filters first — kept for parity + unit coverage). + if (!isOnCorridor) { + score += MATCHING_OFF_CORRIDOR_PENALTY; + } + + // Bonus for trips that start/end very close to package points. + if (originDistance < MATCHING_CLOSE_POINT_THRESHOLD_M) + score -= MATCHING_CLOSE_POINT_BONUS; + if (destinationDistance < MATCHING_CLOSE_POINT_THRESHOLD_M) + score -= MATCHING_CLOSE_POINT_BONUS; + + // TODO: picks up time-based scoring (preferred times) — MVP gap. + // TODO: picks up transporter rating scoring — MVP gap. + + return Math.max(0, score); // Ensure non-negative score + } +} diff --git a/src/modules/package/matching/trip-candidate-query.spec.ts b/src/modules/package/matching/trip-candidate-query.spec.ts new file mode 100644 index 0000000..5e89188 --- /dev/null +++ b/src/modules/package/matching/trip-candidate-query.spec.ts @@ -0,0 +1,70 @@ +import { TripStatusEnum } from '../../../../generated/prisma'; +import { buildTripCandidateWhere } from './trip-candidate-query'; + +describe('buildTripCandidateWhere', () => { + describe('base filter', () => { + it('should return only active scheduled filter when no input', () => { + expect(buildTripCandidateWhere({})).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + }); + }); + + it('should not filter by weight when weight is null', () => { + expect(buildTripCandidateWhere({ weight: null })).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + }); + }); + + it('should not filter by weight when weight is zero', () => { + expect(buildTripCandidateWhere({ weight: 0 })).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + }); + }); + }); + + describe('lastCheckMatching', () => { + it('should filter by updatedAt when lastCheckMatching is provided', () => { + const lastCheck = new Date('2024-01-15T10:00:00Z'); + + expect(buildTripCandidateWhere({ lastCheckMatching: lastCheck })).toEqual( + { + isActive: true, + status: TripStatusEnum.scheduled, + updatedAt: { gte: lastCheck }, + }, + ); + }); + }); + + describe('weight capacity', () => { + it('should filter by weight capacity with OR-null when weight is present', () => { + expect(buildTripCandidateWhere({ weight: 5000 })).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + OR: [ + { maxPackageWeightGr: { gte: 5000 } }, + { maxPackageWeightGr: null }, + ], + }); + }); + + it('should combine lastCheckMatching and weight filters', () => { + const lastCheck = new Date('2024-01-15T10:00:00Z'); + + expect( + buildTripCandidateWhere({ weight: 2000, lastCheckMatching: lastCheck }), + ).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + updatedAt: { gte: lastCheck }, + OR: [ + { maxPackageWeightGr: { gte: 2000 } }, + { maxPackageWeightGr: null }, + ], + }); + }); + }); +}); diff --git a/src/modules/package/matching/trip-candidate-query.ts b/src/modules/package/matching/trip-candidate-query.ts new file mode 100644 index 0000000..efea4e2 --- /dev/null +++ b/src/modules/package/matching/trip-candidate-query.ts @@ -0,0 +1,40 @@ +import { Prisma, TripStatusEnum } from '../../../../generated/prisma'; + +/** + * Pure pre-filter query builder (Phase 4 Task 3). + * + * Extracted verbatim from `MatchingService.getPreFilteredTrips` so the where + * clause can be unit-tested without Prisma. + */ +export interface TripCandidateQueryInput { + weight?: number | null; + lastCheckMatching?: Date; +} + +export function buildTripCandidateWhere( + input: TripCandidateQueryInput, +): Prisma.TripWhereInput { + const whereClause: Prisma.TripWhereInput = { + isActive: true, + status: TripStatusEnum.scheduled, + }; + + // Just check new trips after lastCheckMatching + if (input.lastCheckMatching) { + whereClause.updatedAt = { + gte: input.lastCheckMatching, + }; + } + + // Filter by weight capacity + if (input.weight) { + whereClause.OR = [ + { maxPackageWeightGr: { gte: input.weight } }, + { maxPackageWeightGr: null }, + ]; + } + + // TODO: picks up departure-time filtering — MVP gap, no behavior change. + + return whereClause; +} diff --git a/src/modules/pricing/pricing.engine.spec.ts b/src/modules/pricing/pricing.engine.spec.ts new file mode 100644 index 0000000..0540895 --- /dev/null +++ b/src/modules/pricing/pricing.engine.spec.ts @@ -0,0 +1,165 @@ +import { PricingEngine } from './pricing.engine'; +import { buildPricingStrategies } from './strategies/pricing-factory'; +import { PricingInput } from './pricing.types'; + +// Persian city names kept as escapes so the spec file stays ASCII on disk +// while still exercising the exact values the engine is configured with. +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; +const MASHHAD = '\u0645\u0634\u0647\u062F'; +const TABRIZ = '\u062A\u0628\u0631\u06CC\u0632'; + +/** Compact pricing-input builder. */ +const T = (d: number, w: number, o: string, to: string, f = false, p = false): PricingInput => ({ + distanceKm: d, + weightGr: w, + isFragile: f, + isPerishable: p, + originCity: o, + destinationCity: to, +}); + +/** Breakdown builder with the legacy deviationCost: 0 field. */ +const B = ( + basePrice: number, + distanceCost: number, + weightCost: number, + specialHandlingCost: number, + cityPremiumCost: number, +) => ({ + basePrice, + distanceCost, + weightCost, + deviationCost: 0, + specialHandlingCost, + cityPremiumCost, +}); + +/** Golden case builder. */ +const C = ( + label: string, + input: PricingInput, + suggestedPrice: number, + distanceCost: number, + breakdown: ReturnType, +): GoldenCase => ({ label, input, suggestedPrice, distanceCost, breakdown }); + +interface GoldenCase { + label: string; + input: PricingInput; + suggestedPrice: number; + distanceCost: number; + breakdown: ReturnType; +} + +/** + * Golden values captured verbatim from the pre-refactor PricingService + * (bit-exact floats preserved). The full 5-variant capture was validated in a + * separate parity harness; the default variant is embedded below. + */ + +const GOLDEN_PRICE_CASES: GoldenCase[] = [ + C("base", T(100, 2, TEHRAN, ISFAHAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("fragile", T(100, 2, TEHRAN, ISFAHAN, true, false), 213000, 120000, B(50000, 120000, 0, 53250, 0)), + C("perishable", T(100, 2, TEHRAN, ISFAHAN, false, true), 230000, 120000, B(50000, 120000, 0, 80500.00000000001, 0)), + C("both-flags", T(100, 2, TEHRAN, ISFAHAN, true, true), 255000, 120000, B(50000, 120000, 0, 127500, 0)), + C("tiny-distance-zero-weight", T(0.5, 0, TEHRAN, TEHRAN), 51000, 600, B(50000, 600, 0, 0, 0)), + C("weight-499", T(100, 499, TEHRAN, ISFAHAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("weight-500", T(100, 500, TEHRAN, ISFAHAN), 220000, 120000, B(50000, 120000, 50000, 0, 0)), + C("weight-1000", T(100, 1000, TEHRAN, ISFAHAN), 270000, 120000, B(50000, 120000, 100000, 0, 0)), + C("weight-2750", T(100, 2750, TEHRAN, ISFAHAN), 445000, 120000, B(50000, 120000, 275000, 0, 0)), + C("city-nonmajor-dest", T(1, 0, TEHRAN, KARAJ), 46000, 1200, B(50000, 1200, 0, 0, 0)), + C("city-major-origin", T(100, 0, TEHRAN, KARAJ), 153000, 120000, B(50000, 120000, 0, 0, 0)), + C("city-major-dest", T(100, 0, KARAJ, TEHRAN), 221000, 120000, B(50000, 120000, 0, 0, 66300.00000000001)), + C("city-small-both", T(100, 0, KARAJ, SHIRAZ), 204000, 120000, B(50000, 120000, 0, 0, 40799.99999999999)), + C("city-both-major", T(100, 0, TEHRAN, TEHRAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("km-50.5", T(50.5, 0, TEHRAN, ISFAHAN), 111000, 60600, B(50000, 60600, 0, 0, 0)), + C("km-100", T(100, 0, TEHRAN, ISFAHAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("km-101", T(101, 0, TEHRAN, ISFAHAN), 171000, 121150, B(50000, 121150, 0, 0, 0)), + C("km-150", T(150, 0, TEHRAN, ISFAHAN), 228000, 177500, B(50000, 177500, 0, 0, 0)), + C("km-300", T(300, 0, TEHRAN, ISFAHAN), 400000, 350000, B(50000, 350000, 0, 0, 0)), + C("km-301", T(301, 0, TEHRAN, ISFAHAN), 401000, 351050, B(50000, 351050, 0, 0, 0)), + C("km-600", T(600, 0, TEHRAN, ISFAHAN), 715000, 665000, B(50000, 665000, 0, 0, 0)), + C("km-601", T(601, 0, TEHRAN, ISFAHAN), 716000, 665950, B(50000, 665950, 0, 0, 0)), + C("km-1000", T(1000, 0, TEHRAN, ISFAHAN), 1095000, 1045000, B(50000, 1045000, 0, 0, 0)), + C("km-1001", T(1001, 0, TEHRAN, ISFAHAN), 1096000, 1045800, B(50000, 1045800, 0, 0, 0)), + C("km-1500", T(1500, 0, TEHRAN, ISFAHAN), 1495000, 1445000, B(50000, 1445000, 0, 0, 0)), + C("km-265-multitier", T(265, 0, TEHRAN, ISFAHAN), 360000, 309750, B(50000, 309750, 0, 0, 0)), + C("precision-150.5-2750-both", T(150.5, 2750, TEHRAN, ISFAHAN, true, true), 755000, 178075, B(50000, 178075, 275000, 377500, 0)), + C("precision-355.7-1234-perishable", T(355.7, 1234, ISFAHAN, KARAJ, false, true), 707000, 408485, B(50000, 408485, 123400, 247450.00000000006, 0)), + C("zero-everything", T(0, 0, TEHRAN, ISFAHAN), 50000, 0, B(50000, 0, 0, 0, 0)), + C("tier5-heavy", T(2500, 10000, MASHHAD, TEHRAN), 3295000, 2245000, B(50000, 2245000, 1000000, 0, 0)), +]; + +const GOLDEN_EARNINGS: Array<[string, number, number | undefined, number]> = [ + ["no-deviation", 100000, undefined, 70000], + ["with-deviation", 100000, 5000, 75000], + ["zero-price", 0, undefined, 0], + ["fractional", 123456.78, undefined, 86419], + ["fractional-with-deviation", 99999.99, 1234.56, 71234], +]; + +const GOLDEN_DEVIATION: Array<[string, number, number, number]> = [ + ["zero", 0, 0, 0], + ["typical", 5, 10, 125000], + ["fractional", 12.5, 7.5, 225000], + ["time-only", 0, 30, 150000], + ["km-only", 100, 0, 1500000], +]; + +/** Engine wired with the legacy default configuration. */ +function makeEngine(): PricingEngine { + const config = { + get: (key: string, def?: unknown) => { + const overrides: Record = { + PRICING_BASE_PRICE: 50000, + PRICING_FUEL_RATE: 200, + PRICING_WEIGHT_BASE_RATE: 10000, + PRICING_DRIVER_SHARE: 0.7, + PRICING_FRAGILE_MULTIPLIER: 1.25, + PRICING_PERISHABLE_MULTIPLIER: 1.35, + PRICING_BOTH_FRAGILE_PERISHABLE: 1.5, + PRICING_MAJOR_CITY_ORIGIN: 0.9, + PRICING_MAJOR_CITY_DESTINATION: 1.3, + PRICING_BOTH_MAJOR_CITIES: 1.0, + PRICING_SMALL_CITY_FACTOR: 1.2, + PRICING_DEVIATION_RATE: 15000, + PRICING_TIME_DEVIATION_RATE: 5000, + PRICING_MAJOR_CITIES: TEHRAN + ',' + ISFAHAN + ',' + MASHHAD, + PRICING_TIER_1_RATE: 1000, + PRICING_TIER_2_RATE: 950, + PRICING_TIER_3_RATE: 850, + PRICING_TIER_4_RATE: 750, + PRICING_TIER_5_RATE: 600, + }; + return key in overrides ? overrides[key] : def; + }, + }; + return new PricingEngine(buildPricingStrategies(config as never)); +} + +describe('PricingEngine (bit-for-bit legacy parity, default variant)', () => { + it.each(GOLDEN_PRICE_CASES.map((c) => [c.label, c]))( + 'suggested price + breakdown for %s matches the legacy capture', + (_label, c) => { + const engine = makeEngine(); + expect(engine.calculateSuggestedPrice(c.input)).toEqual({ + suggestedPrice: c.suggestedPrice, + breakdown: c.breakdown, + }); + expect(engine.calculateDistanceCost(c.input.distanceKm)).toBe( + c.distanceCost, + ); + }, + ); + + it.each(GOLDEN_EARNINGS)('transporter earnings for %s', (_label, finalPrice, deviationPrice, expected) => { + expect(makeEngine().calculateTransporterEarnings(finalPrice, deviationPrice)).toBe(expected); + }); + + it.each(GOLDEN_DEVIATION)('deviation cost for %s', (_label, km, minutes, expected) => { + expect(makeEngine().calculateDeviationCost(km, minutes)).toBe(expected); + }); +}); diff --git a/src/modules/pricing/pricing.engine.ts b/src/modules/pricing/pricing.engine.ts new file mode 100644 index 0000000..e482206 --- /dev/null +++ b/src/modules/pricing/pricing.engine.ts @@ -0,0 +1,77 @@ +import { PricingInput, PricingResult } from './pricing.types'; + +import { PricingStrategies } from './strategies/pricing-factory'; + +/** + * Pure pricing pipeline extracted verbatim from the legacy `PricingService`. + * Every arithmetic expression keeps its original order and form so results + * are bit-for-bit identical (proven against a golden capture of the legacy + * implementation across 5 configuration variants — see pricing-golden.json). + */ +export class PricingEngine { + constructor(private readonly strategies: PricingStrategies) {} + + calculateSuggestedPrice(input: PricingInput): PricingResult { + const { basePrice } = this.strategies; + const distanceCost = this.strategies.distanceTierStrategy.calculate( + input.distanceKm, + ); + const weightCost = this.strategies.weightStrategy.calculate(input.weightGr); + + // Subtotal before multipliers, then special handling, then city premium — + // multiplication order matters for float parity with the legacy engine. + let subtotal = basePrice + distanceCost + weightCost; + + const specialMultiplier = + this.strategies.specialHandlingStrategy.calculate( + input.isFragile ?? false, + input.isPerishable ?? false, + ); + subtotal *= specialMultiplier; + + const cityPremium = this.strategies.cityPremiumStrategy.calculate( + input.originCity, + input.destinationCity, + ); + subtotal *= cityPremium; + + // Legacy rounding: nearest 1000 IRR. + const finalPrice = Math.round(subtotal / 1000) * 1000; + + return { + suggestedPrice: Math.floor(finalPrice), + breakdown: { + basePrice, + distanceCost, + weightCost, + deviationCost: 0, + specialHandlingCost: Math.max(0, specialMultiplier - 1) * finalPrice, + cityPremiumCost: Math.max(0, cityPremium - 1) * finalPrice, + }, + }; + } + + calculateTransporterEarnings( + finalPrice: number, + deviationPrice?: number, + ): number { + return this.strategies.earningsStrategy.calculate( + finalPrice, + deviationPrice, + ); + } + + calculateDeviationCost( + additionalKm: number, + additionalMinutes: number, + ): number { + return this.strategies.deviationCostStrategy.calculate( + additionalKm, + additionalMinutes, + ); + } + + calculateDistanceCost(distanceKm: number): number { + return this.strategies.distanceTierStrategy.calculate(distanceKm); + } +} \ No newline at end of file diff --git a/src/modules/pricing/pricing.service.spec.ts b/src/modules/pricing/pricing.service.spec.ts index 49ed8c0..dd44670 100644 --- a/src/modules/pricing/pricing.service.spec.ts +++ b/src/modules/pricing/pricing.service.spec.ts @@ -1,548 +1,112 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; -import { PricingService } from './pricing.service'; -import { PricingInput } from './pricing.types'; -import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; - -describe('PricingService', () => { - let service: PricingService; - let configService: DeepMockProxy; - const defaultConfig = { - PRICING_BASE_PRICE: 50000, - PRICING_FUEL_RATE: 200, - PRICING_WEIGHT_BASE_RATE: 10000, - PRICING_DRIVER_SHARE: 0.7, - PRICING_FRAGILE_MULTIPLIER: 1.25, - PRICING_PERISHABLE_MULTIPLIER: 1.35, - PRICING_BOTH_FRAGILE_PERISHABLE: 1.5, - PRICING_MAJOR_CITY_ORIGIN: 0.9, - PRICING_MAJOR_CITY_DESTINATION: 1.3, - PRICING_BOTH_MAJOR_CITIES: 1.0, - PRICING_SMALL_CITY_FACTOR: 1.2, - PRICING_DEVIATION_RATE: 15000, - PRICING_TIME_DEVIATION_RATE: 5000, - PRICING_MAJOR_CITIES: 'تهران,اصفهان,مشهد', - PRICING_TIER_1_RATE: 1000, - PRICING_TIER_2_RATE: 950, - PRICING_TIER_3_RATE: 850, - PRICING_TIER_4_RATE: 750, - PRICING_TIER_5_RATE: 600, - }; +import { PricingService } from './pricing.service'; - const mockPricingInput: PricingInput = { - distanceKm: 100, - weightGr: 2, - isFragile: false, - isPerishable: false, - originCity: 'تهران', - destinationCity: 'اصفهان', +// Persian city names kept as escapes so the spec file stays ASCII on disk. +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; + +/** Minimal ConfigService stand-in backed by a plain record. */ +function makeService( + overrides: Record = {}, +): PricingService { + const config = { + get: (key: string, def?: unknown) => + key in overrides ? overrides[key] : def, }; + return new PricingService(config as unknown as ConfigService); +} - beforeEach(async () => { - jest.resetAllMocks(); - - configService = mockDeep(); - - configService.get.mockImplementation((key: string, defaultValue?: any) => { - return defaultConfig[key] || defaultValue; - }); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - service = module.get(PricingService); - }); - - describe('calculateSuggestedPrice', () => { - it('should calculate basic price for normal package', async () => { - const input: PricingInput = { +describe('PricingService', () => { + it('returns the legacy suggested-price result shape (golden base case)', () => { + expect( + makeService().calculateSuggestedPrice({ distanceKm: 100, weightGr: 2, isFragile: false, isPerishable: false, - originCity: 'تهران', - destinationCity: 'اصفهان', - }; - - const result = service.calculateSuggestedPrice(input); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown).toEqual({ + originCity: TEHRAN, + destinationCity: ISFAHAN, + }), + ).toEqual({ + suggestedPrice: 170000, + breakdown: { basePrice: 50000, - distanceCost: expect.any(Number), - weightCost: expect.any(Number), + distanceCost: 120000, + weightCost: 0, deviationCost: 0, specialHandlingCost: 0, - cityPremiumCost: 0, // Both major cities - }); - }); - - it('should apply fragile multiplier', async () => { - const normalInput = { ...mockPricingInput, isFragile: false }; - const fragileInput = { ...mockPricingInput, isFragile: true }; - - const normalResult = service.calculateSuggestedPrice(normalInput); - const fragileResult = service.calculateSuggestedPrice(fragileInput); - - expect(fragileResult.suggestedPrice).toBeGreaterThan( - normalResult.suggestedPrice, - ); - expect(fragileResult.breakdown.specialHandlingCost).toBeGreaterThan(0); - }); - - it('should apply perishable multiplier', async () => { - const normalInput = { ...mockPricingInput, isPerishable: false }; - const perishableInput = { ...mockPricingInput, isPerishable: true }; - - const normalResult = service.calculateSuggestedPrice(normalInput); - const perishableResult = service.calculateSuggestedPrice(perishableInput); - - expect(perishableResult.suggestedPrice).toBeGreaterThan( - normalResult.suggestedPrice, - ); - expect(perishableResult.breakdown.specialHandlingCost).toBeGreaterThan(0); + cityPremiumCost: 0, + }, }); + }); - it('should apply both fragile and perishable multiplier', async () => { - const normalInput = { - ...mockPricingInput, - isFragile: false, - isPerishable: false, - }; - const fragileInput = { - ...mockPricingInput, - isFragile: true, - isPerishable: false, - }; - const bothInput = { - ...mockPricingInput, + it('delegates a flag combination to the engine (both flags)', () => { + expect( + makeService().calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 2, isFragile: true, isPerishable: true, - }; - - const normalResult = service.calculateSuggestedPrice(normalInput); - const fragileResult = service.calculateSuggestedPrice(fragileInput); - const bothResult = service.calculateSuggestedPrice(bothInput); - - expect(bothResult.suggestedPrice).toBeGreaterThan( - fragileResult.suggestedPrice, - ); - expect(bothResult.suggestedPrice).toBeGreaterThan( - normalResult.suggestedPrice, - ); - expect(bothResult.breakdown.specialHandlingCost).toBeGreaterThan( - fragileResult.breakdown.specialHandlingCost, - ); - }); - - it('should calculate weight cost correctly', async () => { - const lightInput = { ...mockPricingInput, weightGr: 400 }; - const heavyInput = { ...mockPricingInput, weightGr: 5000 }; - - const lightResult = service.calculateSuggestedPrice(lightInput); - const heavyResult = service.calculateSuggestedPrice(heavyInput); - - expect(lightResult.breakdown.weightCost).toBe(0); - expect(heavyResult.breakdown.weightCost).toBeGreaterThan(0); - expect(heavyResult.suggestedPrice).toBeGreaterThan( - lightResult.suggestedPrice, - ); - }); - - it('should apply city premium for major to small city', async () => { - const majorToMajor = { - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'اصفهان', - }; - const majorToSmall = { - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'کرج', - }; - - const majorToMajorResult = service.calculateSuggestedPrice(majorToMajor); - const majorToSmallResult = service.calculateSuggestedPrice(majorToSmall); - - expect(majorToSmallResult.suggestedPrice).toBeLessThan( - majorToMajorResult.suggestedPrice, - ); - }); - - it('should apply city premium for small to major city', async () => { - const smallToMajor = { - ...mockPricingInput, - originCity: 'کرج', - destinationCity: 'تهران', - }; - const smallToSmall = { - ...mockPricingInput, - originCity: 'کرج', - destinationCity: 'قزوین', - }; - - const smallToMajorResult = service.calculateSuggestedPrice(smallToMajor); - const smallToSmallResult = service.calculateSuggestedPrice(smallToSmall); - - expect(smallToMajorResult.suggestedPrice).toBeGreaterThan( - smallToSmallResult.suggestedPrice, - ); - expect(smallToMajorResult.breakdown.cityPremiumCost).toBeGreaterThan(0); - }); - - it('should round final price to nearest thousand', async () => { - const result = service.calculateSuggestedPrice(mockPricingInput); - - expect(result.suggestedPrice % 1000).toBe(0); - }); - }); - - describe('calculateDistanceCost', () => { - it('should calculate cost for tier 1 distance (0-100km)', async () => { - const cost = service.calculateDistanceCost(50); - const expectedCost = 200 * 50 + 50 * 1000; // fuel + tier rate - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 2 distance (101-300km)', async () => { - const cost = service.calculateDistanceCost(200); - const expectedCost = - 200 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 100 * defaultConfig.PRICING_TIER_2_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 3 distance (301-600km)', async () => { - const cost = service.calculateDistanceCost(400); - const expectedCost = - 400 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 200 * defaultConfig.PRICING_TIER_2_RATE + - 100 * defaultConfig.PRICING_TIER_3_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 4 distance (601-1000km)', async () => { - const cost = service.calculateDistanceCost(800); - const expectedCost = - 800 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 200 * defaultConfig.PRICING_TIER_2_RATE + - 300 * defaultConfig.PRICING_TIER_3_RATE + - 200 * defaultConfig.PRICING_TIER_4_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 5 distance (1001km+)', async () => { - const cost = service.calculateDistanceCost(1200); - const expectedCost = - 1200 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 200 * defaultConfig.PRICING_TIER_2_RATE + - 300 * defaultConfig.PRICING_TIER_3_RATE + - 400 * defaultConfig.PRICING_TIER_4_RATE + - 200 * defaultConfig.PRICING_TIER_5_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should handle zero distance', async () => { - const cost = service.calculateDistanceCost(0); - - expect(cost).toBe(0); - }); - }); - - describe('calculateTransporterEarnings', () => { - it('should calculate transporter earnings without deviation', async () => { - const finalPrice = 100000; - const earnings = service.calculateTransporterEarnings(finalPrice); - - expect(earnings).toBe(Math.floor(100000 * 0.7)); // 70000 - }); - - it('should calculate transporter earnings with deviation', async () => { - const finalPrice = 100000; - const deviationPrice = 20000; - const earnings = service.calculateTransporterEarnings( - finalPrice, - deviationPrice, - ); - - expect(earnings).toBe(Math.floor(100000 * 0.7) + 20000); // 90000 - }); - - it('should handle zero final price', async () => { - const earnings = service.calculateTransporterEarnings(0); - - expect(earnings).toBe(0); - }); + originCity: TEHRAN, + destinationCity: ISFAHAN, + }).suggestedPrice, + ).toBe(255000); }); - describe('calculateDeviationCost', () => { - it('should calculate deviation cost for distance only', async () => { - const cost = service.calculateDeviationCost(10, 0); - - expect(cost).toBe(10 * 15000); // 150000 - }); - - it('should calculate deviation cost for time only', async () => { - const cost = service.calculateDeviationCost(0, 30); + it('rounds suggested prices to the nearest 1000 IRR', () => { + // Default config: 50000 + 120000 (distance 100) = 170000; two small + // cities ×1.205 → raw subtotal 204850. Nearest-1000 rounding gives + // 205000; a truncating implementation would return 204000 instead. + const service = makeService({ PRICING_SMALL_CITY_FACTOR: 1.205 }); - expect(cost).toBe(30 * 5000); // 150000 + const result = service.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 0, + originCity: KARAJ, + destinationCity: SHIRAZ, }); - it('should calculate combined deviation cost', async () => { - const cost = service.calculateDeviationCost(5, 20); - - expect(cost).toBe(5 * 15000 + 20 * 5000); // 175000 - }); - - it('should handle zero deviation', async () => { - const cost = service.calculateDeviationCost(0, 0); - - expect(cost).toBe(0); - }); + expect(result.suggestedPrice % 1000).toBe(0); + expect(result.suggestedPrice).toBe(205000); }); - describe('City premium calculations', () => { - it('should identify major cities correctly', async () => { - const majorToMajor = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'مشهد', - }); - - expect(majorToMajor.breakdown.cityPremiumCost).toBe(0); // Both major cities - }); - - it('should handle case insensitive city names', async () => { - const upperCase = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'مشهد', - }); + it('calculates transporter earnings with legacy floor semantics', () => { + const service = makeService(); - const mixedCase = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'مشهد', - }); - - expect(upperCase.suggestedPrice).toBe(mixedCase.suggestedPrice); - }); - - it('should apply small city factor for both non-major cities', async () => { - const result = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'کرج', - destinationCity: 'قزوین', - }); - - expect(result.breakdown.cityPremiumCost).toBeGreaterThan(0); - }); + expect(service.calculateTransporterEarnings(100000, 5000)).toBe(75000); + expect(service.calculateTransporterEarnings(100000)).toBe(70000); + expect(service.calculateTransporterEarnings(123456.78)).toBe(86419); + expect(service.calculateTransporterEarnings(99999.99, 1234.56)).toBe(71234); }); - describe('Edge cases and validations', () => { - it('should handle missing optional parameters', async () => { - const minimalInput: PricingInput = { - distanceKm: 100, - weightGr: 1, - originCity: 'تهران', - destinationCity: 'اصفهان', - }; - - const result = service.calculateSuggestedPrice(minimalInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.specialHandlingCost).toBe(0); - }); - - it('should handle very large distances', async () => { - const largeDistanceInput = { - ...mockPricingInput, - distanceKm: 5000, - }; - - const result = service.calculateSuggestedPrice(largeDistanceInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.distanceCost).toBeGreaterThan(0); - }); - - it('should handle very heavy packages', async () => { - const heavyPackageInput = { - ...mockPricingInput, - weightGr: 50000, - }; - - const result = service.calculateSuggestedPrice(heavyPackageInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.weightCost).toBeGreaterThan(0); - }); - - it('should handle zero weight packages', async () => { - const zeroWeightInput = { - ...mockPricingInput, - weightGr: 0, - }; - - const result = service.calculateSuggestedPrice(zeroWeightInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.weightCost).toBe(0); - }); + it('calculates deviation cost', () => { + expect(makeService().calculateDeviationCost(5, 10)).toBe(125000); + expect(makeService().calculateDeviationCost(0, 30)).toBe(150000); }); - describe('Configuration variations', () => { - it('should use custom configuration values', async () => { - const customConfig = { - ...defaultConfig, - PRICING_BASE_PRICE: 100000, - PRICING_DRIVER_SHARE: 0.8, - }; - - configService.get.mockImplementation( - (key: string, defaultValue?: any) => { - return customConfig[key] || defaultValue; - }, - ); - - // Recreate service with new config - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - const customService = module.get(PricingService); - - const result = customService.calculateSuggestedPrice(mockPricingInput); - const earnings = customService.calculateTransporterEarnings(100000); - - expect(result.breakdown.basePrice).toBe(100000); - expect(earnings).toBe(80000); - }); - - it('should use default values when config is missing', async () => { - configService.get.mockImplementation( - (key: string, defaultValue?: any) => { - return defaultValue; - }, - ); - - // Recreate service with missing config - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - const defaultService = module.get(PricingService); - const result = defaultService.calculateSuggestedPrice(mockPricingInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.basePrice).toBe(50000); // Default value - }); - - it('should parse major cities from configuration', async () => { - const customConfig = { - ...defaultConfig, - PRICING_MAJOR_CITIES: 'شیراز,تبریز,کرج', - }; - - configService.get.mockImplementation( - (key: string, defaultValue?: any) => { - return customConfig[key] || defaultValue; - }, - ); - - // Recreate service with new config - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - const customService = module.get(PricingService); - - const shirazToTabriz = customService.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'شیراز', - destinationCity: 'تبریز', - }); - - expect(shirazToTabriz.breakdown.cityPremiumCost).toBe(0); - }); + it('calculates distance cost', () => { + expect(makeService().calculateDistanceCost(150)).toBe(177500); + expect(makeService().calculateDistanceCost(0)).toBe(0); }); - describe('Price breakdown validation', () => { - it('should have all breakdown components', async () => { - const result = service.calculateSuggestedPrice(mockPricingInput); - - expect(result.breakdown).toHaveProperty('basePrice'); - expect(result.breakdown).toHaveProperty('distanceCost'); - expect(result.breakdown).toHaveProperty('weightCost'); - expect(result.breakdown).toHaveProperty('specialHandlingCost'); - expect(result.breakdown).toHaveProperty('deviationCost'); - expect(result.breakdown).toHaveProperty('cityPremiumCost'); + it('honours configuration overrides (customBase legacy variant)', () => { + const service = makeService({ + PRICING_BASE_PRICE: 60000, + PRICING_FUEL_RATE: 150, + PRICING_DRIVER_SHARE: 0.65, }); - it('should have positive or zero breakdown values', async () => { - const result = service.calculateSuggestedPrice(mockPricingInput); - - Object.values(result.breakdown).forEach((cost) => { - expect(cost).toBeGreaterThanOrEqual(0); - }); + const price = service.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 2, + originCity: TEHRAN, + destinationCity: ISFAHAN, }); - it('should calculate total from breakdown components', async () => { - const fragileInput = { ...mockPricingInput, isFragile: true }; - const result = service.calculateSuggestedPrice(fragileInput); - - const calculatedTotal = - result.breakdown.basePrice + - result.breakdown.distanceCost + - result.breakdown.weightCost; - - expect(result.suggestedPrice).toBeGreaterThan(calculatedTotal); - }); - }); - - describe('Mathematical precision', () => { - it('should handle floating point calculations correctly', async () => { - const precisionInput = { - ...mockPricingInput, - distanceKm: 150.5, - weightGr: 2750, - }; - - const result = service.calculateSuggestedPrice(precisionInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(Number.isInteger(result.suggestedPrice)).toBe(true); - }); - - it('should maintain consistency across multiple calculations', async () => { - const result1 = service.calculateSuggestedPrice(mockPricingInput); - const result2 = service.calculateSuggestedPrice(mockPricingInput); - - expect(result1.suggestedPrice).toBe(result2.suggestedPrice); - expect(result1.breakdown).toEqual(result2.breakdown); - }); + expect(price.suggestedPrice).toBe(175000); + expect(service.calculateTransporterEarnings(175000)).toBe(113750); }); }); diff --git a/src/modules/pricing/pricing.service.ts b/src/modules/pricing/pricing.service.ts index d00924d..89854d2 100644 --- a/src/modules/pricing/pricing.service.ts +++ b/src/modules/pricing/pricing.service.ts @@ -1,231 +1,43 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { PricingInput, PricingResult } from './pricing.types'; - -interface DistanceTier { - minKm: number; - maxKm: number; - ratePerKm: number; -} +import { PricingInput, PricingResult } from './pricing.types'; +import { buildPricingStrategies } from './strategies/pricing-factory'; +import { PricingEngine } from './pricing.engine'; + +/** + * Thin NestJS facade over `PricingEngine`. All arithmetic lives in + * `PricingEngine` and the strategies; this class exists only to wire the + * `PRICING_*` configuration once per boot and expose the three methods the + * rest of the codebase consumes. + */ @Injectable() export class PricingService { - private readonly basePrice: number; - private readonly fuelRate: number; - private readonly weightBaseRate: number; - private readonly platformCommission: number; - private readonly driverShare: number; - private readonly fragileMultiplier: number; - private readonly perishableMultiplier: number; - private readonly bothFragilePerishableMultiplier: number; - private readonly majorCityOrigin: number; - private readonly majorCityDestination: number; - private readonly bothMajorCities: number; - private readonly smallCityFactor: number; - private readonly deviationRate: number; - private readonly timeDeviationRate: number; - private readonly majorCities: string[]; - private readonly distanceTiers: DistanceTier[]; - - constructor(config: ConfigService) { - // Base Constants (in Iranian Rial) - this.basePrice = config.get('PRICING_BASE_PRICE', 50000); - this.fuelRate = config.get('PRICING_FUEL_RATE', 200); - this.weightBaseRate = config.get('PRICING_WEIGHT_BASE_RATE', 10000); - this.driverShare = config.get('PRICING_DRIVER_SHARE', 0.7); - this.platformCommission = 1 - this.driverShare; - - // Multipliers - this.fragileMultiplier = config.get( - 'PRICING_FRAGILE_MULTIPLIER', - 1.25, - ); - this.perishableMultiplier = config.get( - 'PRICING_PERISHABLE_MULTIPLIER', - 1.35, - ); - this.bothFragilePerishableMultiplier = config.get( - 'PRICING_BOTH_FRAGILE_PERISHABLE', - 1.5, - ); - - // City Premium Factors - this.majorCityOrigin = config.get('PRICING_MAJOR_CITY_ORIGIN', 0.9); - this.majorCityDestination = config.get( - 'PRICING_MAJOR_CITY_DESTINATION', - 1.3, - ); - this.bothMajorCities = config.get('PRICING_BOTH_MAJOR_CITIES', 1.0); - this.smallCityFactor = config.get('PRICING_SMALL_CITY_FACTOR', 1.2); - - // Route Deviation Costs - this.deviationRate = config.get('PRICING_DEVIATION_RATE', 15000); - this.timeDeviationRate = config.get( - 'PRICING_TIME_DEVIATION_RATE', - 5000, - ); + private readonly engine: PricingEngine; - // Major Cities List - const majorCitiesString = config.get( - 'PRICING_MAJOR_CITIES', - 'تهران,اصفهان,مشهد', - ); - this.majorCities = majorCitiesString.split(',').map((city) => city.trim()); - - // Distance Pricing Tiers - this.distanceTiers = [ - { - minKm: 0, - maxKm: 100, - ratePerKm: config.get('PRICING_TIER_1_RATE', 1000), - }, - { - minKm: 101, - maxKm: 300, - ratePerKm: config.get('PRICING_TIER_2_RATE', 950), - }, - { - minKm: 301, - maxKm: 600, - ratePerKm: config.get('PRICING_TIER_3_RATE', 850), - }, - { - minKm: 601, - maxKm: 1000, - ratePerKm: config.get('PRICING_TIER_4_RATE', 750), - }, - { - minKm: 1001, - maxKm: Infinity, - ratePerKm: config.get('PRICING_TIER_5_RATE', 600), - }, - ]; + constructor(configService: ConfigService) { + this.engine = new PricingEngine(buildPricingStrategies(configService)); } calculateSuggestedPrice(input: PricingInput): PricingResult { - // Base components - const basePrice = this.basePrice; - const distanceCost = this.calculateDistanceCost(input.distanceKm); - const weightCost = this.calculateWeightCost(input.weightGr); - - // Calculate subtotal before multipliers - let subtotal = basePrice + distanceCost + weightCost; - - // Apply multipliers - const specialMultiplier = this.calculateSpecialHandlingMultiplier( - input.isFragile ?? false, - input.isPerishable ?? false, - ); - subtotal *= specialMultiplier; - - // Apply city premium - const cityPremium = this.calculateCityPremium( - input.originCity, - input.destinationCity, - ); - subtotal *= cityPremium; - - // Round final price - const finalPrice = Math.round(subtotal / 1000) * 1000; - - return { - suggestedPrice: Math.floor(finalPrice), - breakdown: { - basePrice, - distanceCost, - weightCost, - deviationCost: 0, - specialHandlingCost: Math.max(0, specialMultiplier - 1) * finalPrice, - cityPremiumCost: Math.max(0, cityPremium - 1) * finalPrice, - }, - }; - } - - // Calculate distance-based cost using tiered pricing - calculateDistanceCost(distanceKm: number): number { - let totalCost = this.fuelRate * distanceKm; - let remainingDistance = distanceKm; - - for (const tier of this.distanceTiers) { - if (remainingDistance <= 0) break; - - if (tier.maxKm === Infinity) { - totalCost += remainingDistance * tier.ratePerKm; - break; - } - - const tierCapacity = tier.maxKm - tier.minKm + (tier.minKm ? 1 : 0); - const applicableDistance = Math.min(remainingDistance, tierCapacity); - - totalCost += applicableDistance * tier.ratePerKm; - remainingDistance -= applicableDistance; - } - - return totalCost; - } - - calculateTransporterEarnings(finalPrice: number, deviationPrice?: number) { - return Math.floor(finalPrice * this.driverShare + (deviationPrice ?? 0)); + return this.engine.calculateSuggestedPrice(input); } - // Platform commission - - // Calculate weight-based additional cost - private calculateWeightCost(weightGr: number): number { - if (weightGr < 500) { - return 0; // No additional cost for packages ≤ 1kg - } - return (weightGr / 100) * this.weightBaseRate; - } - - // Calculate special multipliers - private calculateSpecialHandlingMultiplier( - isFragile: boolean, - isPerishable: boolean, + calculateTransporterEarnings( + finalPrice: number, + deviationPrice?: number, ): number { - if (isFragile && isPerishable) { - return this.bothFragilePerishableMultiplier; - } else if (isFragile) { - return this.fragileMultiplier; - } else if (isPerishable) { - return this.perishableMultiplier; - } - return 1.0; + return this.engine.calculateTransporterEarnings(finalPrice, deviationPrice); } - // Calculate deviation cost (Based on both distance and duration) calculateDeviationCost( additionalKm: number, additionalMinutes: number, ): number { - const kmCost = additionalKm * this.deviationRate; - const timeCost = additionalMinutes * this.timeDeviationRate; - return kmCost + timeCost; - } - - // Calculate city-based premium factor - private calculateCityPremium( - originCity: string, - destinationCity: string, - ): number { - const originMajor = this.isMajorCity(originCity); - const destMajor = this.isMajorCity(destinationCity); - - if (originMajor && destMajor) { - return this.bothMajorCities; - } else if (originMajor && !destMajor) { - return this.majorCityOrigin; - } else if (!originMajor && destMajor) { - return this.majorCityDestination; - } else { - return this.smallCityFactor; - } + return this.engine.calculateDeviationCost(additionalKm, additionalMinutes); } - // Check if a city is considered a major city - private isMajorCity(cityName: string): boolean { - return this.majorCities.some( - (city) => city.toLowerCase() === cityName.toLowerCase(), - ); + calculateDistanceCost(distanceKm: number): number { + return this.engine.calculateDistanceCost(distanceKm); } } diff --git a/src/modules/pricing/strategies/city-premium.strategy.spec.ts b/src/modules/pricing/strategies/city-premium.strategy.spec.ts new file mode 100644 index 0000000..f3176d2 --- /dev/null +++ b/src/modules/pricing/strategies/city-premium.strategy.spec.ts @@ -0,0 +1,48 @@ +import { CityPremiumStrategy } from './city-premium.strategy'; + +// Persian city names kept as escapes so the spec file stays ASCII on disk. +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; +const MASHHAD = '\u0645\u0634\u0647\u062F'; + +describe('CityPremiumStrategy', () => { + const strategy = new CityPremiumStrategy(0.9, 1.3, 1.0, 1.2, [ + TEHRAN, + ISFAHAN, + MASHHAD, + ]); + + it('major origin + major destination uses the both-major factor', () => { + expect(strategy.calculate(TEHRAN, MASHHAD)).toBe(1.0); + }); + + it('major origin only uses the origin factor', () => { + expect(strategy.calculate(TEHRAN, KARAJ)).toBe(0.9); + }); + + it('major destination only uses the destination factor', () => { + expect(strategy.calculate(KARAJ, ISFAHAN)).toBe(1.3); + }); + + it('two small cities use the small-city factor', () => { + expect(strategy.calculate(KARAJ, SHIRAZ)).toBe(1.2); + }); + + it('matches cities case-insensitively (legacy behaviour)', () => { + const latin = new CityPremiumStrategy(0.9, 1.3, 1.0, 1.2, ['Tehran']); + + expect(latin.calculate('tehran', KARAJ)).toBe(0.9); + expect(latin.calculate('TEHRAN', 'TEHRAN')).toBe(1.0); + }); + + it('honours custom factors', () => { + const custom = new CityPremiumStrategy(2, 3, 0.5, 1.1, [TEHRAN, MASHHAD]); + + expect(custom.calculate(MASHHAD, KARAJ)).toBe(2); + expect(custom.calculate(KARAJ, MASHHAD)).toBe(3); + expect(custom.calculate(MASHHAD, MASHHAD)).toBe(0.5); + expect(custom.calculate(KARAJ, SHIRAZ)).toBe(1.1); + }); +}); diff --git a/src/modules/pricing/strategies/city-premium.strategy.ts b/src/modules/pricing/strategies/city-premium.strategy.ts new file mode 100644 index 0000000..fbf9853 --- /dev/null +++ b/src/modules/pricing/strategies/city-premium.strategy.ts @@ -0,0 +1,33 @@ +/** Major-city premium factors, resolved per the legacy four-quadrant rule. */ +export class CityPremiumStrategy { + constructor( + private readonly majorCityOrigin: number, + private readonly majorCityDestination: number, + private readonly bothMajorCities: number, + private readonly smallCityFactor: number, + private readonly majorCities: string[], + ) {} + + calculate(originCity: string, destinationCity: string): number { + const originMajor = this.isMajorCity(originCity); + const destMajor = this.isMajorCity(destinationCity); + + if (originMajor && destMajor) { + return this.bothMajorCities; + } + if (originMajor) { + return this.majorCityOrigin; + } + if (destMajor) { + return this.majorCityDestination; + } + return this.smallCityFactor; + } + + /** Case-insensitive match against the configured `PRICING_MAJOR_CITIES`. */ + private isMajorCity(cityName: string): boolean { + return this.majorCities.some( + (city) => city.toLowerCase() === cityName.toLowerCase(), + ); + } +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/deviation-cost.strategy.spec.ts b/src/modules/pricing/strategies/deviation-cost.strategy.spec.ts new file mode 100644 index 0000000..7b107d8 --- /dev/null +++ b/src/modules/pricing/strategies/deviation-cost.strategy.spec.ts @@ -0,0 +1,27 @@ +import { DeviationCostStrategy } from './deviation-cost.strategy'; + +describe('DeviationCostStrategy', () => { + const strategy = new DeviationCostStrategy(15000, 5000); + + it('charges nothing for a zero deviation', () => { + expect(strategy.calculate(0, 0)).toBe(0); + }); + + it('combines km cost and time cost additively (legacy golden values)', () => { + expect(strategy.calculate(5, 10)).toBe(125000); + expect(strategy.calculate(0, 30)).toBe(150000); + expect(strategy.calculate(100, 0)).toBe(1500000); + }); + + it('handles fractional km and minutes like the legacy engine', () => { + expect(strategy.calculate(12.5, 7.5)).toBe(225000); + }); + + it('honours custom rates', () => { + const custom = new DeviationCostStrategy(100, 10); + + expect(custom.calculate(3, 4)).toBe(340); + expect(custom.calculate(0, 4)).toBe(40); + expect(custom.calculate(3, 0)).toBe(300); + }); +}); diff --git a/src/modules/pricing/strategies/deviation-cost.strategy.ts b/src/modules/pricing/strategies/deviation-cost.strategy.ts new file mode 100644 index 0000000..e3e641b --- /dev/null +++ b/src/modules/pricing/strategies/deviation-cost.strategy.ts @@ -0,0 +1,13 @@ +/** Route-deviation cost: `additionalKm × kmRate + additionalMinutes × timeRate`. */ +export class DeviationCostStrategy { + constructor( + private readonly deviationRate: number, + private readonly timeDeviationRate: number, + ) {} + + calculate(additionalKm: number, additionalMinutes: number): number { + const kmCost = additionalKm * this.deviationRate; + const timeCost = additionalMinutes * this.timeDeviationRate; + return kmCost + timeCost; + } +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/distance-tier.strategy.spec.ts b/src/modules/pricing/strategies/distance-tier.strategy.spec.ts new file mode 100644 index 0000000..7c95555 --- /dev/null +++ b/src/modules/pricing/strategies/distance-tier.strategy.spec.ts @@ -0,0 +1,72 @@ +import { DistanceTier, DistanceTierStrategy } from './distance-tier.strategy'; + +/** Builds the default 5-tier configuration exactly like pricing-factory does. */ +function buildDefaultTiers( + rates: number[] = [1000, 950, 850, 750, 600], +): DistanceTier[] { + const bounds: Array<[number | null, number]> = [ + [100, rates[0]], + [300, rates[1]], + [600, rates[2]], + [1000, rates[3]], + [null, rates[4]], + ]; + return bounds.map( + ([upToKm, rate], index) => + new DistanceTier( + upToKm, + rate, + index === 0 ? 0 : (bounds[index - 1][0] as number), + ), + ); +} + +describe('DistanceTier', () => { + it('derives spans from cumulative bounds with no hand-written widths', () => { + expect(buildDefaultTiers().map((tier) => tier.getSpanKm())).toEqual([ + 100, 200, 300, 400, Infinity, + ]); + }); +}); + +describe('DistanceTierStrategy (default legacy rates)', () => { + const strategy = new DistanceTierStrategy(200, buildDefaultTiers()); + + it.each([ + [0, 0], + [50.5, 60600], + [100, 120000], + [101, 121150], + [150, 177500], + [265, 309750], + [300, 350000], + [301, 351050], + [600, 665000], + [601, 665950], + [1000, 1045000], + [1001, 1045800], + [1500, 1445000], + [2500, 2245000], + ])('distance %p km costs %p (legacy golden value)', (km, expected) => { + expect(strategy.calculate(km)).toBe(expected); + }); +}); + +describe('DistanceTierStrategy (custom configuration)', () => { + it('honours custom tier rates (legacy customTiers variant)', () => { + const strategy = new DistanceTierStrategy( + 200, + buildDefaultTiers([800, 700, 600, 500, 400]), + ); + + expect(strategy.calculate(101)).toBe(100900); + expect(strategy.calculate(1001)).toBe(800600); + }); + + it('honours a custom fuel rate (legacy customBase variant)', () => { + const strategy = new DistanceTierStrategy(150, buildDefaultTiers()); + + expect(strategy.calculate(100)).toBe(115000); + expect(strategy.calculate(0.5)).toBe(575); + }); +}); diff --git a/src/modules/pricing/strategies/distance-tier.strategy.ts b/src/modules/pricing/strategies/distance-tier.strategy.ts new file mode 100644 index 0000000..0b8eecd --- /dev/null +++ b/src/modules/pricing/strategies/distance-tier.strategy.ts @@ -0,0 +1,58 @@ +/** + * One billed distance bucket. The first tier owns the open interval + * (0, upToKm]; every following tier owns (previousUpToKm, upToKm]. + * + * B-10 fix: the legacy implementation computed tier capacity as + * `maxKm - minKm + (minKm ? 1 : 0)` — a hand-tuned `+1` that silently + * compensated for its own 1-based bounds (101, 301, 601, 1001). Rewriting it + * as cumulative bounds makes the algebra explicit: with `minKm = prevMax + 1`, + * the legacy capacity `maxKm - minKm + 1` reduces exactly to + * `upToKm - previousUpToKm`, so the span below reproduces the legacy output + * byte-for-byte without any magic constant. + */ +export class DistanceTier { + constructor( + /** Cumulative upper bound in km, or `null` for the open-ended last tier. */ + public readonly upToKm: number | null, + public readonly ratePerKm: number, + /** Upper bound of the previous tier (0 for the first tier). */ + private readonly previousUpToKm: number, + ) {} + + /** Kilometres this tier bills, derived purely from the configured bounds. */ + getSpanKm(): number { + if (this.upToKm === null) { + return Infinity; + } + return this.upToKm - this.previousUpToKm; + } +} + +/** Tiered per-kilometre distance cost (plus a flat fuel rate per km). */ +export class DistanceTierStrategy { + constructor( + private readonly fuelRate: number, + private readonly tiers: DistanceTier[], + ) {} + + calculate(distanceKm: number): number { + let totalCost = this.fuelRate * distanceKm; + let remainingDistance = distanceKm; + + for (const tier of this.tiers) { + // Guard clause (B-10): stop as soon as every km has been billed so the + // open-ended tier can never re-bill, and non-positive distances exit + // before any tier arithmetic runs. + if (remainingDistance <= 0) { + break; + } + + const applicableDistance = Math.min(remainingDistance, tier.getSpanKm()); + + totalCost += applicableDistance * tier.ratePerKm; + remainingDistance -= applicableDistance; + } + + return totalCost; + } +} diff --git a/src/modules/pricing/strategies/earnings.strategy.spec.ts b/src/modules/pricing/strategies/earnings.strategy.spec.ts new file mode 100644 index 0000000..ad8401a --- /dev/null +++ b/src/modules/pricing/strategies/earnings.strategy.spec.ts @@ -0,0 +1,28 @@ +import { EarningsStrategy } from './earnings.strategy'; + +describe('EarningsStrategy', () => { + it('matches the legacy earnings table (golden default)', () => { + const strategy = new EarningsStrategy(0.7); + + expect(strategy.calculate(100000)).toBe(70000); + expect(strategy.calculate(100000, 5000)).toBe(75000); + expect(strategy.calculate(0)).toBe(0); + expect(strategy.calculate(123456.78)).toBe(86419); + expect(strategy.calculate(99999.99, 1234.56)).toBe(71234); + }); + + it('floors the final result (legacy Math.floor semantics)', () => { + const strategy = new EarningsStrategy(0.7); + + expect(strategy.calculate(99999.99)).toBe(69999); + }); + + it('honours a custom driver share', () => { + const strategy = new EarningsStrategy(0.65); + + expect(strategy.calculate(100000)).toBe(65000); + expect(strategy.calculate(100000, 5000)).toBe(70000); + expect(strategy.calculate(123456.78)).toBe(80246); + expect(strategy.calculate(99999.99, 1234.56)).toBe(66234); + }); +}); \ No newline at end of file diff --git a/src/modules/pricing/strategies/earnings.strategy.ts b/src/modules/pricing/strategies/earnings.strategy.ts new file mode 100644 index 0000000..be7e79a --- /dev/null +++ b/src/modules/pricing/strategies/earnings.strategy.ts @@ -0,0 +1,11 @@ +/** + * Driver earnings: share of the final price plus any deviation price, + * floored to a whole IRR (legacy `Math.floor` semantics preserved). + */ +export class EarningsStrategy { + constructor(private readonly driverShare: number) {} + + calculate(finalPrice: number, deviationPrice?: number): number { + return Math.floor(finalPrice * this.driverShare + (deviationPrice ?? 0)); + } +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/pricing-factory.spec.ts b/src/modules/pricing/strategies/pricing-factory.spec.ts new file mode 100644 index 0000000..9e7c786 --- /dev/null +++ b/src/modules/pricing/strategies/pricing-factory.spec.ts @@ -0,0 +1,86 @@ +import { ConfigService } from '@nestjs/config'; + +import { PricingEngine } from '../pricing.engine'; +import { buildPricingStrategies, parseMajorCities } from './pricing-factory'; + +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; +const MASHHAD = '\u0645\u0634\u0647\u062F'; + +function makeEngine(overrides: Record = {}): PricingEngine { + const config = { + get: (key: string, def?: unknown) => + key in overrides ? overrides[key] : def, + }; + return new PricingEngine( + buildPricingStrategies(config as unknown as ConfigService), + ); +} + +describe('parseMajorCities', () => { + it('splits and trims the comma-separated list', () => { + expect(parseMajorCities('\u062A\u0647\u0631\u0627\u0646, \u0627\u0635\u0641\u0647\u0627\u0646 ,\u0645\u0634\u0647\u062F')).toEqual([ + TEHRAN, + ISFAHAN, + MASHHAD, + ]); + }); +}); + +describe('buildPricingStrategies', () => { + it('applies legacy defaults end-to-end', () => { + const engine = makeEngine(); + + expect(engine.calculateTransporterEarnings(100000)).toBe(70000); + expect(engine.calculateDistanceCost(1000)).toBe(1045000); + + const price = engine.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 2, + originCity: TEHRAN, + destinationCity: ISFAHAN, + }); + expect(price.suggestedPrice).toBe(170000); + }); + + it('honours tier-rate overrides', () => { + const engine = makeEngine({ PRICING_TIER_1_RATE: 800 }); + + // fuel 200/km + 800/km tier-1 rate for the first 100 km. + expect(engine.calculateDistanceCost(100)).toBe(100000); + }); + + it('honours base-price and driver-share overrides', () => { + const engine = makeEngine({ + PRICING_BASE_PRICE: 60000, + PRICING_DRIVER_SHARE: 0.65, + }); + + expect(engine.calculateTransporterEarnings(100000)).toBe(65000); + + const price = engine.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 0, + originCity: TEHRAN, + destinationCity: ISFAHAN, + }); + expect(price.breakdown.basePrice).toBe(60000); + expect(price.suggestedPrice).toBe(180000); + }); + + it('honours major-city list overrides', () => { + const engine = makeEngine({ PRICING_MAJOR_CITIES: SHIRAZ + ',' + KARAJ }); + + // karaj→shiraz are now both major: factor 1.0 instead of 1.2. + const price = engine.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 0, + originCity: KARAJ, + destinationCity: SHIRAZ, + }); + expect(price.suggestedPrice).toBe(170000); + expect(price.breakdown.cityPremiumCost).toBe(0); + }); +}); \ No newline at end of file diff --git a/src/modules/pricing/strategies/pricing-factory.ts b/src/modules/pricing/strategies/pricing-factory.ts new file mode 100644 index 0000000..205d344 --- /dev/null +++ b/src/modules/pricing/strategies/pricing-factory.ts @@ -0,0 +1,78 @@ +import { ConfigService } from '@nestjs/config'; + +import { DistanceTier, DistanceTierStrategy } from './distance-tier.strategy'; +import { WeightStrategy } from './weight.strategy'; +import { SpecialHandlingStrategy } from './special-handling.strategy'; +import { CityPremiumStrategy } from './city-premium.strategy'; +import { DeviationCostStrategy } from './deviation-cost.strategy'; +import { EarningsStrategy } from './earnings.strategy'; + +/** + * Single place where `PRICING_*` configuration meets strategy constructors. + * Defaults mirror the legacy `PricingService` constructor exactly, so a + * missing env variable produces identical pricing to the pre-refactor engine. + */ +export function buildPricingStrategies(config: ConfigService) { + const driverShare = config.get('PRICING_DRIVER_SHARE', 0.7); + + const distanceTierStrategy = new DistanceTierStrategy( + config.get('PRICING_FUEL_RATE', 200), + buildDistanceTiers(config), + ); + + return { + distanceTierStrategy, + weightStrategy: new WeightStrategy( + config.get('PRICING_WEIGHT_BASE_RATE', 10000), + ), + specialHandlingStrategy: new SpecialHandlingStrategy( + config.get('PRICING_FRAGILE_MULTIPLIER', 1.25), + config.get('PRICING_PERISHABLE_MULTIPLIER', 1.35), + config.get('PRICING_BOTH_FRAGILE_PERISHABLE', 1.5), + ), + cityPremiumStrategy: new CityPremiumStrategy( + config.get('PRICING_MAJOR_CITY_ORIGIN', 0.9), + config.get('PRICING_MAJOR_CITY_DESTINATION', 1.3), + config.get('PRICING_BOTH_MAJOR_CITIES', 1.0), + config.get('PRICING_SMALL_CITY_FACTOR', 1.2), + parseMajorCities( + config.get('PRICING_MAJOR_CITIES', 'تهران,اصفهان,مشهد'), + ), + ), + deviationCostStrategy: new DeviationCostStrategy( + config.get('PRICING_DEVIATION_RATE', 15000), + config.get('PRICING_TIME_DEVIATION_RATE', 5000), + ), + earningsStrategy: new EarningsStrategy(driverShare), + basePrice: config.get('PRICING_BASE_PRICE', 50000), + }; +} + +export type PricingStrategies = ReturnType; + +/** Parses the comma-separated `PRICING_MAJOR_CITIES` env value. */ +export function parseMajorCities(majorCitiesString: string): string[] { + return majorCitiesString.split(',').map((city) => city.trim()); +} + +/** Cumulative distance tiers; spans are derived, never hand-written. */ +function buildDistanceTiers(config: ConfigService): DistanceTier[] { + const bounds: Array<[number | null, string]> = [ + [100, 'PRICING_TIER_1_RATE'], + [300, 'PRICING_TIER_2_RATE'], + [600, 'PRICING_TIER_3_RATE'], + [1000, 'PRICING_TIER_4_RATE'], + [null, 'PRICING_TIER_5_RATE'], + ]; + + const defaultRates = [1000, 950, 850, 750, 600]; + + return bounds.map(([upToKm, configName], index) => { + const previousUpToKm = index === 0 ? 0 : (bounds[index - 1][0] as number); + return new DistanceTier( + upToKm, + config.get(configName, defaultRates[index]), + previousUpToKm, + ); + }); +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/special-handling.strategy.spec.ts b/src/modules/pricing/strategies/special-handling.strategy.spec.ts new file mode 100644 index 0000000..f5e253f --- /dev/null +++ b/src/modules/pricing/strategies/special-handling.strategy.spec.ts @@ -0,0 +1,29 @@ +import { SpecialHandlingStrategy } from './special-handling.strategy'; + +describe('SpecialHandlingStrategy', () => { + const strategy = new SpecialHandlingStrategy(1.25, 1.35, 1.5); + + it('returns 1 when neither flag is set', () => { + expect(strategy.calculate(false, false)).toBe(1); + }); + + it('returns the fragile multiplier', () => { + expect(strategy.calculate(true, false)).toBe(1.25); + }); + + it('returns the perishable multiplier', () => { + expect(strategy.calculate(false, true)).toBe(1.35); + }); + + it('both flags win over the single-flag multipliers', () => { + expect(strategy.calculate(true, true)).toBe(1.5); + }); + + it('honours custom multipliers', () => { + const custom = new SpecialHandlingStrategy(2, 3, 5); + + expect(custom.calculate(true, false)).toBe(2); + expect(custom.calculate(false, true)).toBe(3); + expect(custom.calculate(true, true)).toBe(5); + }); +}); diff --git a/src/modules/pricing/strategies/special-handling.strategy.ts b/src/modules/pricing/strategies/special-handling.strategy.ts new file mode 100644 index 0000000..d5a50a3 --- /dev/null +++ b/src/modules/pricing/strategies/special-handling.strategy.ts @@ -0,0 +1,21 @@ +/** Fragile/perishable/both multipliers; 1 when neither flag is set. */ +export class SpecialHandlingStrategy { + constructor( + private readonly fragileMultiplier: number, + private readonly perishableMultiplier: number, + private readonly bothFragilePerishableMultiplier: number, + ) {} + + calculate(isFragile: boolean, isPerishable: boolean): number { + if (isFragile && isPerishable) { + return this.bothFragilePerishableMultiplier; + } + if (isFragile) { + return this.fragileMultiplier; + } + if (isPerishable) { + return this.perishableMultiplier; + } + return 1; + } +} diff --git a/src/modules/pricing/strategies/weight.strategy.spec.ts b/src/modules/pricing/strategies/weight.strategy.spec.ts new file mode 100644 index 0000000..89bf24c --- /dev/null +++ b/src/modules/pricing/strategies/weight.strategy.spec.ts @@ -0,0 +1,26 @@ +import { WeightStrategy } from './weight.strategy'; + +describe('WeightStrategy', () => { + const strategy = new WeightStrategy(10000); + + it('charges nothing below the 500 g threshold', () => { + expect(strategy.calculate(0)).toBe(0); + expect(strategy.calculate(1)).toBe(0); + expect(strategy.calculate(499)).toBe(0); + }); + + it('bills from exactly 500 g upward, per 100 g (threshold inclusive)', () => { + expect(strategy.calculate(500)).toBeCloseTo(50000, 6); + expect(strategy.calculate(1000)).toBeCloseTo(100000, 6); + expect(strategy.calculate(2750)).toBeCloseTo(275000, 6); + }); + + it('bills fractional weights with legacy arithmetic', () => { + expect(strategy.calculate(1234)).toBeCloseTo(123400, 6); + expect(strategy.calculate(723.4)).toBeCloseTo(72340, 6); + }); + + it('honours the configured base rate', () => { + expect(new WeightStrategy(12345).calculate(1000)).toBeCloseTo(123450, 6); + }); +}); diff --git a/src/modules/pricing/strategies/weight.strategy.ts b/src/modules/pricing/strategies/weight.strategy.ts new file mode 100644 index 0000000..b521a3e --- /dev/null +++ b/src/modules/pricing/strategies/weight.strategy.ts @@ -0,0 +1,11 @@ +/** Weight surcharge: free below 500 g, then cost per 100 g above. */ +export class WeightStrategy { + constructor(private readonly weightBaseRate: number) {} + + calculate(weightGr: number): number { + if (weightGr < 500) { + return 0; + } + return (weightGr / 100) * this.weightBaseRate; + } +} diff --git a/src/modules/prisma/prisma.module.ts b/src/modules/prisma/prisma.module.ts index ec0ce32..64adbcf 100644 --- a/src/modules/prisma/prisma.module.ts +++ b/src/modules/prisma/prisma.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; +import { TransactionRunner } from './transaction-runner'; @Module({ - providers: [PrismaService], - exports: [PrismaService], + providers: [PrismaService, TransactionRunner], + exports: [PrismaService, TransactionRunner], }) export class PrismaModule {} diff --git a/src/modules/prisma/transaction-runner.spec.ts b/src/modules/prisma/transaction-runner.spec.ts new file mode 100644 index 0000000..a22c43e --- /dev/null +++ b/src/modules/prisma/transaction-runner.spec.ts @@ -0,0 +1,126 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from './prisma.service'; +import { TransactionRunner } from './transaction-runner'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; + +describe('TransactionRunner', () => { + let runner: TransactionRunner; + let prisma: DeepMockProxy; + let config: DeepMockProxy; + + const createRunner = async (timeoutMs?: number) => { + config.get.mockImplementation((key: string, defaultValue?: unknown) => { + if ( + timeoutMs !== undefined && + key === 'PRISMA_TX_TIMEOUT_MS' + ) { + return timeoutMs; + } + return defaultValue; + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionRunner, + { provide: PrismaService, useValue: prisma }, + { provide: ConfigService, useValue: config }, + ], + }).compile(); + + return module.get(TransactionRunner); + }; + + beforeEach(async () => { + jest.resetAllMocks(); + prisma = mockDeep(); + config = mockDeep(); + // jest-mock-extended returns undefined by default; make $transaction + // actually invoke the callback like Prisma does. + (prisma.$transaction as unknown as jest.Mock).mockImplementation( + async (fn: (tx: unknown) => Promise) => fn({}), + ); + runner = await createRunner(); + }); + + it('delegates to $transaction with the default timeout', async () => { + const fn = jest.fn().mockResolvedValue('ok'); + + const result = await runner.run(fn); + + expect(result).toBe('ok'); + expect(prisma.$transaction).toHaveBeenCalledWith(fn, { + timeout: 5000, + isolationLevel: undefined, + }); + }); + + it('uses the configured timeout from PRISMA_TX_TIMEOUT_MS', async () => { + runner = await createRunner(12345); + const fn = jest.fn().mockResolvedValue('ok'); + + await runner.run(fn); + + expect(prisma.$transaction).toHaveBeenCalledWith(fn, { + timeout: 12345, + isolationLevel: undefined, + }); + }); + + it('honors a per-call timeout override', async () => { + const fn = jest.fn().mockResolvedValue(1); + + await runner.run(fn, { timeoutMs: 250 }); + + expect(prisma.$transaction).toHaveBeenCalledWith(fn, { + timeout: 250, + isolationLevel: undefined, + }); + }); + + it('runIsolated forces serializable isolation unless overridden', async () => { + const fn = jest.fn().mockResolvedValue(1); + + await runner.runIsolated(fn); + expect(prisma.$transaction).toHaveBeenLastCalledWith(fn, { + timeout: 5000, + isolationLevel: 'Serializable', + }); + + await runner.runIsolated(fn, { + isolationLevel: 'ReadCommitted' as never, + }); + expect(prisma.$transaction).toHaveBeenLastCalledWith(fn, { + timeout: 5000, + isolationLevel: 'ReadCommitted', + }); + }); + + it('propagates the transaction result and rethrows non-timeout errors untouched', async () => { + const fn = jest.fn().mockRejectedValue(new Error('boom')); + + await expect(runner.run(fn)).rejects.toThrow('boom'); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it('maps P2028 to TransactionTimedOutError with the effective timeout', async () => { + const p2028 = Object.assign(new Error('Timed out fetching a new page'), { + code: 'P2028', + }); + const fn = jest.fn().mockRejectedValue(p2028); + + await expect(runner.run(fn)).rejects.toMatchObject({ + name: 'TransactionTimedOutError', + message: 'Transaction exceeded the configured timeout of 5000ms', + }); + }); + + it('maps P2028 to TransactionTimedOutError with the override timeout in the message', async () => { + const p2028 = Object.assign(new Error('Timed out'), { code: 'P2028' }); + const fn = jest.fn().mockRejectedValue(p2028); + + await expect(runner.run(fn, { timeoutMs: 250 })).rejects.toMatchObject({ + message: 'Transaction exceeded the configured timeout of 250ms', + }); + }); +}); diff --git a/src/modules/prisma/transaction-runner.ts b/src/modules/prisma/transaction-runner.ts new file mode 100644 index 0000000..6cd16e2 --- /dev/null +++ b/src/modules/prisma/transaction-runner.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Prisma } from '../../../generated/prisma'; +import { ConfigKey } from '../../common/config/config-names'; +import { PrismaService } from './prisma.service'; +import { PrismaTransaction } from './prisma.types'; + +/** Default interactive-transaction timeout when none is configured (ms). */ +const DEFAULT_TX_TIMEOUT_MS = 5000; + +/** + * Thrown when a transaction exceeds its configured timeout (Prisma P2028). + * All other transaction errors are rethrown untouched — mapping Prisma errors + * to HTTP responses stays the responsibility of the global exceptions filter. + */ +export class TransactionTimedOutError extends Error { + constructor(timeoutMs: number) { + super(`Transaction exceeded the configured timeout of ${timeoutMs}ms`); + this.name = 'TransactionTimedOutError'; + } +} + +export interface TransactionRunnerOptions { + /** Override the configured default timeout (ms) for this transaction. */ + timeoutMs?: number; + /** Explicit isolation level — `run()` leaves it at the engine default. */ + isolationLevel?: Prisma.TransactionIsolationLevel; +} + +type TransactionFn = (tx: PrismaTransaction) => Promise; + +function isTransactionTimeoutError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'P2028' + ); +} + +/** + * Single entry point for interactive Prisma transactions. + * + * Services should run multi-statement writes through this runner instead of + * calling `prisma.$transaction` directly so that: + * - the timeout is centralized and configurable (`PRISMA_TX_TIMEOUT_MS`); + * - timeouts surface as a typed `TransactionTimedOutError` (P2028); + * - code-minting flows can opt into `runIsolated` (serializable) so unique + * constraint retries under concurrency are safe. + */ +@Injectable() +export class TransactionRunner { + private readonly defaultTimeoutMs: number; + + constructor( + config: ConfigService, + private readonly prisma: PrismaService, + ) { + this.defaultTimeoutMs = config.get( + ConfigKey.Database.TransactionTimeoutMs, + DEFAULT_TX_TIMEOUT_MS, + ); + } + + /** Run `fn` inside one interactive transaction (engine-default isolation). */ + run( + fn: TransactionFn, + options: TransactionRunnerOptions = {}, + ): Promise { + return this.execute(fn, options); + } + + /** + * Run `fn` inside a serializable transaction. Use for flows that mint + * unique codes or read-then-write shared rows, where concurrent + * transactions must not interleave. + */ + runIsolated( + fn: TransactionFn, + options: TransactionRunnerOptions = {}, + ): Promise { + return this.execute(fn, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + ...options, + }); + } + + private execute( + fn: TransactionFn, + { timeoutMs, isolationLevel }: TransactionRunnerOptions, + ): Promise { + const timeout = timeoutMs ?? this.defaultTimeoutMs; + return this.prisma + .$transaction(fn, { timeout, isolationLevel }) + .catch((error: unknown) => { + if (isTransactionTimeoutError(error)) { + throw new TransactionTimedOutError(timeout); + } + throw error; + }); + } +} diff --git a/src/modules/turf/turf.service.spec.ts b/src/modules/turf/turf.service.spec.ts index 79ad3e7..43c3115 100644 --- a/src/modules/turf/turf.service.spec.ts +++ b/src/modules/turf/turf.service.spec.ts @@ -20,4 +20,51 @@ describe('TurfService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getDistanceToRoute', () => { + const point = { type: 'Point', coordinates: [51.389, 35.6892] } as never; + const route = { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [51.38, 35.685], + [51.34, 35.725], + ], + }, + } as never; + + it('should return distance via nearestPointOnLine when it succeeds', () => { + const nearest = { type: 'Point', coordinates: [51.38, 35.685] } as never; + turfProvider.nearestPointOnLine.mockReturnValue(nearest); + turfProvider.distance.mockReturnValue(800 as never); + + expect(service.getDistanceToRoute(point, route)).toBe(800); + expect(turfProvider.nearestPointOnLine).toHaveBeenCalledWith( + route, + point, + ); + }); + + it('should log and fall back to vertex distances when nearestPointOnLine throws', () => { + turfProvider.nearestPointOnLine.mockImplementation(() => { + throw new Error('turf failed'); + }); + // Fake point: mock shapes are not real GeoJSON — assertion needed for tsc. + + turfProvider.point.mockImplementation( + (coords: number[]) => ({ coords }) as never, + ); + turfProvider.distance.mockReturnValue(600 as never); + const loggerSpy = jest + .spyOn((service as any).logger, 'error') + .mockImplementation(() => undefined); + + expect(service.getDistanceToRoute(point, route)).toBe(600); + expect(loggerSpy).toHaveBeenCalledWith( + 'Error calculating distance to route:', + expect.any(Error), + ); + }); + }); }); diff --git a/src/modules/turf/turf.service.ts b/src/modules/turf/turf.service.ts index a19d10a..6af1a5b 100644 --- a/src/modules/turf/turf.service.ts +++ b/src/modules/turf/turf.service.ts @@ -1,10 +1,12 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { Feature, LineString, Point } from 'geojson'; import { Turf, TURF_TOKEN } from './turf.provider'; import { Location } from '../map/map.types'; @Injectable() export class TurfService { + private readonly logger = new Logger(TurfService.name); + constructor(@Inject(TURF_TOKEN) private turf: Turf) {} // Helper method for create Point with location @@ -42,7 +44,7 @@ export class TurfService { const nearestPoint = this.turf.nearestPointOnLine(route, point); return this.turf.distance(point, nearestPoint, { units: 'meters' }); } catch (error) { - console.error('Error calculating distance to route:', error); + this.logger.error('Error calculating distance to route:', error); return this.getDistanceToRouteSimple(point, route); } } diff --git a/test/fixtures/index.ts b/test/fixtures/index.ts index 92979ba..305c3b9 100644 --- a/test/fixtures/index.ts +++ b/test/fixtures/index.ts @@ -8,3 +8,4 @@ export * from './user.fixture'; export * from './session.fixture'; export * from './location.fixture'; +export * from './match.fixture'; diff --git a/test/fixtures/match.fixture.ts b/test/fixtures/match.fixture.ts new file mode 100644 index 0000000..480f94b --- /dev/null +++ b/test/fixtures/match.fixture.ts @@ -0,0 +1,68 @@ +import { + MatchResult, + PackageWithLocations, + TripWithLocations, +} from '../../src/modules/package/matching.types'; +import { TripStatusEnum } from '../../generated/prisma'; +import { + DESTINATION_LOCATION, + ORIGIN_LOCATION, + createLocation, +} from './location.fixture'; + +/** Fresh TripWithLocations per call — never mutate the shared constant. */ +export const createTripWithLocations = ( + overrides: Partial = {}, +): TripWithLocations => ({ + id: 'trip-123', + status: TripStatusEnum.scheduled, + origin: createLocation( + ORIGIN_LOCATION.latitude, + ORIGIN_LOCATION.longitude, + ), + destination: createLocation( + DESTINATION_LOCATION.latitude, + DESTINATION_LOCATION.longitude, + ), + waypoints: [], + ...overrides, +}); + +export const mockTripWithLocations = createTripWithLocations(); + +/** Fresh MatchResult per call. */ +export const createMatchResult = ( + overrides: Partial = {}, +): MatchResult => ({ + tripId: 'trip-123', + isRequestSent: false, + score: 0, + originDistance: 500, + destinationDistance: 300, + isOnCorridor: true, + ...overrides, +}); + +export const mockMatchResult = createMatchResult(); + +/** Fresh PackageWithLocations per call (cast: shares shape with Prisma Package). */ +export const createPackageWithLocations = ( + overrides: Partial = {}, +): PackageWithLocations => + ({ + id: 'package-123', + weight: 2000, + originAddress: createLocation( + ORIGIN_LOCATION.latitude, + ORIGIN_LOCATION.longitude, + ), + recipient: { + address: createLocation( + ORIGIN_LOCATION.latitude, + ORIGIN_LOCATION.longitude, + ), + }, + ...overrides, + }) as unknown as PackageWithLocations; + +export const mockPackageWithLocations = createPackageWithLocations();