src/trips/optimizer/auto-assign.service.ts
Methods |
|
constructor(prisma: PrismaService)
|
||||||
|
Parameters :
|
| Async bulkAssign |
bulkAssign()
|
|
Bulk auto-assign: assigns ALL unassigned validated orders at once, creating trips for each assignment.
Returns :
Promise<literal type>
|
| Async generateSuggestions |
generateSuggestions()
|
|
Generate assignment suggestions for all unassigned orders. Scoring factors (weighted):
Returns :
Promise<AssignmentSuggestion[]>
|
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export interface AssignmentSuggestion {
orderId: string;
orderNumber: string;
vehicleId: string;
vehicleUnit: string;
score: number; // 0-100
reasoning: string[]; // Human-readable explanations
warnings: string[];
estimatedDistanceKm: number;
}
@Injectable()
export class AutoAssignService {
private readonly logger = new Logger(AutoAssignService.name);
constructor(private prisma: PrismaService) {}
/**
* Bulk auto-assign: assigns ALL unassigned validated orders at once,
* creating trips for each assignment.
*/
async bulkAssign(): Promise<{
assigned: { orderId: string; orderNumber: string; tripId: string; vehicleId: string }[];
failed: { orderId: string; orderNumber: string; reason: string }[];
}> {
const suggestions = await this.generateSuggestions();
const assigned: { orderId: string; orderNumber: string; tripId: string; vehicleId: string }[] = [];
const failed: { orderId: string; orderNumber: string; reason: string }[] = [];
// Track which vehicles are already assigned in this batch to avoid double-booking
const usedVehicleIds = new Set<string>();
for (const suggestion of suggestions) {
if (suggestion.score < 20) {
failed.push({
orderId: suggestion.orderId,
orderNumber: suggestion.orderNumber,
reason: `Score too low (${suggestion.score}): ${suggestion.warnings.join('; ')}`,
});
continue;
}
if (usedVehicleIds.has(suggestion.vehicleId)) {
failed.push({
orderId: suggestion.orderId,
orderNumber: suggestion.orderNumber,
reason: `Vehicle ${suggestion.vehicleUnit} already assigned in this batch`,
});
continue;
}
try {
// Generate trip number
const tripCount = await this.prisma.trip.count();
const tripNumber = `TRP-${String(tripCount + assigned.length + 1).padStart(6, '0')}`;
// Create trip and assign order in a transaction
const trip = await this.prisma.$transaction(async (tx) => {
const newTrip = await tx.trip.create({
data: {
organizationId: (await tx.order.findUnique({ where: { id: suggestion.orderId } }))!.organizationId,
tripNumber,
vehicleId: suggestion.vehicleId,
status: 'PLANNED',
totalDistance: suggestion.estimatedDistanceKm,
},
});
await tx.order.update({
where: { id: suggestion.orderId },
data: {
tripId: newTrip.id,
status: 'ASSIGNED',
jobStatus: 'ALLOCATED',
},
});
await tx.vehicle.update({
where: { id: suggestion.vehicleId },
data: { status: 'ASSIGNED' },
});
return newTrip;
});
usedVehicleIds.add(suggestion.vehicleId);
assigned.push({
orderId: suggestion.orderId,
orderNumber: suggestion.orderNumber,
tripId: trip.id,
vehicleId: suggestion.vehicleId,
});
} catch (err) {
this.logger.error(`Failed to assign order ${suggestion.orderNumber}: ${err}`);
failed.push({
orderId: suggestion.orderId,
orderNumber: suggestion.orderNumber,
reason: `Assignment error: ${(err as Error).message}`,
});
}
}
this.logger.log(`Bulk assign complete: ${assigned.length} assigned, ${failed.length} failed`);
return { assigned, failed };
}
/**
* Generate assignment suggestions for all unassigned orders.
*
* Scoring factors (weighted):
* - Geographic proximity to pickup (35%) -- haversine distance from vehicle's current position to order origin
* - Remaining capacity match (20%) -- how well the order weight fits the vehicle's remaining capacity
* - Vehicle type match (15%) -- bonus if vehicle type matches order's required type
* - Availability (15%) -- prefer AVAILABLE over ASSIGNED vehicles
* - Route balancing (15%) -- if last trip was long (>300km), bonus for short routes and vice versa
*/
async generateSuggestions(): Promise<AssignmentSuggestion[]> {
// Get unassigned orders (VALIDATED or DRAFT with addresses)
const orders = await this.prisma.order.findMany({
where: { status: { in: ['VALIDATED', 'DRAFT'] }, tripId: null },
include: { client: true },
});
// Get available vehicles with their current load + last completed trip for route balancing
const vehicles = await this.prisma.vehicle.findMany({
where: { status: { in: ['AVAILABLE', 'ASSIGNED'] } },
include: {
trips: {
where: { status: { in: ['PLANNED', 'DISPATCHED', 'IN_TRANSIT'] } },
include: { orders: true },
},
},
});
// Fetch last completed trip for each vehicle (for route balancing)
const vehicleLastTrips = new Map<string, number>();
for (const vehicle of vehicles) {
const lastCompleted = await this.prisma.trip.findFirst({
where: { vehicleId: vehicle.id, status: 'COMPLETED' },
orderBy: { endDate: 'desc' },
select: { totalDistance: true },
});
if (lastCompleted?.totalDistance) {
vehicleLastTrips.set(vehicle.id, lastCompleted.totalDistance);
}
}
if (orders.length === 0 || vehicles.length === 0) return [];
const suggestions: AssignmentSuggestion[] = [];
// Sort orders by priority (CRITICAL first)
const priorityWeight: Record<string, number> = {
CRITICAL: 4,
HIGH: 3,
NORMAL: 2,
LOW: 1,
};
orders.sort(
(a, b) =>
(priorityWeight[b.priority] || 0) - (priorityWeight[a.priority] || 0),
);
for (const order of orders) {
let bestScore = -1;
let bestVehicle: (typeof vehicles)[number] | null = null;
let bestReasons: string[] = [];
let bestWarnings: string[] = [];
for (const vehicle of vehicles) {
const lastTripDistance = vehicleLastTrips.get(vehicle.id);
const { score, reasons, warnings } = this.scoreVehicle(order, vehicle, lastTripDistance);
if (score > bestScore) {
bestScore = score;
bestVehicle = vehicle;
bestReasons = reasons;
bestWarnings = warnings;
}
}
if (bestVehicle && bestScore > 0) {
const distance = this.haversineDistance(
bestVehicle.currentLat || 0,
bestVehicle.currentLng || 0,
order.originLat || 0,
order.originLng || 0,
);
suggestions.push({
orderId: order.id,
orderNumber: order.orderNumber,
vehicleId: bestVehicle.id,
vehicleUnit: bestVehicle.unitNumber,
score: Math.round(bestScore),
reasoning: bestReasons,
warnings: bestWarnings,
estimatedDistanceKm: Math.round(distance),
});
}
}
return suggestions;
}
private scoreVehicle(
order: any,
vehicle: any,
lastTripDistance?: number,
): { score: number; reasons: string[]; warnings: string[] } {
let score = 0;
const reasons: string[] = [];
const warnings: string[] = [];
// 1. Proximity (35%)
let orderDistanceKm = 0;
if (
vehicle.currentLat &&
vehicle.currentLng &&
order.originLat &&
order.originLng
) {
const distKm = this.haversineDistance(
vehicle.currentLat,
vehicle.currentLng,
order.originLat,
order.originLng,
);
orderDistanceKm = distKm;
const proximityScore = Math.max(0, 35 - distKm / 25);
score += proximityScore;
reasons.push(`${Math.round(distKm)}km from pickup`);
} else {
score += 17; // Neutral if no GPS data
reasons.push('No GPS data -- neutral proximity score');
}
// Estimate the order's route distance (origin to dest)
let routeDistanceKm = 0;
if (order.originLat && order.originLng && order.destLat && order.destLng) {
routeDistanceKm = this.haversineDistance(
order.originLat,
order.originLng,
order.destLat,
order.destLng,
);
}
// 2. Capacity (20%)
const currentLoad = vehicle.trips.reduce((sum: number, trip: any) => {
return (
sum + trip.orders.reduce((s: number, o: any) => s + (o.weight || 0), 0)
);
}, 0);
const remainingCapacity = (vehicle.maxWeight || 44000) - currentLoad;
const orderWeight = order.weight || 0;
if (orderWeight <= remainingCapacity) {
const utilization = orderWeight / (vehicle.maxWeight || 44000);
score += 20 * Math.min(1, utilization * 2);
reasons.push(
`Capacity: ${Math.round(remainingCapacity)}kg available`,
);
} else {
warnings.push(
`Over capacity: needs ${orderWeight}kg, only ${Math.round(remainingCapacity)}kg available`,
);
}
// 3. Vehicle type match (15%)
if (order.vehicleType) {
if (vehicle.type === order.vehicleType) {
score += 15;
reasons.push(`Vehicle type matches: ${vehicle.type}`);
} else {
score += 4;
warnings.push(
`Type mismatch: needs ${order.vehicleType}, vehicle is ${vehicle.type}`,
);
}
} else {
score += 12;
reasons.push('No specific vehicle type required');
}
// 4. Availability (15%)
if (vehicle.status === 'AVAILABLE') {
score += 15;
reasons.push('Vehicle is available');
} else {
score += 5;
reasons.push('Vehicle is already assigned to other trips');
}
// 5. Route balancing (15%) -- prevents driver fatigue
// If last trip was long (>300km), give bonus for short routes, and vice versa.
if (lastTripDistance !== undefined && routeDistanceKm > 0) {
const LONG_THRESHOLD = 300;
const lastWasLong = lastTripDistance > LONG_THRESHOLD;
const currentIsShort = routeDistanceKm <= LONG_THRESHOLD;
if (lastWasLong && currentIsShort) {
// Driver did a long haul last -- give them a short route
score += 15;
reasons.push(`Route balance: last trip ${Math.round(lastTripDistance)}km (long), this ${Math.round(routeDistanceKm)}km (short) -- good balance`);
} else if (!lastWasLong && !currentIsShort) {
// Driver did a short route last -- they're rested, give them a long one
score += 12;
reasons.push(`Route balance: last trip ${Math.round(lastTripDistance)}km (short), this ${Math.round(routeDistanceKm)}km (long) -- acceptable`);
} else if (lastWasLong && !currentIsShort) {
// Back-to-back long hauls -- fatigue risk
score += 3;
warnings.push(`Route balance: back-to-back long hauls (last ${Math.round(lastTripDistance)}km, this ${Math.round(routeDistanceKm)}km) -- fatigue risk`);
} else {
// Short after short -- fine
score += 10;
reasons.push(`Route balance: consecutive short routes -- ok`);
}
} else {
score += 8; // Neutral if no history
reasons.push('No route history -- neutral balance score');
}
return { score, reasons, warnings };
}
private haversineDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const R = 6371;
const dLat = this.toRad(lat2 - lat1);
const dLon = this.toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(this.toRad(lat1)) *
Math.cos(this.toRad(lat2)) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
private toRad(deg: number): number {
return (deg * Math.PI) / 180;
}
}