File

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

Index

Properties

Properties

availableBonus
availableBonus: number
Type : number
batchPenalty
batchPenalty: number
Type : number
capacityOverPenalty
capacityOverPenalty: number
Type : number
enableRouteBalancing
enableRouteBalancing: boolean
Type : boolean
idleTimeMaxBonus
idleTimeMaxBonus: number
Type : number
loadBalanceFactor
loadBalanceFactor: number
Type : number
maxOrdersPerTruck
maxOrdersPerTruck: number
Type : number
onlyAvailableVehicles
onlyAvailableVehicles: boolean
Type : boolean
respectZonePermits
respectZonePermits: boolean
Type : boolean
routeBalanceBonus
routeBalanceBonus: number
Type : number
routeBalancePenalty
routeBalancePenalty: number
Type : number
zonePermitPenalty
zonePermitPenalty: number
Type : number
zonePermitWeight
zonePermitWeight: number
Type : number
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 ""