FairRelay / ops /backend-dm /controllers /consolidationController.js
MouleeswaranM's picture
Upload folder using huggingface_hub
fcf8749 verified
raw
history blame
17.2 kB
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
// ─── Haversine distance (km) ────────────────────────────────────────────────
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));
}
// ─── Geographic clustering (simple K-Means-style) ───────────────────────────
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;
}
// ─── Time window compatibility filter ───────────────────────────────────────
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;
}
// ─── First-fit-decreasing bin-packing ───────────────────────────────────────
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);
}
// ─── Compute per-group route distance (Haversine sum) ───────────────────────
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
);
}
// ─── Core consolidation algorithm ───────────────────────────────────────────
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)),
},
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// Controller endpoints
// ═══════════════════════════════════════════════════════════════════════════════
/**
* POST /api/consolidation/optimize
*/
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 });
}
};
/**
* POST /api/consolidation/simulate
*/
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 });
}
};
/**
* GET /api/consolidation/history
*/
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: [] });
}
};
/**
* POST /api/consolidation/demo
* Returns a pre-built demo consolidation with realistic Indian logistics 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 });
};
// Export the core function for V1 route usage
exports.runConsolidation = runConsolidation;