File

src/trips/optimizer/auto-assign.service.ts

Index

Properties

Properties

estimatedDistanceKm
estimatedDistanceKm: number
Type : number
orderId
orderId: string
Type : string
orderNumber
orderNumber: string
Type : string
reasoning
reasoning: string[]
Type : string[]
score
score: number
Type : number
vehicleId
vehicleId: string
Type : string
vehicleUnit
vehicleUnit: string
Type : string
warnings
warnings: string[]
Type : string[]
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;
  }
}

results matching ""

    No results matching ""