| const { PrismaClient } = require('@prisma/client'); |
| const prisma = new PrismaClient(); |
|
|
| |
| function haversine(lat1, lng1, lat2, lng2) { |
| const R = 6371; |
| const dLat = ((lat2 - lat1) * Math.PI) / 180; |
| const dLng = ((lng2 - lng1) * Math.PI) / 180; |
| const a = |
| Math.sin(dLat / 2) ** 2 + |
| Math.cos((lat1 * Math.PI) / 180) * |
| Math.cos((lat2 * Math.PI) / 180) * |
| Math.sin(dLng / 2) ** 2; |
| return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); |
| } |
|
|
| |
| function clusterShipments(shipments, maxGroupRadiusKm = 30) { |
| const clusters = []; |
| const assigned = new Set(); |
|
|
| for (let i = 0; i < shipments.length; i++) { |
| if (assigned.has(i)) continue; |
| const seed = shipments[i]; |
| const cluster = [seed]; |
| assigned.add(i); |
|
|
| for (let j = i + 1; j < shipments.length; j++) { |
| if (assigned.has(j)) continue; |
| const s = shipments[j]; |
| const pickupDist = haversine(seed.pickupLat, seed.pickupLng, s.pickupLat, s.pickupLng); |
| const dropDist = haversine(seed.dropLat, seed.dropLng, s.dropLat, s.dropLng); |
| if (pickupDist <= maxGroupRadiusKm && dropDist <= maxGroupRadiusKm) { |
| cluster.push(s); |
| assigned.add(j); |
| } |
| } |
| clusters.push(cluster); |
| } |
| return clusters; |
| } |
|
|
| |
| function filterByTimeWindow(cluster, toleranceMinutes = 120) { |
| if (!cluster[0].timeWindowStart) return cluster; |
| const toleranceMs = toleranceMinutes * 60 * 1000; |
| const groups = []; |
| const used = new Set(); |
|
|
| for (let i = 0; i < cluster.length; i++) { |
| if (used.has(i)) continue; |
| const group = [cluster[i]]; |
| used.add(i); |
| const refStart = new Date(cluster[i].timeWindowStart).getTime(); |
| const refEnd = cluster[i].timeWindowEnd |
| ? new Date(cluster[i].timeWindowEnd).getTime() |
| : refStart + 4 * 3600000; |
|
|
| for (let j = i + 1; j < cluster.length; j++) { |
| if (used.has(j)) continue; |
| const s = cluster[j]; |
| const sStart = new Date(s.timeWindowStart || 0).getTime(); |
| const sEnd = s.timeWindowEnd ? new Date(s.timeWindowEnd).getTime() : sStart + 4 * 3600000; |
| if (sStart <= refEnd + toleranceMs && sEnd >= refStart - toleranceMs) { |
| group.push(s); |
| used.add(j); |
| } |
| } |
| groups.push(group); |
| } |
| return groups; |
| } |
|
|
| |
| function binPack(shipmentGroup, trucks) { |
| const sorted = [...shipmentGroup].sort( |
| (a, b) => (b.weight || 0) - (a.weight || 0) |
| ); |
| const bins = trucks.map((t) => ({ |
| truck: t, |
| shipments: [], |
| usedWeight: 0, |
| usedVolume: 0, |
| })); |
|
|
| for (const s of sorted) { |
| const w = s.weight || 0; |
| const v = s.volume || 0; |
| let placed = false; |
| for (const bin of bins) { |
| if ( |
| bin.usedWeight + w <= bin.truck.maxWeight && |
| bin.usedVolume + v <= bin.truck.maxVolume |
| ) { |
| bin.shipments.push(s); |
| bin.usedWeight += w; |
| bin.usedVolume += v; |
| placed = true; |
| break; |
| } |
| } |
| if (!placed && bins.length > 0) { |
| const last = bins[bins.length - 1]; |
| last.shipments.push(s); |
| last.usedWeight += w; |
| last.usedVolume += v; |
| } |
| } |
| return bins.filter((b) => b.shipments.length > 0); |
| } |
|
|
| |
| function groupRouteDistance(shipments) { |
| if (shipments.length === 0) return 0; |
| let dist = 0; |
| for (const s of shipments) { |
| dist += haversine(s.pickupLat, s.pickupLng, s.dropLat, s.dropLng); |
| } |
| return dist; |
| } |
|
|
| function naiveDistance(shipments) { |
| return shipments.reduce( |
| (sum, s) => sum + haversine(s.pickupLat, s.pickupLng, s.dropLat, s.dropLng), |
| 0 |
| ); |
| } |
|
|
| |
| function runConsolidation(shipments, trucks, options = {}) { |
| const maxGroupRadius = options.maxGroupRadiusKm || 30; |
| const timeWindowTolerance = options.timeWindowToleranceMinutes || 120; |
|
|
| const naiveTotalDist = naiveDistance(shipments); |
| const naiveTrips = shipments.length; |
| const naiveUtilization = |
| trucks.length > 0 |
| ? shipments.reduce((s, sh) => s + (sh.weight || 0), 0) / |
| (trucks.reduce((s, t) => s + t.maxWeight, 0) / trucks.length) / |
| naiveTrips * |
| 100 |
| : 0; |
|
|
| const geoClusters = clusterShipments(shipments, maxGroupRadius); |
|
|
| const allGroups = []; |
| for (const cluster of geoClusters) { |
| const timeGroups = filterByTimeWindow(cluster, timeWindowTolerance); |
| for (const tg of timeGroups) { |
| allGroups.push(tg); |
| } |
| } |
|
|
| const packedBins = []; |
| let truckPool = [...trucks]; |
| for (const group of allGroups) { |
| if (truckPool.length === 0) truckPool = [...trucks]; |
| const bins = binPack(group, truckPool); |
| packedBins.push(...bins); |
| const usedIds = new Set(bins.map((b) => b.truck.id)); |
| truckPool = truckPool.filter((t) => !usedIds.has(t.id)); |
| } |
|
|
| let consolidatedDist = 0; |
| for (const bin of packedBins) { |
| consolidatedDist += groupRouteDistance(bin.shipments); |
| } |
|
|
| const totalWeight = shipments.reduce((s, sh) => s + (sh.weight || 0), 0); |
| const avgTruckCap = |
| trucks.length > 0 |
| ? trucks.reduce((s, t) => s + t.maxWeight, 0) / trucks.length |
| : 1; |
| const consolidatedTrips = packedBins.length; |
| const consolidatedUtil = |
| consolidatedTrips > 0 |
| ? (totalWeight / (consolidatedTrips * avgTruckCap)) * 100 |
| : 0; |
|
|
| const distSaved = Math.max(0, naiveTotalDist - consolidatedDist); |
| const co2Factor = 0.21; |
| const carbonSaved = distSaved * co2Factor; |
| const tripsReduced = Math.max(0, naiveTrips - consolidatedTrips); |
| const costSavedPercent = |
| naiveTrips > 0 ? (tripsReduced / naiveTrips) * 100 : 0; |
|
|
| const groups = packedBins.map((bin, idx) => ({ |
| groupId: idx + 1, |
| truckId: bin.truck.id, |
| truckName: bin.truck.name || bin.truck.licensePlate || `Truck-${idx + 1}`, |
| truckCapacity: { maxWeight: bin.truck.maxWeight, maxVolume: bin.truck.maxVolume }, |
| shipmentCount: bin.shipments.length, |
| shipments: bin.shipments.map((s) => ({ |
| id: s.id, |
| pickupLocation: s.pickupLocation, |
| dropLocation: s.dropLocation, |
| weight: s.weight, |
| volume: s.volume, |
| })), |
| totalWeight: bin.usedWeight, |
| totalVolume: bin.usedVolume, |
| utilizationWeight: (bin.usedWeight / bin.truck.maxWeight) * 100, |
| utilizationVolume: (bin.usedVolume / bin.truck.maxVolume) * 100, |
| routeDistanceKm: parseFloat(groupRouteDistance(bin.shipments).toFixed(1)), |
| })); |
|
|
| return { |
| groups, |
| metrics: { |
| totalShipments: shipments.length, |
| totalGroups: groups.length, |
| totalTrucks: trucks.length, |
| utilizationBefore: parseFloat(Math.min(naiveUtilization, 100).toFixed(1)), |
| utilizationAfter: parseFloat(Math.min(consolidatedUtil, 100).toFixed(1)), |
| utilizationImprovement: parseFloat( |
| Math.min(consolidatedUtil - naiveUtilization, 100).toFixed(1) |
| ), |
| tripsReduced, |
| tripReductionPercent: parseFloat( |
| (naiveTrips > 0 ? (tripsReduced / naiveTrips) * 100 : 0).toFixed(1) |
| ), |
| distanceSavedKm: parseFloat(distSaved.toFixed(1)), |
| carbonSavedKg: parseFloat(carbonSaved.toFixed(1)), |
| costSavedPercent: parseFloat(costSavedPercent.toFixed(1)), |
| naiveTotalDistanceKm: parseFloat(naiveTotalDist.toFixed(1)), |
| consolidatedDistanceKm: parseFloat(consolidatedDist.toFixed(1)), |
| }, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| exports.optimize = async (req, res) => { |
| try { |
| const { shipments = [], trucks = [], options = {} } = req.body; |
|
|
| if (!shipments.length) { |
| return res.status(400).json({ success: false, message: 'shipments array is required' }); |
| } |
| if (!trucks.length) { |
| return res.status(400).json({ success: false, message: 'trucks array is required' }); |
| } |
|
|
| const result = runConsolidation(shipments, trucks, options); |
|
|
| try { |
| await prisma.consolidationRun.create({ |
| data: { |
| totalShipments: result.metrics.totalShipments, |
| totalGroups: result.metrics.totalGroups, |
| totalTrucks: result.metrics.totalTrucks, |
| utilizationBefore: result.metrics.utilizationBefore, |
| utilizationAfter: result.metrics.utilizationAfter, |
| tripsReduced: result.metrics.tripsReduced, |
| distanceSavedKm: result.metrics.distanceSavedKm, |
| carbonSavedKg: result.metrics.carbonSavedKg, |
| costSavedPercent: result.metrics.costSavedPercent, |
| scenarioName: options.scenarioName || null, |
| inputPayload: { shipmentCount: shipments.length, truckCount: trucks.length, options }, |
| resultPayload: result, |
| }, |
| }); |
| } catch (dbErr) { |
| console.warn('Could not persist consolidation run:', dbErr.message); |
| } |
|
|
| return res.json({ success: true, data: result }); |
| } catch (err) { |
| console.error('Consolidation optimize error:', err); |
| return res.status(500).json({ success: false, message: err.message }); |
| } |
| }; |
|
|
| |
| |
| |
| exports.simulate = async (req, res) => { |
| try { |
| const { shipments = [], trucks = [], scenarios = [] } = req.body; |
|
|
| if (!shipments.length || !trucks.length) { |
| return res.status(400).json({ |
| success: false, |
| message: 'shipments and trucks arrays are required', |
| }); |
| } |
|
|
| if (!scenarios.length) { |
| return res.status(400).json({ |
| success: false, |
| message: 'scenarios array is required (at least one scenario)', |
| }); |
| } |
|
|
| const results = scenarios.map((scenario) => ({ |
| name: scenario.name || 'Unnamed', |
| ...runConsolidation(shipments, trucks, { |
| maxGroupRadiusKm: scenario.maxGroupRadiusKm, |
| timeWindowToleranceMinutes: scenario.timeWindowToleranceMinutes, |
| scenarioName: scenario.name, |
| }), |
| })); |
|
|
| const best = results.reduce((a, b) => |
| b.metrics.utilizationAfter > a.metrics.utilizationAfter ? b : a |
| ); |
|
|
| return res.json({ |
| success: true, |
| data: { scenarios: results, recommendation: best.name }, |
| }); |
| } catch (err) { |
| console.error('Consolidation simulate error:', err); |
| return res.status(500).json({ success: false, message: err.message }); |
| } |
| }; |
|
|
| |
| |
| |
| exports.history = async (req, res) => { |
| try { |
| const limit = Math.min(parseInt(req.query.limit) || 20, 50); |
| const runs = await prisma.consolidationRun.findMany({ |
| orderBy: { createdAt: 'desc' }, |
| take: limit, |
| }); |
| return res.json({ success: true, data: runs }); |
| } catch (err) { |
| console.warn('Could not fetch consolidation history:', err.message); |
| return res.json({ success: true, data: [] }); |
| } |
| }; |
|
|
| |
| |
| |
| |
| exports.demo = async (req, res) => { |
| const demoShipments = [ |
| { id: 'SH-001', pickupLat: 19.076, pickupLng: 72.877, dropLat: 18.520, dropLng: 73.856, pickupLocation: 'Mumbai Port', dropLocation: 'Pune Warehouse', weight: 800, volume: 3.2, timeWindowStart: '2026-02-20T06:00:00Z', timeWindowEnd: '2026-02-20T12:00:00Z' }, |
| { id: 'SH-002', pickupLat: 19.098, pickupLng: 72.890, dropLat: 18.532, dropLng: 73.870, pickupLocation: 'Mumbai JNPT', dropLocation: 'Pune Industrial', weight: 600, volume: 2.5, timeWindowStart: '2026-02-20T07:00:00Z', timeWindowEnd: '2026-02-20T13:00:00Z' }, |
| { id: 'SH-003', pickupLat: 19.060, pickupLng: 72.868, dropLat: 18.540, dropLng: 73.880, pickupLocation: 'Mumbai Dock', dropLocation: 'Pune Hub', weight: 450, volume: 1.8, timeWindowStart: '2026-02-20T06:30:00Z', timeWindowEnd: '2026-02-20T11:00:00Z' }, |
| { id: 'SH-004', pickupLat: 12.971, pickupLng: 77.594, dropLat: 13.083, dropLng: 80.270, pickupLocation: 'Bangalore Warehouse', dropLocation: 'Chennai Central', weight: 1200, volume: 5.0, timeWindowStart: '2026-02-20T08:00:00Z', timeWindowEnd: '2026-02-20T18:00:00Z' }, |
| { id: 'SH-005', pickupLat: 12.985, pickupLng: 77.610, dropLat: 13.060, dropLng: 80.250, pickupLocation: 'Bangalore Tech Park', dropLocation: 'Chennai Port', weight: 900, volume: 3.8, timeWindowStart: '2026-02-20T09:00:00Z', timeWindowEnd: '2026-02-20T19:00:00Z' }, |
| { id: 'SH-006', pickupLat: 12.960, pickupLng: 77.580, dropLat: 13.070, dropLng: 80.260, pickupLocation: 'Bangalore South', dropLocation: 'Chennai North', weight: 700, volume: 2.9, timeWindowStart: '2026-02-20T08:30:00Z', timeWindowEnd: '2026-02-20T17:00:00Z' }, |
| { id: 'SH-007', pickupLat: 28.704, pickupLng: 77.102, dropLat: 26.912, dropLng: 75.787, pickupLocation: 'Delhi NCR Hub', dropLocation: 'Jaipur Depot', weight: 1500, volume: 6.0, timeWindowStart: '2026-02-20T05:00:00Z', timeWindowEnd: '2026-02-20T14:00:00Z' }, |
| { id: 'SH-008', pickupLat: 28.720, pickupLng: 77.120, dropLat: 26.930, dropLng: 75.800, pickupLocation: 'Delhi Warehouse', dropLocation: 'Jaipur Industrial', weight: 1100, volume: 4.5, timeWindowStart: '2026-02-20T05:30:00Z', timeWindowEnd: '2026-02-20T13:00:00Z' }, |
| { id: 'SH-009', pickupLat: 28.690, pickupLng: 77.090, dropLat: 26.900, dropLng: 75.770, pickupLocation: 'Delhi Logistics Park', dropLocation: 'Jaipur Hub', weight: 500, volume: 2.0, timeWindowStart: '2026-02-20T06:00:00Z', timeWindowEnd: '2026-02-20T15:00:00Z' }, |
| { id: 'SH-010', pickupLat: 19.090, pickupLng: 72.870, dropLat: 21.170, dropLng: 72.831, pickupLocation: 'Mumbai Central', dropLocation: 'Surat Hub', weight: 950, volume: 4.0, timeWindowStart: '2026-02-20T07:00:00Z', timeWindowEnd: '2026-02-20T16:00:00Z' }, |
| { id: 'SH-011', pickupLat: 19.070, pickupLng: 72.860, dropLat: 21.180, dropLng: 72.840, pickupLocation: 'Mumbai West', dropLocation: 'Surat Depot', weight: 650, volume: 2.7, timeWindowStart: '2026-02-20T07:30:00Z', timeWindowEnd: '2026-02-20T16:00:00Z' }, |
| { id: 'SH-012', pickupLat: 17.385, pickupLng: 78.486, dropLat: 15.828, dropLng: 78.037, pickupLocation: 'Hyderabad Hub', dropLocation: 'Kurnool Depot', weight: 1800, volume: 7.2, timeWindowStart: '2026-02-20T06:00:00Z', timeWindowEnd: '2026-02-20T14:00:00Z' }, |
| { id: 'SH-013', pickupLat: 17.400, pickupLng: 78.500, dropLat: 15.840, dropLng: 78.050, pickupLocation: 'Hyderabad HITEC', dropLocation: 'Kurnool Industrial', weight: 400, volume: 1.6, timeWindowStart: '2026-02-20T06:30:00Z', timeWindowEnd: '2026-02-20T13:00:00Z' }, |
| { id: 'SH-014', pickupLat: 28.680, pickupLng: 77.080, dropLat: 28.460, dropLng: 77.026, pickupLocation: 'Delhi South', dropLocation: 'Gurgaon Hub', weight: 300, volume: 1.2, timeWindowStart: '2026-02-20T10:00:00Z', timeWindowEnd: '2026-02-20T14:00:00Z' }, |
| { id: 'SH-015', pickupLat: 28.700, pickupLng: 77.100, dropLat: 28.470, dropLng: 77.030, pickupLocation: 'Delhi Central', dropLocation: 'Gurgaon Cyber City', weight: 350, volume: 1.4, timeWindowStart: '2026-02-20T10:30:00Z', timeWindowEnd: '2026-02-20T15:00:00Z' }, |
| ]; |
|
|
| const demoTrucks = [ |
| { id: 'TRK-001', name: 'Tata Ace Gold', maxWeight: 2000, maxVolume: 8.0, licensePlate: 'MH-12-AB-1234', co2PerKm: 0.21 }, |
| { id: 'TRK-002', name: 'Ashok Leyland Dost', maxWeight: 2500, maxVolume: 10.0, licensePlate: 'KA-05-CD-5678', co2PerKm: 0.23 }, |
| { id: 'TRK-003', name: 'Mahindra Bolero Pickup', maxWeight: 1500, maxVolume: 6.0, licensePlate: 'DL-01-EF-9012', co2PerKm: 0.19 }, |
| { id: 'TRK-004', name: 'Eicher Pro 2049', maxWeight: 5000, maxVolume: 20.0, licensePlate: 'GJ-06-GH-3456', co2PerKm: 0.25 }, |
| { id: 'TRK-005', name: 'BharatBenz 1015R', maxWeight: 3000, maxVolume: 12.0, licensePlate: 'TN-09-IJ-7890', co2PerKm: 0.22 }, |
| { id: 'TRK-006', name: 'Tata Ultra T.7', maxWeight: 3500, maxVolume: 14.0, licensePlate: 'TS-08-KL-2345', co2PerKm: 0.20 }, |
| ]; |
|
|
| const result = runConsolidation(demoShipments, demoTrucks, { |
| maxGroupRadiusKm: 30, |
| timeWindowToleranceMinutes: 120, |
| }); |
|
|
| return res.json({ success: true, data: result, demo: true }); |
| }; |
|
|
| |
| exports.runConsolidation = runConsolidation; |
|
|