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 },
},
});
}
}
}
}