| const prisma = require('../config/database'); |
|
|
| |
| |
| |
| function getDistance(lat1, lon1, lat2, lon2) { |
| const R = 6371; |
| const dLat = (lat2 - lat1) * Math.PI / 180; |
| const dLon = (lon2 - lon1) * Math.PI / 180; |
| const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + |
| Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * |
| Math.sin(dLon / 2) * Math.sin(dLon / 2); |
| const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); |
| return R * c; |
| } |
|
|
| |
| |
| |
| |
| exports.createAndAssignRoute = async (req, res) => { |
| try { |
| const { |
| courierCompanyId, |
| truckNumber, |
| driverNumber, |
| deliveryIds, |
| totalDistance, |
| totalDuration, |
| estimatedStartTime, |
| estimatedEndTime, |
| baselineDistance, |
| carbonSaved, |
| emptyMilesSaved |
| } = req.body; |
|
|
| |
| if (!courierCompanyId || !truckNumber || !driverNumber || !deliveryIds || !totalDistance) { |
| return res.status(400).json({ |
| success: false, |
| message: 'Missing required fields: courierCompanyId, truckId, driverId, deliveryIds, totalDistance' |
| }); |
| } |
|
|
| if (!Array.isArray(deliveryIds) || deliveryIds.length === 0) { |
| return res.status(400).json({ |
| success: false, |
| message: 'deliveryIds must be a non-empty array' |
| }); |
| } |
|
|
| |
| const [driver, truck] = await Promise.all([ |
| prisma.user.findUnique({ |
| where: { phone: driverNumber }, |
| select: { |
| id: true, |
| courierCompanyId: true, |
| totalDistanceKm: true, |
| role: true |
| } |
| }), |
| prisma.truck.findUnique({ |
| where: { licensePlate: truckNumber }, |
| select: { |
| id: true, |
| courierCompanyId: true, |
| maxWeight: true, |
| maxVolume: true, |
| currentWeight: true, |
| currentVolume: true, |
| isAvailable: true |
| } |
| }) |
| ]); |
|
|
| if (!driver) { |
| return res.status(404).json({ |
| success: false, |
| message: 'Driver not found' |
| }); |
| } |
|
|
| if (!truck) { |
| return res.status(404).json({ |
| success: false, |
| message: 'Truck not found' |
| }); |
| } |
|
|
| if (driver.role !== 'DRIVER') { |
| return res.status(400).json({ |
| success: false, |
| message: 'Specified user is not a driver' |
| }); |
| } |
|
|
| |
| if (driver.courierCompanyId !== courierCompanyId || truck.courierCompanyId !== courierCompanyId) { |
| return res.status(403).json({ |
| success: false, |
| message: 'Driver and/or Truck does not belong to the specified courier company' |
| }); |
| } |
|
|
| if (!truck.isAvailable) { |
| return res.status(400).json({ |
| success: false, |
| message: 'Truck is not available for assignment' |
| }); |
| } |
|
|
| |
| const isLongDistance = totalDistance > 300; |
| const driverHistoricalDistance = driver.totalDistanceKm || 0; |
| const HIGH_WORKLOAD_THRESHOLD = 500; |
|
|
| if (isLongDistance && driverHistoricalDistance < HIGH_WORKLOAD_THRESHOLD) { |
| return res.status(400).json({ |
| success: false, |
| message: `Long-distance route (${totalDistance}km) requires experienced driver with high historical distance (>${HIGH_WORKLOAD_THRESHOLD}km). Driver has only ${driverHistoricalDistance}km.` |
| }); |
| } |
|
|
| if (!isLongDistance && driverHistoricalDistance >= HIGH_WORKLOAD_THRESHOLD) { |
| return res.status(400).json({ |
| success: false, |
| message: `Short-distance route (${totalDistance}km) should be assigned to driver with lower workload. Driver has ${driverHistoricalDistance}km historical distance.` |
| }); |
| } |
|
|
| |
| const deliveries = await prisma.delivery.findMany({ |
| where: { |
| id: { in: deliveryIds }, |
| courierCompanyId |
| }, |
| orderBy: { pickupTime: 'asc' } |
| }); |
|
|
| if (deliveries.length !== deliveryIds.length) { |
| return res.status(404).json({ |
| success: false, |
| message: 'One or more deliveries not found or do not belong to the specified company' |
| }); |
| } |
|
|
| |
| const allocatedDeliveries = deliveries.filter(d => d.status !== 'PENDING'); |
| if (allocatedDeliveries.length > 0) { |
| return res.status(400).json({ |
| success: false, |
| message: `Deliveries already allocated: ${allocatedDeliveries.map(d => d.packageId).join(', ')}` |
| }); |
| } |
|
|
| |
| const totalWeight = deliveries.reduce((sum, d) => sum + (d.cargoWeight || 0), 0); |
| const totalVolume = deliveries.reduce((sum, d) => sum + (d.cargoVolumeLtrs || 0), 0); |
|
|
| |
| const availableWeight = truck.maxWeight - truck.currentWeight; |
| const availableVolume = truck.maxVolume - truck.currentVolume; |
|
|
| if (totalWeight > availableWeight) { |
| return res.status(400).json({ |
| success: false, |
| message: `Truck capacity exceeded. Required: ${totalWeight}kg, Available: ${availableWeight}kg` |
| }); |
| } |
|
|
| if (totalVolume > availableVolume) { |
| return res.status(400).json({ |
| success: false, |
| message: `Truck volume exceeded. Required: ${totalVolume}L, Available: ${availableVolume}L` |
| }); |
| } |
|
|
| |
| const waypoints = []; |
| deliveries.forEach(delivery => { |
| |
| waypoints.push({ |
| type: 'PICKUP', |
| location: delivery.pickupLocation, |
| lat: delivery.pickupLat, |
| lng: delivery.pickupLng, |
| deliveryId: delivery.id, |
| packageId: delivery.packageId |
| }); |
|
|
| |
| waypoints.push({ |
| type: 'DROP', |
| location: delivery.dropLocation, |
| lat: delivery.dropLat, |
| lng: delivery.dropLng, |
| deliveryId: delivery.id, |
| packageId: delivery.packageId |
| }); |
| }); |
|
|
| |
| const result = await prisma.$transaction(async (tx) => { |
| |
| const route = await tx.optimizedRoute.create({ |
| data: { |
| courierCompanyId, |
| truckId, |
| driverId, |
| totalDistance, |
| totalDuration: totalDuration || 0, |
| estimatedStartTime: estimatedStartTime ? new Date(estimatedStartTime) : new Date(), |
| estimatedEndTime: estimatedEndTime ? new Date(estimatedEndTime) : new Date(Date.now() + (totalDuration || 60) * 60000), |
| totalPackages: deliveries.length, |
| totalWeight, |
| totalVolume, |
| utilizationPercent: truck.maxWeight ? ((totalWeight / truck.maxWeight) * 100) : 0, |
| baselineDistance: baselineDistance || totalDistance, |
| carbonSaved: carbonSaved || 0, |
| emptyMilesSaved: emptyMilesSaved || 0, |
| waypoints: waypoints, |
| isTSPOptimized: true, |
| status: 'ALLOCATED' |
| }, |
| include: { |
| _count: { |
| select: { deliveries: true } |
| } |
| } |
| }); |
|
|
| |
| await tx.delivery.updateMany({ |
| where: { id: { in: deliveryIds } }, |
| data: { |
| optimizedRouteId: route.id, |
| truckId, |
| driverId, |
| status: 'ALLOCATED' |
| } |
| }); |
|
|
| |
| await tx.truck.update({ |
| where: { id: truckId }, |
| data: { |
| currentWeight: { increment: totalWeight }, |
| currentVolume: { increment: totalVolume }, |
| isAvailable: false |
| } |
| }); |
|
|
| |
| await tx.user.update({ |
| where: { id: driverId }, |
| data: { |
| totalDistanceKm: { increment: totalDistance } |
| } |
| }); |
|
|
| return route; |
| }); |
|
|
| res.status(201).json({ |
| success: true, |
| message: `Route created and assigned successfully with ${deliveries.length} deliveries`, |
| data: { |
| route: result |
| } |
| }); |
|
|
| } catch (error) { |
| console.error('Create and assign route error:', error); |
| res.status(500).json({ |
| success: false, |
| message: 'Failed to create and assign route', |
| error: error.message |
| }); |
| } |
| }; |
|
|
| |
| |
| |
| |
| exports.allocateRoutes = async (req, res) => { |
| try { |
| const { courierCompanyId } = req.body; |
|
|
| if (!courierCompanyId) { |
| return res.status(400).json({ success: false, message: 'courierCompanyId is required' }); |
| } |
|
|
| |
| const pendingDeliveries = await prisma.delivery.findMany({ |
| where: { |
| courierCompanyId, |
| status: 'PENDING' |
| }, |
| orderBy: { timeWindowStart: 'asc' } |
| }); |
|
|
| if (pendingDeliveries.length === 0) { |
| return res.status(200).json({ success: true, message: 'No pending deliveries to allocate' }); |
| } |
|
|
| |
| const availableTrucks = await prisma.truck.findMany({ |
| where: { |
| courierCompanyId, |
| isAvailable: true, |
| registrationStatus: 'APPROVED' |
| }, |
| include: { owner: true } |
| }); |
|
|
| if (availableTrucks.length === 0) { |
| return res.status(400).json({ success: false, message: 'No available trucks for allocation' }); |
| } |
|
|
| |
| |
| const allocations = []; |
| let deliveryIndex = 0; |
|
|
| for (const truck of availableTrucks) { |
| if (deliveryIndex >= pendingDeliveries.length) break; |
|
|
| let currentWeight = 0; |
| let currentVolume = 0; |
| const routeDeliveries = []; |
|
|
| while (deliveryIndex < pendingDeliveries.length) { |
| const delivery = pendingDeliveries[deliveryIndex]; |
|
|
| |
| const fitsWeight = (currentWeight + delivery.cargoWeight) <= (truck.maxWeight || Infinity); |
| const fitsVolume = (currentVolume + delivery.cargoVolumeLtrs) <= (truck.maxVolume || Infinity); |
|
|
| if (fitsWeight && fitsVolume) { |
| routeDeliveries.push(delivery); |
| currentWeight += delivery.cargoWeight; |
| currentVolume += delivery.cargoVolumeLtrs; |
| deliveryIndex++; |
| } else { |
| |
| break; |
| } |
| } |
|
|
| if (routeDeliveries.length > 0) { |
| allocations.push({ |
| truck, |
| deliveries: routeDeliveries, |
| totalWeight: currentWeight, |
| totalVolume: currentVolume |
| }); |
| } |
| } |
|
|
| |
| const results = await prisma.$transaction(async (tx) => { |
| const createdRoutes = []; |
|
|
| for (const alloc of allocations) { |
| const { truck, deliveries, totalWeight, totalVolume } = alloc; |
|
|
| |
| const route = await tx.optimizedRoute.create({ |
| data: { |
| courierCompanyId, |
| truckId: truck.id, |
| driverId: truck.ownerId, |
| totalDistance: 0, |
| totalDuration: 60, |
| estimatedStartTime: new Date(), |
| estimatedEndTime: new Date(Date.now() + 3600000), |
| totalPackages: deliveries.length, |
| totalWeight, |
| totalVolume, |
| utilizationPercent: truck.maxWeight ? (totalWeight / truck.maxWeight) * 100 : 0, |
| baselineDistance: 0, |
| carbonSaved: 0, |
| emptyMilesSaved: 0, |
| status: 'ALLOCATED' |
| } |
| }); |
|
|
| |
| await tx.delivery.updateMany({ |
| where: { id: { in: deliveries.map(d => d.id) } }, |
| data: { |
| truckId: truck.id, |
| driverId: truck.ownerId, |
| optimizedRouteId: route.id, |
| status: 'ALLOCATED' |
| } |
| }); |
|
|
| |
| await tx.truck.update({ |
| where: { id: truck.id }, |
| data: { isAvailable: false } |
| }); |
|
|
| createdRoutes.push(route); |
| } |
| return createdRoutes; |
| }); |
|
|
| res.status(201).json({ |
| success: true, |
| message: `Allocated ${allocations.length} routes for ${pendingDeliveries.length} packages`, |
| data: results |
| }); |
|
|
| } catch (err) { |
| console.error('Allocation Error:', err); |
| res.status(500).json({ success: false, message: 'Internal server error during allocation', error: err.message }); |
| } |
| }; |
| |
| |
| |
| |
| exports.assignMultiStopRoute = async (req, res) => { |
| try { |
| const { |
| driverPhone, |
| truckLicensePlate, |
| courierCompanyId, |
| totalDistance, |
| checkpoints |
| } = req.body; |
|
|
| |
| if (!driverPhone || !truckLicensePlate || !courierCompanyId || !totalDistance || !Array.isArray(checkpoints)) { |
| return res.status(400).json({ |
| success: false, |
| message: 'Missing required fields: driverPhone, truckLicensePlate, courierCompanyId, totalDistance, checkpoints' |
| }); |
| } |
|
|
| |
| const [driver, truck] = await Promise.all([ |
| prisma.user.findUnique({ |
| where: { phone: driverPhone }, |
| select: { id: true, role: true, courierCompanyId: true, totalDistanceKm: true } |
| }), |
| prisma.truck.findUnique({ |
| where: { licensePlate: truckLicensePlate }, |
| select: { id: true, courierCompanyId: true, maxWeight: true, maxVolume: true, currentWeight: true, currentVolume: true } |
| }) |
| ]); |
|
|
| if (!driver || driver.role !== 'DRIVER' || driver.courierCompanyId !== courierCompanyId) { |
| return res.status(404).json({ |
| success: false, |
| message: 'Driver not found or does not belong to the specified company' |
| }); |
| } |
|
|
| if (!truck || truck.courierCompanyId !== courierCompanyId) { |
| return res.status(404).json({ |
| success: false, |
| message: 'Truck not found or does not belong to the specified company' |
| }); |
| } |
|
|
| |
| const deliveryIds = []; |
| const deliveriesToLink = []; |
|
|
| for (const cp of checkpoints) { |
| const delivery = await prisma.delivery.findFirst({ |
| where: { |
| pickupLocation: cp.pickupLocation, |
| dropLocation: cp.dropLocation, |
| status: 'PENDING', |
| |
| OR: [ |
| { courierCompanyId: courierCompanyId }, |
| { courierCompanyId: null } |
| ] |
| } |
| }); |
|
|
| if (!delivery) { |
| return res.status(400).json({ |
| success: false, |
| message: `Checkpoint mismatch: No PENDING delivery found for truck ${truckLicensePlate} from "${cp.pickupLocation}" to "${cp.dropLocation}"` |
| }); |
| } |
|
|
| deliveryIds.push(delivery.id); |
| deliveriesToLink.push(delivery); |
| } |
|
|
| |
| const HIGH_WORKLOAD_THRESHOLD = 500; |
| const isLongDistance = totalDistance > 300; |
| const driverHistory = driver.totalDistanceKm || 0; |
|
|
| if (isLongDistance && driverHistory < HIGH_WORKLOAD_THRESHOLD) { |
| return res.status(400).json({ |
| success: false, |
| message: `Workload Balance Error: Long-distance mission (${totalDistance}km) requires experienced driver (>${HIGH_WORKLOAD_THRESHOLD}km history). Driver has ${driverHistory}km.` |
| }); |
| } |
|
|
| if (!isLongDistance && driverHistory >= HIGH_WORKLOAD_THRESHOLD) { |
| return res.status(400).json({ |
| success: false, |
| message: `Workload Balance Error: Short-haul mission (${totalDistance}km) should be assigned to driver with less history. Driver has ${driverHistory}km.` |
| }); |
| } |
|
|
| |
| const totalWeight = deliveriesToLink.reduce((sum, d) => sum + (d.cargoWeight || 0), 0); |
| const totalVolume = deliveriesToLink.reduce((sum, d) => sum + (d.cargoVolumeLtrs || 0), 0); |
|
|
| |
| const waypoints = []; |
| deliveriesToLink.forEach(d => { |
| waypoints.push({ type: 'PICKUP', location: d.pickupLocation, lat: d.pickupLat, lng: d.pickupLng, deliveryId: d.id }); |
| waypoints.push({ type: 'DROP', location: d.dropLocation, lat: d.dropLat, lng: d.dropLng, deliveryId: d.id }); |
| }); |
|
|
| const result = await prisma.$transaction(async (tx) => { |
| const route = await tx.optimizedRoute.create({ |
| data: { |
| courierCompanyId, |
| truckId: truck.id, |
| driverId: driver.id, |
| totalDistance, |
| totalPackages: deliveriesToLink.length, |
| totalWeight, |
| totalVolume, |
| utilizationPercent: truck.maxWeight ? (totalWeight / truck.maxWeight) * 100 : 0, |
| baselineDistance: totalDistance, |
| carbonSaved: 0, |
| emptyMilesSaved: 0, |
| waypoints: waypoints, |
| estimatedStartTime: new Date(), |
| estimatedEndTime: new Date(Date.now() + 3600000), |
| status: 'ALLOCATED' |
| } |
| }); |
|
|
| await tx.delivery.updateMany({ |
| where: { id: { in: deliveryIds } }, |
| data: { |
| optimizedRouteId: route.id, |
| driverId: driver.id, |
| status: 'ALLOCATED' |
| } |
| }); |
|
|
| |
| await tx.user.update({ |
| where: { id: driver.id }, |
| data: { totalDistanceKm: { increment: totalDistance } } |
| }); |
|
|
| |
| await tx.truck.update({ |
| where: { id: truck.id }, |
| data: { isAvailable: false } |
| }); |
|
|
| return route; |
| }); |
|
|
| res.status(201).json({ |
| success: true, |
| message: 'Multi-stop route created successfully', |
| data: { |
| route: result, |
| checkpointsLinked: checkpoints.length |
| } |
| }); |
|
|
| } catch (error) { |
| console.error('Multi-stop assignment error:', error); |
| res.status(500).json({ |
| success: false, |
| message: 'Internal server error during multi-stop assignment', |
| error: error.message |
| }); |
| } |
| }; |
|
|
| module.exports = exports; |
|
|