From 1ee941c0df7162412fe6e594ad758fd9970dd3b8 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 5 Aug 2026 20:29:48 +0000 Subject: [PATCH] feat(mile): auto-estimate first/last-mile km from yard GPS to customer point --- .../src/common/mile-distance.util.spec.ts | 17 +++++++ .../src/common/mile-distance.util.ts | 48 +++++++++++++++++++ .../modules/first-mile/first-mile.service.ts | 3 +- .../modules/last-mile/last-mile.service.ts | 3 +- 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/common/mile-distance.util.spec.ts create mode 100644 apps/edr-freight-api/src/common/mile-distance.util.ts diff --git a/apps/edr-freight-api/src/common/mile-distance.util.spec.ts b/apps/edr-freight-api/src/common/mile-distance.util.spec.ts new file mode 100644 index 000000000..75801a20b --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-distance.util.spec.ts @@ -0,0 +1,17 @@ +import { haversineKm } from './mile-distance.util'; + +describe('haversineKm', () => { + it('is zero for the same point', () => { + expect(haversineKm(8.9, 38.6, 8.9, 38.6)).toBe(0); + }); + + it('matches one degree of longitude at the equator (~111.19 km)', () => { + expect(haversineKm(0, 0, 0, 1)).toBeCloseTo(111.19, 1); + }); + + it('Sebeta yard → Indode yard is roughly 26 km', () => { + const km = haversineKm(8.9096, 38.636, 8.7386, 38.7913); + expect(km).toBeGreaterThan(20); + expect(km).toBeLessThan(35); + }); +}); diff --git a/apps/edr-freight-api/src/common/mile-distance.util.ts b/apps/edr-freight-api/src/common/mile-distance.util.ts new file mode 100644 index 000000000..822a13350 --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-distance.util.ts @@ -0,0 +1,48 @@ +import { DataSource } from 'typeorm'; + +/** Great-circle distance in km between two WGS84 points (haversine). */ +export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + return 2 * 6371 * Math.asin(Math.sqrt(a)); +} + +/** + * Estimated road-leg distance for a booking's first/last mile: straight-line km + * from the yard (freight.yard_locations) to the customer's pickup/delivery GPS + * point on the booking. FIRST = origin yard → pickup point, LAST = destination + * yard → delivery point. Null when either end has no coordinates. + * + * ponytail: haversine straight-line, not road routing — plug a routing API in + * here if real road km is ever required. + */ +export async function estimateMileKm( + dataSource: DataSource, + bookingId: string, + mile: 'FIRST' | 'LAST', +): Promise { + const [row] = await dataSource.query( + mile === 'LAST' + ? `SELECT b.last_mile_delivery_lat AS lat, b.last_mile_delivery_lng AS lng, + l.latitude AS yard_lat, l.longitude AS yard_lng + FROM freight.bookings b + LEFT JOIN freight.yard_locations l + ON l.yard_id = b.destination_yard_id AND l.deleted_at IS NULL + WHERE b.id = $1 AND b.deleted_at IS NULL` + : `SELECT b.first_mile_pickup_lat AS lat, b.first_mile_pickup_lng AS lng, + l.latitude AS yard_lat, l.longitude AS yard_lng + FROM freight.bookings b + LEFT JOIN freight.yard_locations l + ON l.yard_id = b.origin_yard_id AND l.deleted_at IS NULL + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); + if (!row || row.lat == null || row.lng == null || row.yard_lat == null || row.yard_lng == null) { + return null; + } + const km = haversineKm(Number(row.yard_lat), Number(row.yard_lng), Number(row.lat), Number(row.lng)); + return Math.round(km * 100) / 100; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index b11912b2c..a35978020 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -3,6 +3,7 @@ import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { attachMileFinancials } from '../../common/mile-financials.util'; +import { estimateMileKm } from '../../common/mile-distance.util'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { BookingsRepository } from "../bookings/bookings.repository"; import { DriversService } from "../drivers/drivers.service"; @@ -285,7 +286,7 @@ export class FirstMileService { status: dto.status ?? "READY_TO_TRANSIT", advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, - estimatedKm: dto.estimatedKm ?? null, + estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'FIRST')), exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index af6c920fc..080c3bbe6 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -13,6 +13,7 @@ import { usesEdrMileService, } from '../../common/mile-haulage.util'; import { attachMileFinancials } from '../../common/mile-financials.util'; +import { estimateMileKm } from '../../common/mile-distance.util'; import { assertBulkTonnageRemains, assertTruckCountWithinContainers, @@ -426,7 +427,7 @@ export class LastMileService { status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, - estimatedKm: dto.estimatedKm ?? null, + estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')), exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false,