feat: Upgrade dispatch.js proxy with retry logic, structured errors, health polling
#22
by MouleeswaranM - opened
- ops/backend-dm/routes/dispatch.js +140 -70
ops/backend-dm/routes/dispatch.js
CHANGED
|
@@ -5,28 +5,79 @@ const router = express.Router();
|
|
| 5 |
// Proxy to the FairRelay AI Brain (FastAPI)
|
| 6 |
const BRAIN_URL = process.env.BRAIN_URL || 'http://localhost:8000';
|
| 7 |
|
| 8 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
async function proxyToBrain(req, res, method, path, data = null) {
|
|
|
|
| 10 |
try {
|
| 11 |
-
const config = {
|
| 12 |
-
method,
|
| 13 |
-
url: `${BRAIN_URL}${path}`,
|
| 14 |
-
headers: { 'Content-Type': 'application/json' },
|
| 15 |
-
timeout: 30000,
|
| 16 |
-
};
|
| 17 |
if (data) config.data = data;
|
| 18 |
|
| 19 |
-
const response = await
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
res.json(response.data);
|
| 21 |
} catch (error) {
|
| 22 |
-
|
|
|
|
|
|
|
| 23 |
if (error.response) {
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
} else {
|
| 26 |
-
res.status(
|
| 27 |
-
|
|
|
|
| 28 |
message: error.message,
|
| 29 |
-
|
| 30 |
});
|
| 31 |
}
|
| 32 |
}
|
|
@@ -36,9 +87,22 @@ async function proxyToBrain(req, res, method, path, data = null) {
|
|
| 36 |
|
| 37 |
// Run fair allocation with LangGraph multi-agent workflow
|
| 38 |
router.post('/allocate', async (req, res) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
await proxyToBrain(req, res, 'POST', '/api/v1/allocate/langgraph', req.body);
|
| 40 |
});
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
// Get allocation status for a run
|
| 43 |
router.get('/runs/:runId', async (req, res) => {
|
| 44 |
await proxyToBrain(req, res, 'GET', `/api/v1/runs/${req.params.runId}`);
|
|
@@ -55,6 +119,7 @@ router.get('/agent-events/stream', async (req, res) => {
|
|
| 55 |
const response = await axios({
|
| 56 |
method: 'GET',
|
| 57 |
url: `${BRAIN_URL}/agent-events/stream`,
|
|
|
|
| 58 |
responseType: 'stream',
|
| 59 |
timeout: 0,
|
| 60 |
});
|
|
@@ -62,6 +127,7 @@ router.get('/agent-events/stream', async (req, res) => {
|
|
| 62 |
res.setHeader('Content-Type', 'text/event-stream');
|
| 63 |
res.setHeader('Cache-Control', 'no-cache');
|
| 64 |
res.setHeader('Connection', 'keep-alive');
|
|
|
|
| 65 |
|
| 66 |
response.data.pipe(res);
|
| 67 |
|
|
@@ -69,8 +135,8 @@ router.get('/agent-events/stream', async (req, res) => {
|
|
| 69 |
response.data.destroy();
|
| 70 |
});
|
| 71 |
} catch (error) {
|
| 72 |
-
console.error('[Dispatch
|
| 73 |
-
res.status(503).json({ error: 'Agent events stream unavailable' });
|
| 74 |
}
|
| 75 |
});
|
| 76 |
|
|
@@ -93,117 +159,124 @@ router.post('/feedback', async (req, res) => {
|
|
| 93 |
await proxyToBrain(req, res, 'POST', '/api/v1/feedback', req.body);
|
| 94 |
});
|
| 95 |
|
| 96 |
-
// Health check for brain
|
|
|
|
|
|
|
|
|
|
| 97 |
router.get('/health', async (req, res) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
try {
|
| 99 |
-
const response = await
|
| 100 |
-
|
| 101 |
brain_status: 'connected',
|
| 102 |
brain_health: response.data,
|
| 103 |
-
gateway: 'operational'
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
} catch (error) {
|
| 106 |
-
|
| 107 |
brain_status: 'disconnected',
|
| 108 |
error: error.message,
|
| 109 |
gateway: 'operational',
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
});
|
| 114 |
|
| 115 |
-
// ======
|
| 116 |
router.post('/wellness-check', async (req, res) => {
|
| 117 |
-
// Wellness scoring for drivers before dispatch
|
| 118 |
const { drivers } = req.body;
|
| 119 |
|
| 120 |
const scoredDrivers = (drivers || []).map(driver => {
|
| 121 |
const wrs = calculateWellnessScore(driver);
|
|
|
|
| 122 |
return {
|
| 123 |
...driver,
|
| 124 |
wellnessScore: wrs,
|
|
|
|
|
|
|
| 125 |
maxDifficulty: wrs < 40 ? 'EASY' : wrs < 70 ? 'MEDIUM' : 'ANY',
|
| 126 |
wellnessStatus: wrs < 40 ? 'FATIGUED' : wrs < 70 ? 'MODERATE' : 'FIT',
|
| 127 |
};
|
| 128 |
});
|
| 129 |
|
| 130 |
-
res.json({ drivers: scoredDrivers });
|
| 131 |
});
|
| 132 |
|
| 133 |
function calculateWellnessScore(driver) {
|
| 134 |
-
const {
|
| 135 |
-
hoursToday = 0,
|
| 136 |
-
hoursSinceRest = 24,
|
| 137 |
-
isIll = false,
|
| 138 |
-
totalHours7d = 0,
|
| 139 |
-
} = driver;
|
| 140 |
-
|
| 141 |
-
// Max 8 hrs/day, fatigue increases after that
|
| 142 |
const fatigueFactor = Math.min(hoursToday / 12, 1.0) * 30;
|
| 143 |
-
|
| 144 |
-
// Less rest = lower score
|
| 145 |
const restFactor = Math.max(0, (1 - Math.min(hoursSinceRest / 10, 1.0))) * 25;
|
| 146 |
-
|
| 147 |
-
// Illness flag
|
| 148 |
const illnessFactor = isIll ? 30 : 0;
|
| 149 |
-
|
| 150 |
-
// Weekly overwork (>50 hrs = concerning)
|
| 151 |
const overworkFactor = Math.min(totalHours7d / 70, 1.0) * 15;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
}
|
| 156 |
|
| 157 |
-
// ======
|
| 158 |
router.post('/carbon-calculate', async (req, res) => {
|
| 159 |
const { routes } = req.body;
|
| 160 |
-
const EMISSION_FACTORS = {
|
| 161 |
-
PETROL: 2.31,
|
| 162 |
-
DIESEL: 2.68,
|
| 163 |
-
CNG: 1.86,
|
| 164 |
-
ELECTRIC: 0.0,
|
| 165 |
-
EV: 0.0,
|
| 166 |
-
};
|
| 167 |
|
| 168 |
const results = (routes || []).map(route => {
|
| 169 |
const factor = EMISSION_FACTORS[route.vehicleType] || EMISSION_FACTORS.DIESEL;
|
| 170 |
const loadFactor = Math.min(route.loadPercent || 70, 100) / 100;
|
| 171 |
const co2Kg = route.distanceKm * factor * (0.5 + 0.5 * loadFactor);
|
| 172 |
-
const evOptimal = 0; // If EV was used
|
| 173 |
-
const carbonSaved = co2Kg - evOptimal;
|
| 174 |
-
|
| 175 |
return {
|
| 176 |
-
routeId: route.routeId,
|
| 177 |
-
distanceKm: route.distanceKm,
|
| 178 |
vehicleType: route.vehicleType,
|
| 179 |
co2ActualKg: Math.round(co2Kg * 100) / 100,
|
| 180 |
co2OptimalKg: 0,
|
| 181 |
-
carbonSavedKg: Math.round(
|
| 182 |
greenScore: Math.round((1 - co2Kg / (route.distanceKm * 2.68 + 0.01)) * 100),
|
| 183 |
};
|
| 184 |
});
|
| 185 |
|
| 186 |
-
const totalCO2 = results.reduce((
|
| 187 |
-
const totalSaved = results.reduce((
|
| 188 |
-
const fleetGreenScore = results.length > 0
|
| 189 |
-
? Math.round(results.reduce((sum, r) => sum + r.greenScore, 0) / results.length)
|
| 190 |
-
: 0;
|
| 191 |
|
| 192 |
res.json({
|
| 193 |
routes: results,
|
| 194 |
summary: {
|
| 195 |
totalCO2Kg: Math.round(totalCO2 * 100) / 100,
|
| 196 |
totalCarbonSavedKg: Math.round(totalSaved * 100) / 100,
|
| 197 |
-
fleetGreenScore,
|
| 198 |
evUtilizationRate: results.filter(r => r.vehicleType === 'ELECTRIC' || r.vehicleType === 'EV').length / (results.length || 1) * 100,
|
| 199 |
}
|
| 200 |
});
|
| 201 |
});
|
| 202 |
|
| 203 |
-
// ======
|
| 204 |
router.post('/night-safety-filter', async (req, res) => {
|
| 205 |
const { drivers, currentHour } = req.body;
|
| 206 |
-
const
|
|
|
|
| 207 |
|
| 208 |
const filtered = (drivers || []).map(driver => {
|
| 209 |
const needsSafety = isNight && driver.gender === 'F';
|
|
@@ -211,16 +284,13 @@ router.post('/night-safety-filter', async (req, res) => {
|
|
| 211 |
...driver,
|
| 212 |
nightSafetyActive: needsSafety,
|
| 213 |
routeConstraints: needsSafety ? {
|
| 214 |
-
maxDistanceKm: 50,
|
| 215 |
-
|
| 216 |
-
preferWellLitAreas: true,
|
| 217 |
-
preferNearPoliceStations: true,
|
| 218 |
-
sosEnabled: true,
|
| 219 |
} : null,
|
| 220 |
};
|
| 221 |
});
|
| 222 |
|
| 223 |
-
res.json({ drivers: filtered, isNightMode: isNight });
|
| 224 |
});
|
| 225 |
|
| 226 |
module.exports = router;
|
|
|
|
| 5 |
// Proxy to the FairRelay AI Brain (FastAPI)
|
| 6 |
const BRAIN_URL = process.env.BRAIN_URL || 'http://localhost:8000';
|
| 7 |
|
| 8 |
+
// Axios instance with retry logic
|
| 9 |
+
const brainClient = axios.create({
|
| 10 |
+
baseURL: BRAIN_URL,
|
| 11 |
+
timeout: 30000,
|
| 12 |
+
headers: { 'Content-Type': 'application/json' },
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
// Retry interceptor (3 retries with exponential backoff)
|
| 16 |
+
brainClient.interceptors.response.use(null, async (error) => {
|
| 17 |
+
const config = error.config;
|
| 18 |
+
if (!config || config.__retryCount >= 3) return Promise.reject(error);
|
| 19 |
+
|
| 20 |
+
config.__retryCount = (config.__retryCount || 0) + 1;
|
| 21 |
+
const backoff = Math.pow(2, config.__retryCount) * 500;
|
| 22 |
+
|
| 23 |
+
// Only retry on network errors or 5xx
|
| 24 |
+
if (!error.response || error.response.status >= 500) {
|
| 25 |
+
await new Promise(r => setTimeout(r, backoff));
|
| 26 |
+
return brainClient(config);
|
| 27 |
+
}
|
| 28 |
+
return Promise.reject(error);
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
// Helper to proxy requests to the AI Brain with structured error handling
|
| 32 |
async function proxyToBrain(req, res, method, path, data = null) {
|
| 33 |
+
const startTime = Date.now();
|
| 34 |
try {
|
| 35 |
+
const config = { method, url: path };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
if (data) config.data = data;
|
| 37 |
|
| 38 |
+
const response = await brainClient(config);
|
| 39 |
+
const latencyMs = Date.now() - startTime;
|
| 40 |
+
|
| 41 |
+
// Add latency header for monitoring
|
| 42 |
+
res.set('X-Brain-Latency-Ms', String(latencyMs));
|
| 43 |
res.json(response.data);
|
| 44 |
} catch (error) {
|
| 45 |
+
const latencyMs = Date.now() - startTime;
|
| 46 |
+
console.error(`[Dispatch] ${method} ${path} failed (${latencyMs}ms):`, error.message);
|
| 47 |
+
|
| 48 |
if (error.response) {
|
| 49 |
+
// Brain returned an error — forward it with context
|
| 50 |
+
res.status(error.response.status).json({
|
| 51 |
+
success: false,
|
| 52 |
+
error: error.response.data?.detail || error.response.data?.error || 'Brain service error',
|
| 53 |
+
status: error.response.status,
|
| 54 |
+
brain_url: BRAIN_URL,
|
| 55 |
+
latency_ms: latencyMs,
|
| 56 |
+
path: path,
|
| 57 |
+
});
|
| 58 |
+
} else if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
|
| 59 |
+
res.status(503).json({
|
| 60 |
+
success: false,
|
| 61 |
+
error: 'AI Brain service unreachable',
|
| 62 |
+
code: 'BRAIN_UNREACHABLE',
|
| 63 |
+
brain_url: BRAIN_URL,
|
| 64 |
+
hint: 'Brain service may be cold-starting on Render (30-60s). Retry shortly.',
|
| 65 |
+
latency_ms: latencyMs,
|
| 66 |
+
});
|
| 67 |
+
} else if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
|
| 68 |
+
res.status(504).json({
|
| 69 |
+
success: false,
|
| 70 |
+
error: 'Brain service timeout — allocation may be running with large dataset',
|
| 71 |
+
code: 'BRAIN_TIMEOUT',
|
| 72 |
+
latency_ms: latencyMs,
|
| 73 |
+
hint: 'Try again or reduce input size.',
|
| 74 |
+
});
|
| 75 |
} else {
|
| 76 |
+
res.status(500).json({
|
| 77 |
+
success: false,
|
| 78 |
+
error: 'Unexpected error communicating with AI Brain',
|
| 79 |
message: error.message,
|
| 80 |
+
latency_ms: latencyMs,
|
| 81 |
});
|
| 82 |
}
|
| 83 |
}
|
|
|
|
| 87 |
|
| 88 |
// Run fair allocation with LangGraph multi-agent workflow
|
| 89 |
router.post('/allocate', async (req, res) => {
|
| 90 |
+
// Validate minimum input
|
| 91 |
+
if (!req.body.drivers?.length && !req.body.packages?.length) {
|
| 92 |
+
return res.status(400).json({
|
| 93 |
+
success: false,
|
| 94 |
+
error: 'Request must include drivers and/or packages arrays',
|
| 95 |
+
hint: 'See API docs at /api/docs',
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
await proxyToBrain(req, res, 'POST', '/api/v1/allocate/langgraph', req.body);
|
| 99 |
});
|
| 100 |
|
| 101 |
+
// Fallback: original (non-LangGraph) allocation
|
| 102 |
+
router.post('/allocate/simple', async (req, res) => {
|
| 103 |
+
await proxyToBrain(req, res, 'POST', '/api/v1/allocate', req.body);
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
// Get allocation status for a run
|
| 107 |
router.get('/runs/:runId', async (req, res) => {
|
| 108 |
await proxyToBrain(req, res, 'GET', `/api/v1/runs/${req.params.runId}`);
|
|
|
|
| 119 |
const response = await axios({
|
| 120 |
method: 'GET',
|
| 121 |
url: `${BRAIN_URL}/agent-events/stream`,
|
| 122 |
+
params: req.query,
|
| 123 |
responseType: 'stream',
|
| 124 |
timeout: 0,
|
| 125 |
});
|
|
|
|
| 127 |
res.setHeader('Content-Type', 'text/event-stream');
|
| 128 |
res.setHeader('Cache-Control', 'no-cache');
|
| 129 |
res.setHeader('Connection', 'keep-alive');
|
| 130 |
+
res.setHeader('X-Accel-Buffering', 'no');
|
| 131 |
|
| 132 |
response.data.pipe(res);
|
| 133 |
|
|
|
|
| 135 |
response.data.destroy();
|
| 136 |
});
|
| 137 |
} catch (error) {
|
| 138 |
+
console.error('[Dispatch] SSE stream error:', error.message);
|
| 139 |
+
res.status(503).json({ error: 'Agent events stream unavailable', hint: 'Brain may be starting up' });
|
| 140 |
}
|
| 141 |
});
|
| 142 |
|
|
|
|
| 159 |
await proxyToBrain(req, res, 'POST', '/api/v1/feedback', req.body);
|
| 160 |
});
|
| 161 |
|
| 162 |
+
// Health check for brain with caching (avoid hammering brain)
|
| 163 |
+
let _brainHealthCache = { data: null, ts: 0 };
|
| 164 |
+
const HEALTH_CACHE_TTL = 15000; // 15s
|
| 165 |
+
|
| 166 |
router.get('/health', async (req, res) => {
|
| 167 |
+
const now = Date.now();
|
| 168 |
+
if (_brainHealthCache.data && (now - _brainHealthCache.ts) < HEALTH_CACHE_TTL) {
|
| 169 |
+
return res.json(_brainHealthCache.data);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
try {
|
| 173 |
+
const response = await brainClient.get('/health', { timeout: 5000 });
|
| 174 |
+
const result = {
|
| 175 |
brain_status: 'connected',
|
| 176 |
brain_health: response.data,
|
| 177 |
+
gateway: 'operational',
|
| 178 |
+
brain_url: BRAIN_URL,
|
| 179 |
+
latency_ms: Date.now() - now,
|
| 180 |
+
};
|
| 181 |
+
_brainHealthCache = { data: result, ts: now };
|
| 182 |
+
res.json(result);
|
| 183 |
} catch (error) {
|
| 184 |
+
const result = {
|
| 185 |
brain_status: 'disconnected',
|
| 186 |
error: error.message,
|
| 187 |
gateway: 'operational',
|
| 188 |
+
brain_url: BRAIN_URL,
|
| 189 |
+
hint: 'Brain may be cold-starting (30-60s on free tier). Try again shortly.',
|
| 190 |
+
};
|
| 191 |
+
_brainHealthCache = { data: result, ts: now };
|
| 192 |
+
res.json(result);
|
| 193 |
}
|
| 194 |
});
|
| 195 |
|
| 196 |
+
// ====== Wellness-Aware Dispatch ======
|
| 197 |
router.post('/wellness-check', async (req, res) => {
|
|
|
|
| 198 |
const { drivers } = req.body;
|
| 199 |
|
| 200 |
const scoredDrivers = (drivers || []).map(driver => {
|
| 201 |
const wrs = calculateWellnessScore(driver);
|
| 202 |
+
const cli = calculateCognitiveLoad(driver);
|
| 203 |
return {
|
| 204 |
...driver,
|
| 205 |
wellnessScore: wrs,
|
| 206 |
+
cognitiveLoad: cli.score,
|
| 207 |
+
cognitiveState: cli.state,
|
| 208 |
maxDifficulty: wrs < 40 ? 'EASY' : wrs < 70 ? 'MEDIUM' : 'ANY',
|
| 209 |
wellnessStatus: wrs < 40 ? 'FATIGUED' : wrs < 70 ? 'MODERATE' : 'FIT',
|
| 210 |
};
|
| 211 |
});
|
| 212 |
|
| 213 |
+
res.json({ drivers: scoredDrivers, timestamp: new Date().toISOString() });
|
| 214 |
});
|
| 215 |
|
| 216 |
function calculateWellnessScore(driver) {
|
| 217 |
+
const { hoursToday = 0, hoursSinceRest = 24, isIll = false, totalHours7d = 0 } = driver;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
const fatigueFactor = Math.min(hoursToday / 12, 1.0) * 30;
|
|
|
|
|
|
|
| 219 |
const restFactor = Math.max(0, (1 - Math.min(hoursSinceRest / 10, 1.0))) * 25;
|
|
|
|
|
|
|
| 220 |
const illnessFactor = isIll ? 30 : 0;
|
|
|
|
|
|
|
| 221 |
const overworkFactor = Math.min(totalHours7d / 70, 1.0) * 15;
|
| 222 |
+
return Math.max(0, Math.min(100, Math.round(100 - fatigueFactor - restFactor - illnessFactor - overworkFactor)));
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
function calculateCognitiveLoad(driver) {
|
| 226 |
+
const { stopsToday = 0, hoursToday = 0, cognitiveLoad } = driver;
|
| 227 |
+
if (cognitiveLoad !== undefined) return { score: cognitiveLoad, state: cognitiveLoad > 70 ? 'OVERLOADED' : cognitiveLoad > 50 ? 'STRAINED' : cognitiveLoad > 30 ? 'ALERT' : 'SHARP' };
|
| 228 |
|
| 229 |
+
// 6-factor Cognitive Load Index (CLI)
|
| 230 |
+
const decisionFatigue = Math.min(stopsToday / 20, 1.0) * 25;
|
| 231 |
+
const timePressure = Math.min(hoursToday / 10, 1.0) * 20;
|
| 232 |
+
const taskComplexity = Math.min(stopsToday * 2.5, 25);
|
| 233 |
+
const circadianDip = (new Date().getHours() >= 13 && new Date().getHours() <= 15) ? 10 : 0;
|
| 234 |
+
const monotony = hoursToday > 6 ? 10 : 0;
|
| 235 |
+
const environmentalStress = 5; // baseline
|
| 236 |
+
|
| 237 |
+
const score = Math.min(100, Math.round(decisionFatigue + timePressure + taskComplexity + circadianDip + monotony + environmentalStress));
|
| 238 |
+
const state = score > 70 ? 'OVERLOADED' : score > 50 ? 'STRAINED' : score > 30 ? 'ALERT' : 'SHARP';
|
| 239 |
+
return { score, state };
|
| 240 |
}
|
| 241 |
|
| 242 |
+
// ====== Carbon Footprint Tracking ======
|
| 243 |
router.post('/carbon-calculate', async (req, res) => {
|
| 244 |
const { routes } = req.body;
|
| 245 |
+
const EMISSION_FACTORS = { PETROL: 2.31, DIESEL: 2.68, CNG: 1.86, ELECTRIC: 0.0, EV: 0.0 };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
const results = (routes || []).map(route => {
|
| 248 |
const factor = EMISSION_FACTORS[route.vehicleType] || EMISSION_FACTORS.DIESEL;
|
| 249 |
const loadFactor = Math.min(route.loadPercent || 70, 100) / 100;
|
| 250 |
const co2Kg = route.distanceKm * factor * (0.5 + 0.5 * loadFactor);
|
|
|
|
|
|
|
|
|
|
| 251 |
return {
|
| 252 |
+
routeId: route.routeId, distanceKm: route.distanceKm,
|
|
|
|
| 253 |
vehicleType: route.vehicleType,
|
| 254 |
co2ActualKg: Math.round(co2Kg * 100) / 100,
|
| 255 |
co2OptimalKg: 0,
|
| 256 |
+
carbonSavedKg: Math.round(co2Kg * 100) / 100,
|
| 257 |
greenScore: Math.round((1 - co2Kg / (route.distanceKm * 2.68 + 0.01)) * 100),
|
| 258 |
};
|
| 259 |
});
|
| 260 |
|
| 261 |
+
const totalCO2 = results.reduce((s, r) => s + r.co2ActualKg, 0);
|
| 262 |
+
const totalSaved = results.reduce((s, r) => s + r.carbonSavedKg, 0);
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
res.json({
|
| 265 |
routes: results,
|
| 266 |
summary: {
|
| 267 |
totalCO2Kg: Math.round(totalCO2 * 100) / 100,
|
| 268 |
totalCarbonSavedKg: Math.round(totalSaved * 100) / 100,
|
| 269 |
+
fleetGreenScore: results.length > 0 ? Math.round(results.reduce((s, r) => s + r.greenScore, 0) / results.length) : 0,
|
| 270 |
evUtilizationRate: results.filter(r => r.vehicleType === 'ELECTRIC' || r.vehicleType === 'EV').length / (results.length || 1) * 100,
|
| 271 |
}
|
| 272 |
});
|
| 273 |
});
|
| 274 |
|
| 275 |
+
// ====== Night Safety Filter ======
|
| 276 |
router.post('/night-safety-filter', async (req, res) => {
|
| 277 |
const { drivers, currentHour } = req.body;
|
| 278 |
+
const hour = currentHour ?? new Date().getHours();
|
| 279 |
+
const isNight = hour >= 19 || hour <= 6;
|
| 280 |
|
| 281 |
const filtered = (drivers || []).map(driver => {
|
| 282 |
const needsSafety = isNight && driver.gender === 'F';
|
|
|
|
| 284 |
...driver,
|
| 285 |
nightSafetyActive: needsSafety,
|
| 286 |
routeConstraints: needsSafety ? {
|
| 287 |
+
maxDistanceKm: 50, avoidHighCrimeZones: true,
|
| 288 |
+
preferWellLitAreas: true, preferNearPoliceStations: true, sosEnabled: true,
|
|
|
|
|
|
|
|
|
|
| 289 |
} : null,
|
| 290 |
};
|
| 291 |
});
|
| 292 |
|
| 293 |
+
res.json({ drivers: filtered, isNightMode: isNight, hour });
|
| 294 |
});
|
| 295 |
|
| 296 |
module.exports = router;
|