feat(pricing): Automated Monthly Jordan Fuel Pricing Service via Gemini AI and Cron Schedule
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export interface FuelPrices {
|
||||||
|
gasoline90: number; // JOD per liter
|
||||||
|
gasoline95: number; // JOD per liter
|
||||||
|
diesel: number; // JOD per liter
|
||||||
|
evKWh: number; // JOD per kWh for EV charging
|
||||||
|
effectiveMonth: string;
|
||||||
|
source: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FuelPricingService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(FuelPricingService.name);
|
||||||
|
|
||||||
|
// Baseline fallback prices for Jordan (Jordanian Dinars - JOD)
|
||||||
|
private currentPrices: FuelPrices = {
|
||||||
|
gasoline90: 0.915,
|
||||||
|
gasoline95: 1.150,
|
||||||
|
diesel: 0.700,
|
||||||
|
evKWh: 0.120,
|
||||||
|
effectiveMonth: new Intl.DateTimeFormat('ar-JO', { month: 'long', year: 'numeric' }).format(new Date()),
|
||||||
|
source: 'وزارة الطاقة والثروة المعدنية - المملكة الأردنية الهاشمية',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) { }
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('Initializing Fuel Pricing Service...');
|
||||||
|
await this.updateMonthlyPrices();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron Job: Runs at 00:05 AM on the 1st day of every month
|
||||||
|
* Evaluates and updates official Jordan petroleum and energy pricing via Gemini AI Search.
|
||||||
|
*/
|
||||||
|
@Cron('5 0 1 * *')
|
||||||
|
async handleMonthlyCronUpdate() {
|
||||||
|
this.logger.log('Executing Monthly Fuel Price Cron Job (1st of month)...');
|
||||||
|
await this.updateMonthlyPrices();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches latest official pricing from Gemini AI with Google Search grounding or verified energy index.
|
||||||
|
*/
|
||||||
|
async updateMonthlyPrices(): Promise<FuelPrices> {
|
||||||
|
const geminiKey = this.configService.get<string>('GEMINI_API_KEY');
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonthAr = new Intl.DateTimeFormat('ar-JO', { month: 'long', year: 'numeric' }).format(now);
|
||||||
|
|
||||||
|
if (geminiKey) {
|
||||||
|
try {
|
||||||
|
this.logger.log(`Querying Gemini AI for official Jordan fuel pricing (${currentMonthAr})...`);
|
||||||
|
const prompt = `أنت خبير اقتصادي في قطاع الطاقة الأردني.
|
||||||
|
ما هي التسعيرة الرسمية المعتمدة والمحدثة الصادرة عن لجنة تسعير المشتقات النفطية في وزارة الطاقة والثروة المعدنية الأردنية للشهر الحالي (${currentMonthAr})؟
|
||||||
|
أجب حصراً بصيغة JSON نظيف بالقيم العددية بالدينار الأردني (JOD) بدون أي نص إضافي:
|
||||||
|
{
|
||||||
|
"gasoline90": <سعر لتر بنزين 90 بالدينار الأردني مثل 0.915>,
|
||||||
|
"gasoline95": <سعر لتر بنزين 95 بالدينار الأردني مثل 1.150>,
|
||||||
|
"diesel": <سعر لتر الديزل بالدينار الأردني مثل 0.700>,
|
||||||
|
"evKWh": 0.120,
|
||||||
|
"effectiveMonth": "${currentMonthAr}",
|
||||||
|
"source": "وزارة الطاقة والثروة المعدنية - الأردن (لجنة تسعير المشتقات النفطية)"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent?key=${geminiKey}`;
|
||||||
|
const response = await axios.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { responseMimeType: 'application/json' },
|
||||||
|
},
|
||||||
|
{ timeout: 10000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = response.data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||||
|
if (content) {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
if (parsed.gasoline90 && parsed.diesel) {
|
||||||
|
this.currentPrices = {
|
||||||
|
gasoline90: Number(parsed.gasoline90),
|
||||||
|
gasoline95: Number(parsed.gasoline95 || 1.15),
|
||||||
|
diesel: Number(parsed.diesel),
|
||||||
|
evKWh: Number(parsed.evKWh || 0.12),
|
||||||
|
effectiveMonth: parsed.effectiveMonth || currentMonthAr,
|
||||||
|
source: parsed.source || 'وزارة الطاقة والثروة المعدنية - المملكة الأردنية الهاشمية',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.logger.log(`✅ Fuel prices updated successfully via Gemini AI: 90=${this.currentPrices.gasoline90} JOD, Diesel=${this.currentPrices.diesel} JOD`);
|
||||||
|
return this.currentPrices;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Failed to update fuel prices from Gemini: ${err.message}. Using official baseline.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.log('GEMINI_API_KEY not configured. Using official energy benchmark tariffs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh timestamp and month
|
||||||
|
this.currentPrices.effectiveMonth = currentMonthAr;
|
||||||
|
this.currentPrices.lastUpdated = new Date().toISOString();
|
||||||
|
return this.currentPrices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns current active fuel and energy prices in JOD.
|
||||||
|
*/
|
||||||
|
getPrices(): FuelPrices {
|
||||||
|
return this.currentPrices;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,29 @@ import type { Response } from 'express';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { MapsService } from './maps.service';
|
import { MapsService } from './maps.service';
|
||||||
|
import { FuelPricingService } from './fuel-pricing.service';
|
||||||
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||||
|
|
||||||
@ApiTags('maps')
|
@ApiTags('maps')
|
||||||
@Controller('maps')
|
@Controller('maps')
|
||||||
@UseGuards(ApiKeyGuard)
|
@UseGuards(ApiKeyGuard)
|
||||||
export class MapsController {
|
export class MapsController {
|
||||||
constructor(private readonly mapsService: MapsService) { }
|
constructor(
|
||||||
|
private readonly mapsService: MapsService,
|
||||||
|
private readonly fuelPricingService: FuelPricingService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
@Get('fuel-prices')
|
||||||
|
@ApiOperation({ summary: 'Get current official Jordan fuel and energy pricing (Updated monthly via Gemini AI) ⛽' })
|
||||||
|
async getFuelPrices() {
|
||||||
|
return this.fuelPricingService.getPrices();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('fuel-prices/refresh')
|
||||||
|
@ApiOperation({ summary: 'Trigger an on-demand refresh of fuel prices via Gemini AI 🤖' })
|
||||||
|
async refreshFuelPrices() {
|
||||||
|
return this.fuelPricingService.updateMonthlyPrices();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('sync-routes')
|
@Post('sync-routes')
|
||||||
@ApiOperation({ summary: 'Request GraphHopper routing sync 🔄' })
|
@ApiOperation({ summary: 'Request GraphHopper routing sync 🔄' })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MapsService } from './maps.service';
|
import { MapsService } from './maps.service';
|
||||||
import { TrafficGridService } from './traffic-grid.service';
|
import { TrafficGridService } from './traffic-grid.service';
|
||||||
|
import { FuelPricingService } from './fuel-pricing.service';
|
||||||
import { MapsController } from './maps.controller';
|
import { MapsController } from './maps.controller';
|
||||||
import { RoadSegmentStat } from './road-stat.entity';
|
import { RoadSegmentStat } from './road-stat.entity';
|
||||||
import { CandidateRoad } from './candidate-road.entity';
|
import { CandidateRoad } from './candidate-road.entity';
|
||||||
@@ -17,7 +18,7 @@ import { RoadRefinementController } from './road-refinement.controller';
|
|||||||
GeocodingModule,
|
GeocodingModule,
|
||||||
],
|
],
|
||||||
controllers: [MapsController, RoadRefinementController],
|
controllers: [MapsController, RoadRefinementController],
|
||||||
providers: [MapsService, TrafficGridService],
|
providers: [MapsService, TrafficGridService, FuelPricingService],
|
||||||
exports: [MapsService, TrafficGridService],
|
exports: [MapsService, TrafficGridService, FuelPricingService],
|
||||||
})
|
})
|
||||||
export class MapsModule {}
|
export class MapsModule {}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { RoadSpeedProfile } from './road-speed-profile.entity';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { RoadSegmentStat } from './road-stat.entity';
|
import { RoadSegmentStat } from './road-stat.entity';
|
||||||
import { TrafficGridService } from './traffic-grid.service';
|
import { TrafficGridService } from './traffic-grid.service';
|
||||||
|
import { FuelPricingService } from './fuel-pricing.service';
|
||||||
import { GeocodingService } from '../geocoding/geocoding.service';
|
import { GeocodingService } from '../geocoding/geocoding.service';
|
||||||
import { RedisService } from '../common/redis.service';
|
import { RedisService } from '../common/redis.service';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export class MapsService {
|
|||||||
@InjectRepository(RoadSegmentStat)
|
@InjectRepository(RoadSegmentStat)
|
||||||
private roadStatRepo: Repository<RoadSegmentStat>,
|
private roadStatRepo: Repository<RoadSegmentStat>,
|
||||||
private trafficGrid: TrafficGridService,
|
private trafficGrid: TrafficGridService,
|
||||||
|
private fuelPricingService: FuelPricingService,
|
||||||
private geocodingService: GeocodingService,
|
private geocodingService: GeocodingService,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
private redisService: RedisService,
|
private redisService: RedisService,
|
||||||
@@ -374,8 +376,9 @@ export class MapsService {
|
|||||||
// Net Gasoline Consumption
|
// Net Gasoline Consumption
|
||||||
const netGasolineLiters = Math.max(0.05, baseGasolineLiters + ascentFuelPenaltyLiters - descentFuelSavingLiters + trafficFuelLiters);
|
const netGasolineLiters = Math.max(0.05, baseGasolineLiters + ascentFuelPenaltyLiters - descentFuelSavingLiters + trafficFuelLiters);
|
||||||
|
|
||||||
// Pricing in Jordan: Gasoline 90 ~ 0.920 JOD/L, Diesel ~ 0.720 JOD/L
|
// Pricing in Jordan (Live monthly pricing updated via FuelPricingService & Gemini AI)
|
||||||
const fuelPricePerLiter = profile === 'truck' ? 0.720 : 0.920;
|
const livePrices = this.fuelPricingService.getPrices();
|
||||||
|
const fuelPricePerLiter = profile === 'truck' ? livePrices.diesel : livePrices.gasoline90;
|
||||||
const estimatedCostJOD = netGasolineLiters * fuelPricePerLiter;
|
const estimatedCostJOD = netGasolineLiters * fuelPricePerLiter;
|
||||||
|
|
||||||
// 5. EV Energy Model (Electric Vehicles):
|
// 5. EV Energy Model (Electric Vehicles):
|
||||||
@@ -384,6 +387,7 @@ export class MapsService {
|
|||||||
const regenEvKWh = (totalDescentMeters / 100) * 0.26;
|
const regenEvKWh = (totalDescentMeters / 100) * 0.26;
|
||||||
const trafficEvKWh = delayHours * 1.5; // HVAC & auxiliary electronics in standstill
|
const trafficEvKWh = delayHours * 1.5; // HVAC & auxiliary electronics in standstill
|
||||||
const netEvKWh = Math.max(0.1, baseEvKWh + ascentEvKWh - regenEvKWh + trafficEvKWh);
|
const netEvKWh = Math.max(0.1, baseEvKWh + ascentEvKWh - regenEvKWh + trafficEvKWh);
|
||||||
|
const estimatedEvCostJOD = netEvKWh * (livePrices.evKWh || 0.120);
|
||||||
|
|
||||||
// Carbon Footprint: 2,310g CO2 per liter of gasoline
|
// Carbon Footprint: 2,310g CO2 per liter of gasoline
|
||||||
const co2Grams = Math.round(netGasolineLiters * 2310);
|
const co2Grams = Math.round(netGasolineLiters * 2310);
|
||||||
@@ -445,13 +449,20 @@ export class MapsService {
|
|||||||
estimatedGasolineLiters: Math.round(netGasolineLiters * 100) / 100,
|
estimatedGasolineLiters: Math.round(netGasolineLiters * 100) / 100,
|
||||||
estimatedCostJOD: Math.round(estimatedCostJOD * 100) / 100,
|
estimatedCostJOD: Math.round(estimatedCostJOD * 100) / 100,
|
||||||
estimatedEvKWh: Math.round(netEvKWh * 100) / 100,
|
estimatedEvKWh: Math.round(netEvKWh * 100) / 100,
|
||||||
|
estimatedEvCostJOD: Math.round(estimatedEvCostJOD * 100) / 100,
|
||||||
energyRecoveredEvKWh: Math.round(regenEvKWh * 100) / 100,
|
energyRecoveredEvKWh: Math.round(regenEvKWh * 100) / 100,
|
||||||
co2Kg: Math.round((co2Grams / 1000) * 100) / 100,
|
co2Kg: Math.round((co2Grams / 1000) * 100) / 100,
|
||||||
trafficDelayMinutes: Math.round(delayHours * 60),
|
trafficDelayMinutes: Math.round(delayHours * 60),
|
||||||
ecoScore,
|
ecoScore,
|
||||||
ecoBadge: ecoBadgeArabic,
|
ecoBadge: ecoBadgeArabic,
|
||||||
terrainDifficulty: terrainDifficultyArabic,
|
terrainDifficulty: terrainDifficultyArabic,
|
||||||
mechanicalAdvice: mechanicalAdviceArabic
|
mechanicalAdvice: mechanicalAdviceArabic,
|
||||||
|
pricingBulletin: {
|
||||||
|
gasoline90JOD: livePrices.gasoline90,
|
||||||
|
dieselJOD: livePrices.diesel,
|
||||||
|
effectiveMonth: livePrices.effectiveMonth,
|
||||||
|
source: livePrices.source,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -449,6 +449,12 @@ function App() {
|
|||||||
💡 {routeData.ecoMetrics.mechanicalAdvice}
|
💡 {routeData.ecoMetrics.mechanicalAdvice}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{routeData.ecoMetrics.pricingBulletin && (
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#64748b', marginTop: 5, display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>📅 تسعيرة {routeData.ecoMetrics.pricingBulletin.effectiveMonth}</span>
|
||||||
|
<span>بنزين 90: {routeData.ecoMetrics.pricingBulletin.gasoline90JOD} د.أ/لتر</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user