const AVERAGE_SPEED_KMPH = 50; const UNASSIGNED_DELIVERY_HARD_PENALTY = 1_000_000; export function buildPreview(plan) { const assigned = new Set(); const vehicles = []; const deliveries = (plan.deliveries || []).map((delivery) => ({ deliveryId: delivery.id, label: delivery.label, kind: delivery.kind, demand: delivery.demand, minStartTime: delivery.minStartTime, maxEndTime: delivery.maxEndTime, serviceDuration: delivery.serviceDuration, assignedVehicleId: null, assignedVehicleName: null, sequence: null, arrivalTime: null, serviceStartTime: null, departureTime: null, lateSeconds: null, })); let capacityOverage = 0; let lateSeconds = 0; let travelSeconds = 0; for (const vehicle of plan.vehicles || []) { const metrics = computeVehicleMetrics(plan, vehicle); capacityOverage += metrics.capacityOverage; lateSeconds += metrics.totalLateSeconds; travelSeconds += metrics.totalTravelSeconds; vehicles.push(metrics); for (const stop of metrics.stops) { assigned.add(stop.deliveryId); const delivery = deliveries[stop.deliveryId]; delivery.assignedVehicleId = vehicle.id; delivery.assignedVehicleName = vehicle.name; delivery.sequence = stop.sequence; delivery.arrivalTime = stop.arrivalTime; delivery.serviceStartTime = stop.serviceStartTime; delivery.departureTime = stop.departureTime; delivery.lateSeconds = stop.lateSeconds; } } const unassignedDeliveryIds = deliveries .filter((delivery) => !assigned.has(delivery.deliveryId)) .map((delivery) => delivery.deliveryId); return { hardScore: -( unassignedDeliveryIds.length * UNASSIGNED_DELIVERY_HARD_PENALTY + capacityOverage + lateSeconds ), softScore: -travelSeconds, unassignedDeliveryIds, vehicles, deliveries, }; } function computeVehicleMetrics(plan, vehicle) { const stops = []; let totalDemand = 0; let totalTravelSeconds = 0; let totalWaitSeconds = 0; let totalServiceSeconds = 0; let totalLateSeconds = 0; let endTime = vehicle.departureTime || 0; let currentTime = vehicle.departureTime || 0; let previous = { lat: vehicle.homeLat, lng: vehicle.homeLng }; for (const [sequence, deliveryId] of (vehicle.deliveryOrder || []).entries()) { const delivery = plan.deliveries[deliveryId]; if (!delivery) continue; totalDemand += Number(delivery.demand || 0); const travel = estimateTravel(previous, delivery); totalTravelSeconds += travel; const arrivalTime = currentTime + travel; const serviceStartTime = Math.max(arrivalTime, delivery.minStartTime || 0); const waitSeconds = Math.max(0, serviceStartTime - arrivalTime); const departureTime = serviceStartTime + Number(delivery.serviceDuration || 0); const lateSeconds = Math.max(0, departureTime - Number(delivery.maxEndTime || 0)); totalWaitSeconds += waitSeconds; totalServiceSeconds += Number(delivery.serviceDuration || 0); totalLateSeconds += lateSeconds; currentTime = departureTime; endTime = departureTime; previous = delivery; stops.push({ deliveryId, label: delivery.label, kind: delivery.kind, sequence, demand: delivery.demand, minStartTime: delivery.minStartTime, maxEndTime: delivery.maxEndTime, arrivalTime, serviceStartTime, departureTime, travelSecondsFromPrevious: travel, waitSeconds, lateSeconds, }); } if (stops.length) { const depot = { lat: vehicle.homeLat, lng: vehicle.homeLng }; const returnTravel = estimateTravel(previous, depot); totalTravelSeconds += returnTravel; endTime = currentTime + returnTravel; } return { vehicleId: vehicle.id, vehicleName: vehicle.name, totalDemand, capacityOverage: Math.max(0, totalDemand - Number(vehicle.capacity || 0)), stopCount: stops.length, totalTravelSeconds, totalWaitSeconds, totalServiceSeconds, totalLateSeconds, startTime: vehicle.departureTime || 0, endTime, stops, }; } function estimateTravel(from, to) { const meters = haversineMeters(Number(from.lat), Number(from.lng), Number(to.lat), Number(to.lng)); const metersPerSecond = (AVERAGE_SPEED_KMPH * 1000) / 3600; return Math.round(meters / metersPerSecond); } function haversineMeters(lat1, lng1, lat2, lng2) { const toRad = (value) => (value * Math.PI) / 180; const r = 6371000; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); return 2 * r * Math.asin(Math.sqrt(a)); }