File

src/orders/ingestion/allocation/auto-allocator.ts

Description

Auto-allocation engine for truck assignment.

Rules:

  1. Only assign AVAILABLE trucks
  2. Truck must have zone permit for the delivery region
  3. Route balancing: if last trip was LONG, next should be SHORT (and vice versa)
  4. Prefer trucks with the fewest total trips (load balance)
  5. If truck plate is already specified in Excel, validate and keep it

Index

Properties

Properties

distributor
distributor: string
Type : string
orderId
orderId: string
Type : string
pieces
pieces: number | null
Type : number | null
plant
plant: string | null
Type : string | null
qtyBeer
qtyBeer: number | null
Type : number | null
qtyUdv
qtyUdv: number | null
Type : number | null
region
region: string | null
Type : string | null
truckPlateFromExcel
truckPlateFromExcel: string | null
Type : string | null
weightKg
weightKg: number | null
Type : number | null
import { Logger } from '@nestjs/common';
import { VehicleStatus } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';

/**
 * Auto-allocation engine for truck assignment.
 *
 * Rules:
 * 1. Only assign AVAILABLE trucks
 * 2. Truck must have zone permit for the delivery region
 * 3. Route balancing: if last trip was LONG, next should be SHORT (and vice versa)
 * 4. Prefer trucks with the fewest total trips (load balance)
 * 5. If truck plate is already specified in Excel, validate and keep it
 */

interface AllocationOrder {
  orderId: string;
  distributor: string;
  region: string | null;
  plant: string | null;
  truckPlateFromExcel: string | null;
  qtyBeer: number | null;
  qtyUdv: number | null;
  pieces: number | null;
  weightKg: number | null;
}

interface AllocationResult {
  orderId: string;
  vehicleId: string | null;
  vehicleUnitNumber: string | null;
  vehiclePlate: string | null;
  reason: string;
  autoAllocated: boolean;
}

// Route distance classification for distribution network
// NI1 = Nairobi plant, KBS2 = Kisumu plant
const ROUTE_DISTANCES: Record<string, Record<string, { km: number; type: 'SHORT' | 'MEDIUM' | 'LONG' }>> = {
  // From Nairobi (NI1)
  'NI1': {
    'COAST': { km: 480, type: 'LONG' },
    'NAIROBI': { km: 30, type: 'SHORT' },
    'MOUNTAIN': { km: 180, type: 'MEDIUM' },
    'LAKE (NCD)': { km: 350, type: 'LONG' },
    'LAKE (KSM)': { km: 350, type: 'LONG' },
    'LAKE': { km: 350, type: 'LONG' },
    'WESTERN': { km: 380, type: 'LONG' },
    'CENTRAL': { km: 100, type: 'SHORT' },
    'RIFT': { km: 160, type: 'MEDIUM' },
    'NYANZA': { km: 340, type: 'LONG' },
  },
  // From Kisumu (KBS2)
  'KBS2': {
    'COAST': { km: 800, type: 'LONG' },
    'NAIROBI': { km: 340, type: 'LONG' },
    'MOUNTAIN': { km: 450, type: 'LONG' },
    'LAKE (NCD)': { km: 50, type: 'SHORT' },
    'LAKE (KSM)': { km: 30, type: 'SHORT' },
    'LAKE': { km: 40, type: 'SHORT' },
    'WESTERN': { km: 120, type: 'MEDIUM' },
    'CENTRAL': { km: 300, type: 'LONG' },
    'RIFT': { km: 200, type: 'MEDIUM' },
    'NYANZA': { km: 80, type: 'SHORT' },
  },
};

// Opposite route type for balancing
const OPPOSITE_ROUTE: Record<string, string> = {
  'SHORT': 'LONG',
  'LONG': 'SHORT',
  'MEDIUM': 'MEDIUM',
};

export interface AllocationWeights {
  zonePermitWeight: number;
  zonePermitPenalty: number;
  routeBalanceBonus: number;
  routeBalancePenalty: number;
  capacityOverPenalty: number;
  loadBalanceFactor: number;
  batchPenalty: number;
  availableBonus: number;
  idleTimeMaxBonus: number;
  maxOrdersPerTruck: number;
  onlyAvailableVehicles: boolean;
  respectZonePermits: boolean;
  enableRouteBalancing: boolean;
}

const DEFAULT_WEIGHTS: AllocationWeights = {
  zonePermitWeight: 50,
  zonePermitPenalty: 30,
  routeBalanceBonus: 30,
  routeBalancePenalty: 20,
  capacityOverPenalty: 500,
  loadBalanceFactor: 0.5,
  batchPenalty: 10,
  availableBonus: 20,
  idleTimeMaxBonus: 20,
  maxOrdersPerTruck: 3,
  onlyAvailableVehicles: true,
  respectZonePermits: true,
  enableRouteBalancing: true,
};

export class AutoAllocator {
  private readonly logger = new Logger(AutoAllocator.name);

  constructor(private readonly prisma: PrismaService) {}

  /** Load active allocation rule from DB, fallback to defaults */
  async loadWeights(organizationId: string): Promise<AllocationWeights> {
    const rule = await this.prisma.allocationRule.findFirst({
      where: { organizationId, isActive: true },
      orderBy: { priority: 'desc' },
    });
    if (!rule) return DEFAULT_WEIGHTS;
    return {
      zonePermitWeight: rule.zonePermitWeight,
      zonePermitPenalty: rule.zonePermitPenalty,
      routeBalanceBonus: rule.routeBalanceBonus,
      routeBalancePenalty: rule.routeBalancePenalty,
      capacityOverPenalty: rule.capacityOverPenalty,
      loadBalanceFactor: rule.loadBalanceFactor,
      batchPenalty: rule.batchPenalty,
      availableBonus: rule.availableBonus,
      idleTimeMaxBonus: rule.idleTimeMaxBonus,
      maxOrdersPerTruck: rule.maxOrdersPerTruck,
      onlyAvailableVehicles: rule.onlyAvailableVehicles,
      respectZonePermits: rule.respectZonePermits,
      enableRouteBalancing: rule.enableRouteBalancing,
    };
  }

  /**
   * Auto-register trucks from Excel that don't exist in the system.
   * Parses truck plate strings like "KBU 304X ZC9866 PPD" into:
   *   - licensePlate: "KBU 304X" (the cab plate)
   *   - trailer: "ZC9866" (trailer number)
   *   - transporter code: "PPD" or "DHL" or "ACC"
   */
  async autoRegisterTrucks(
    organizationId: string,
    truckPlates: string[],
  ): Promise<{ registered: number; existing: number; details: { plate: string; vehicleId: string; isNew: boolean }[] }> {
    const uniquePlates = [...new Set(truckPlates.filter(Boolean))];
    this.logger.log(`Auto-registering ${uniquePlates.length} unique truck plates`);

    const details: { plate: string; vehicleId: string; isNew: boolean }[] = [];
    let registered = 0;
    let existing = 0;

    for (const rawPlate of uniquePlates) {
      // Parse: "KBU 304X ZC9866 PPD" → plate=KBU304X, trailer=ZC9866, transporter=PPD
      const parts = rawPlate.trim().split(/\s+/);
      let licensePlate = '';
      let trailer = '';
      let transporterCode = '';

      // Kenyan plates: 3 letters + 3 digits + 1 letter (e.g., KBU 304X or KBU304X)
      // First part is always the plate (may be split across first 2 tokens)
      if (parts.length >= 2 && /^K[A-Z]{2}$/i.test(parts[0]) && /^\d{3}[A-Z]$/i.test(parts[1])) {
        licensePlate = `${parts[0]} ${parts[1]}`;
        if (parts.length >= 3) trailer = parts[2];
        if (parts.length >= 4) transporterCode = parts[3];
      } else if (parts.length >= 1 && /^K[A-Z]{2}\d{3}[A-Z]$/i.test(parts[0])) {
        licensePlate = parts[0];
        if (parts.length >= 2) trailer = parts[1];
        if (parts.length >= 3) transporterCode = parts[2];
      } else {
        // Fallback — use the whole string
        licensePlate = rawPlate.trim();
      }

      const unitNumber = licensePlate.replace(/\s+/g, '').toUpperCase();

      // Check if already exists
      const existingVehicle = await this.prisma.vehicle.findFirst({
        where: {
          organizationId,
          OR: [
            { unitNumber },
            { licensePlate: { contains: unitNumber.substring(0, 6), mode: 'insensitive' } },
          ],
        },
      });

      if (existingVehicle) {
        details.push({ plate: rawPlate, vehicleId: existingVehicle.id, isNew: false });
        existing++;
        continue;
      }

      // Find or create transporter
      let transporterId: string | undefined;
      if (transporterCode) {
        const transporter = await this.prisma.transporter.findFirst({
          where: {
            organizationId,
            OR: [
              { code: transporterCode.toUpperCase() },
              { name: { contains: transporterCode, mode: 'insensitive' } },
            ],
          },
        });
        if (transporter) {
          transporterId = transporter.id;
        } else {
          // Auto-create transporter
          const newTransporter = await this.prisma.transporter.create({
            data: {
              organizationId,
              name: transporterCode.toUpperCase(),
              code: transporterCode.toUpperCase(),
              isActive: true,
            },
          });
          transporterId = newTransporter.id;
          this.logger.log(`Auto-created transporter: ${transporterCode}`);
        }
      }

      // Register the vehicle — standard 30-tonne dry vans
      const vehicle = await this.prisma.vehicle.create({
        data: {
          organizationId,
          unitNumber,
          licensePlate,
          type: 'DRY_VAN',
          status: 'AVAILABLE',
          maxWeight: 30000, // 30 tonnes — standard capacity
          transporterId,
          homeSite: trailer || undefined,
        },
      });

      details.push({ plate: rawPlate, vehicleId: vehicle.id, isNew: true });
      registered++;
    }

    this.logger.log(`Truck registration: ${registered} new, ${existing} existing`);
    return { registered, existing, details };
  }

  /**
   * Auto-allocate trucks to a batch of orders.
   * Called after Excel extraction + order creation.
   */
  async allocateOrders(
    organizationId: string,
    orders: AllocationOrder[],
  ): Promise<{
    results: AllocationResult[];
    summary: {
      total: number;
      autoAllocated: number;
      excelPreAssigned: number;
      unallocated: number;
      reasons: Record<string, number>;
    };
  }> {
    this.logger.log(`Auto-allocating ${orders.length} orders for org ${organizationId}`);

    // 0. Load allocation weights from DB rules
    const w = await this.loadWeights(organizationId);

    // 1. Load vehicles — always include AVAILABLE + ASSIGNED.
    // A vehicle is ASSIGNED when it has a PLANNED trip (not yet dispatched),
    // meaning it is physically still available for planning.
    // Exclude only vehicles with genuinely active trips (DISPATCHED/IN_TRANSIT/AT_STOP).
    const allVehicles = await this.prisma.vehicle.findMany({
      where: {
        organizationId,
        status: { in: [VehicleStatus.AVAILABLE, VehicleStatus.ASSIGNED] },
      },
      include: {
        zonePermits: {
          where: { isActive: true },
          include: { zone: true },
        },
        trips: {
          orderBy: { createdAt: 'desc' },
          take: 5,
          select: {
            id: true,
            totalDistance: true,
            createdAt: true,
            status: true,
          },
        },
      },
    });

    // Filter out vehicles that have any genuinely active (dispatched/in-transit) trip
    const activeStatuses = new Set(['DISPATCHED', 'IN_TRANSIT', 'AT_STOP']);
    const vehicles = allVehicles.filter(v => {
      const hasActiveTrip = v.trips.some(t => activeStatuses.has(t.status));
      if (hasActiveTrip && w.onlyAvailableVehicles) return false; // strict mode: skip if any active trip
      return !hasActiveTrip; // always skip vehicles actually out on the road
    });

    this.logger.log(`Found ${vehicles.length} plannable vehicles (${allVehicles.length} total AVAILABLE/ASSIGNED)`);

    // 2. Load all zones for region matching
    const zones = await this.prisma.zone.findMany({
      where: { organizationId, isActive: true },
    });

    // Build region → zoneId lookup (fuzzy match zone names/codes to our regions)
    const regionToZone = new Map<string, string>();
    for (const zone of zones) {
      const name = zone.name.toUpperCase();
      const code = zone.code.toUpperCase();
      // Match zone to region
      if (name.includes('COAST') || code === 'COAST') regionToZone.set('COAST', zone.id);
      if (name.includes('NAIROBI') || code === 'NRB' || code === 'NAIROBI') regionToZone.set('NAIROBI', zone.id);
      if (name.includes('MOUNTAIN') || code === 'MTN') regionToZone.set('MOUNTAIN', zone.id);
      if (name.includes('LAKE') && name.includes('NCD')) regionToZone.set('LAKE (NCD)', zone.id);
      if (name.includes('LAKE') && name.includes('KSM')) regionToZone.set('LAKE (KSM)', zone.id);
      if (name.includes('LAKE') && !name.includes('NCD') && !name.includes('KSM')) regionToZone.set('LAKE', zone.id);
      if (name.includes('WESTERN') || code === 'WST') regionToZone.set('WESTERN', zone.id);
      if (name.includes('CENTRAL') || code === 'CTR') regionToZone.set('CENTRAL', zone.id);
      if (name.includes('RIFT') || code === 'RIFT') regionToZone.set('RIFT', zone.id);
      if (name.includes('NYANZA') || code === 'NYZ') regionToZone.set('NYANZA', zone.id);
    }

    // 3. Build vehicle plate lookup for pre-assigned trucks from Excel
    const plateToVehicle = new Map<string, typeof vehicles[0]>();
    for (const v of vehicles) {
      if (v.licensePlate) {
        // Normalize plate: remove spaces, uppercase
        const normalized = v.licensePlate.replace(/\s+/g, '').toUpperCase();
        plateToVehicle.set(normalized, v);
      }
      // Also match by unitNumber
      plateToVehicle.set(v.unitNumber.replace(/\s+/g, '').toUpperCase(), v);
    }

    // 4. Track which vehicles are already allocated in this batch
    const vehicleAllocations = new Map<string, number>(); // vehicleId → count

    const results: AllocationResult[] = [];
    const reasons: Record<string, number> = {};
    let autoAllocated = 0;
    let excelPreAssigned = 0;
    let unallocated = 0;

    const addReason = (reason: string) => {
      reasons[reason] = (reasons[reason] || 0) + 1;
    };

    // 5. Process each order — ALL orders go through the algorithm
    // Excel truck plates are saved as reference but the system makes the allocation decision
    // based on: zone permits, route balancing, weight/capacity matching, and load balancing
    for (const order of orders) {
      // Auto-allocate — find the best available truck using scoring
      const routeInfo = this.getRouteInfo(order.plant, order.region);
      const targetZoneId = order.region ? regionToZone.get(order.region) : null;

      // Score each vehicle
      let bestVehicle: typeof vehicles[0] | null = null;
      let bestScore = -Infinity;

      for (const vehicle of vehicles) {
        // Skip if vehicle is already heavily allocated in this batch
        const currentAllocCount = vehicleAllocations.get(vehicle.id) || 0;
        if (currentAllocCount >= w.maxOrdersPerTruck) continue;

        let score = 0;

        // Zone permit check
        if (w.respectZonePermits && targetZoneId && (vehicle as any).zonePermits?.length > 0) {
          const hasPermit = (vehicle as any).zonePermits?.some((p: any) => p.zoneId === targetZoneId);
          if (hasPermit) {
            score += w.zonePermitWeight;
          } else {
            score -= w.zonePermitPenalty;
          }
        }

        // Route balancing
        if (w.enableRouteBalancing && routeInfo && vehicle.lastRouteType) {
          const preferred = OPPOSITE_ROUTE[vehicle.lastRouteType];
          if (routeInfo.type === preferred) {
            score += w.routeBalanceBonus;
          } else if (routeInfo.type === vehicle.lastRouteType) {
            score -= w.routeBalancePenalty;
          }
        }

        // Weight-based capacity check
        const orderWeight = order.weightKg || 0;
        const truckCapacity = vehicle.maxWeight || 30000;
        if (orderWeight > 0 && orderWeight > truckCapacity) {
          score -= w.capacityOverPenalty;
        }

        // Load balance — prefer vehicles with fewer trips
        score -= vehicle.totalTripsCompleted * w.loadBalanceFactor;

        // Batch allocation penalty
        score -= currentAllocCount * w.batchPenalty;

        // Prefer AVAILABLE over ASSIGNED
        if (vehicle.status === 'AVAILABLE') score += w.availableBonus;

        // Prefer vehicles that have been idle longer
        if (vehicle.lastTripCompletedAt) {
          const hoursSinceLastTrip =
            (Date.now() - vehicle.lastTripCompletedAt.getTime()) / (1000 * 60 * 60);
          score += Math.min(hoursSinceLastTrip * 0.5, w.idleTimeMaxBonus);
        } else {
          score += w.idleTimeMaxBonus / 2;
        }

        if (score > bestScore) {
          bestScore = score;
          bestVehicle = vehicle;
        }
      }

      if (bestVehicle) {
        results.push({
          orderId: order.orderId,
          vehicleId: bestVehicle.id,
          vehicleUnitNumber: bestVehicle.unitNumber,
          vehiclePlate: bestVehicle.licensePlate,
          reason: `Auto-allocated (score: ${bestScore.toFixed(0)}, route: ${routeInfo?.type || 'UNKNOWN'})`,
          autoAllocated: true,
        });
        vehicleAllocations.set(bestVehicle.id, (vehicleAllocations.get(bestVehicle.id) || 0) + 1);
        autoAllocated++;
        addReason('Auto-allocated');
      } else {
        const reason = !vehicles.length
          ? 'No vehicles registered in system'
          : targetZoneId && !vehicles.some((v: any) => v.zonePermits?.some((p: any) => p.zoneId === targetZoneId))
            ? `No vehicles with zone permit for ${order.region}`
            : 'No suitable vehicle available — all trucks busy or allocated';

        results.push({
          orderId: order.orderId,
          vehicleId: null,
          vehicleUnitNumber: null,
          vehiclePlate: null,
          reason,
          autoAllocated: false,
        });
        unallocated++;
        addReason(reason);
      }
    }

    this.logger.log(
      `Allocation complete: ${autoAllocated} auto, ${excelPreAssigned} from Excel, ${unallocated} unallocated`,
    );

    return {
      results,
      summary: {
        total: orders.length,
        autoAllocated,
        excelPreAssigned,
        unallocated,
        reasons,
      },
    };
  }

  private findVehicleByPlate(
    normalizedPlate: string,
    plateMap: Map<string, any>,
  ): any | null {
    // Direct match
    if (plateMap.has(normalizedPlate)) return plateMap.get(normalizedPlate);

    // Fuzzy match — try removing common suffixes like DHL, PPD, ACC
    const cleaned = normalizedPlate.replace(/(DHL|PPD|ACC|HIRED)$/i, '').trim();
    if (cleaned && plateMap.has(cleaned)) return plateMap.get(cleaned);

    // Try matching just the plate number part (e.g., KBU304X from "KBU 304X ZC9866 PPD")
    const plateParts = normalizedPlate.match(/^(K[A-Z]{2}\d{3}[A-Z])/);
    if (plateParts) {
      for (const [key, vehicle] of plateMap.entries()) {
        if (key.startsWith(plateParts[1])) return vehicle;
      }
    }

    return null;
  }

  private getRouteInfo(
    plant: string | null,
    region: string | null,
  ): { km: number; type: 'SHORT' | 'MEDIUM' | 'LONG' } | null {
    if (!plant || !region) return null;
    const plantUpper = plant.toUpperCase();
    const routes = ROUTE_DISTANCES[plantUpper];
    if (!routes) return null;
    return routes[region] || null;
  }

  /**
   * After allocation, update vehicle route tracking data.
   */
  async updateVehicleRouteData(
    allocations: AllocationResult[],
    plant: string | null,
    regions: Map<string, string | null>,
  ): Promise<void> {
    for (const alloc of allocations) {
      if (!alloc.vehicleId || !alloc.autoAllocated) continue;

      const region = regions.get(alloc.orderId);
      const routeInfo = this.getRouteInfo(plant, region || null);

      if (routeInfo) {
        await this.prisma.vehicle.update({
          where: { id: alloc.vehicleId },
          data: {
            lastRouteDistanceKm: routeInfo.km,
            lastRouteType: routeInfo.type,
            lastTripCompletedAt: new Date(),
            totalTripsCompleted: { increment: 1 },
          },
        });
      }
    }
  }
}

results matching ""

    No results matching ""