345 lines
16 KiB
PHP
345 lines
16 KiB
PHP
<?php
|
||
/**
|
||
* cron_ai_engine.php - AI Pricing Engine v2
|
||
*
|
||
* يقرأ معادلات المنافسين من Node.js Pricing Engine (competitor_secret_formulas)
|
||
* ويطبّق تسعيراً ذكياً بناءً على النتائج الإحصائية بدلاً من الحسابات البدائية.
|
||
*
|
||
* لم يعُد هذا الملف يحسب بنفسه - بل يعتمد على التحليل الإحصائي من pricing-engine.
|
||
*/
|
||
|
||
require_once __DIR__ . '/../core/bootstrap.php';
|
||
require_once __DIR__ . '/../functions.php';
|
||
require_once __DIR__ . '/../ride/pricing/pricing_helper.php';
|
||
|
||
try {
|
||
$con = Database::get('main');
|
||
$redis = getRedisConnection();
|
||
} catch (Exception $e) {
|
||
die("Connection failed: " . $e->getMessage() . "\n");
|
||
}
|
||
|
||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||
// كل مخرجات هذا المحرّك تُكتب في Redis (مقارنة السائقين، ملخّص ذروة
|
||
// المنافسين، المناطق الساخنة)، فلا معنى لمتابعة التشغيل بدونه.
|
||
if (!$redis) {
|
||
error_log('[AI Engine] ABORT: Redis unavailable — no cache target to write to.');
|
||
die("Redis unavailable — aborting AI engine run.\n");
|
||
}
|
||
|
||
echo "Starting Siro AI Engine v2 (Powered by Pricing Engine)...\n";
|
||
|
||
// ==========================================
|
||
// 0. جدول افتراضات عمولة المنافسين (لميزة "أرباحك أعلى" للسائق)
|
||
// ==========================================
|
||
$con->exec("
|
||
CREATE TABLE IF NOT EXISTS `competitor_commission_assumptions` (
|
||
`country_code` VARCHAR(5) NOT NULL,
|
||
`competitor_name` VARCHAR(64) NOT NULL,
|
||
`commission_pct` DECIMAL(5,2) NOT NULL,
|
||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (`country_code`, `competitor_name`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
");
|
||
|
||
// زرع أولي بأرقام حقيقية زوّدنا فيها — INSERT IGNORE عشان ما نبهدل أي تعديل يدوي لاحق من الأدمن
|
||
$commissionSeed = [
|
||
// الأردن — كل التطبيقات المذكورة أعطتنا نفس النسبة 23%
|
||
['JO', 'com.careem.ae', 23.0],
|
||
['JO', 'com.ubercab', 23.0],
|
||
['JO', 'com.jeeny.app', 23.0],
|
||
['JO', 'com.taxif.passenger', 23.0],
|
||
['JO', 'gogo', 23.0],
|
||
['JO', 'petra_ride', 23.0],
|
||
// سوريا
|
||
['SY', 'yallago', 20.0],
|
||
['SY', 'zaken', 17.0],
|
||
['SY', 'tufaddal', 15.0],
|
||
// مصر — استخدمنا الطرف الأدنى من كل مجال أعطانا إياه المستخدم (تحفظاً)
|
||
['EG', 'uber', 25.0],
|
||
['EG', 'careem', 20.0],
|
||
['EG', 'didi', 15.0],
|
||
['EG', 'indrive', 14.0],
|
||
];
|
||
$seedStmt = $con->prepare("
|
||
INSERT IGNORE INTO competitor_commission_assumptions (country_code, competitor_name, commission_pct)
|
||
VALUES (:cc, :name, :pct)
|
||
");
|
||
foreach ($commissionSeed as [$cc, $name, $pct]) {
|
||
$seedStmt->execute([':cc' => $cc, ':name' => $name, ':pct' => $pct]);
|
||
}
|
||
|
||
// ==========================================
|
||
// 1. التسعير الديناميكي بناءً على المعادلات الإحصائية
|
||
// ==========================================
|
||
echo "1. Reading competitor formulas from Pricing Engine...\n";
|
||
try {
|
||
// قراءة آخر المعادلات من التحليل الإحصائي
|
||
$sql = "SELECT * FROM competitor_secret_formulas
|
||
WHERE last_updated >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
|
||
ORDER BY last_updated DESC";
|
||
$stmt = $con->query($sql);
|
||
$formulas = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
if (empty($formulas)) {
|
||
echo " ⚠️ No recent formulas found. Run pricing-engine first.\n";
|
||
} else {
|
||
// تجميع المعادلات حسب الدولة
|
||
$byCountry = [];
|
||
foreach ($formulas as $f) {
|
||
$country = $f['country_code'];
|
||
if (!isset($byCountry[$country])) $byCountry[$country] = [];
|
||
$byCountry[$country][] = $f;
|
||
}
|
||
|
||
foreach ($byCountry as $country => $countryFormulas) {
|
||
$countryNameMap = ['JO' => 'Jordan', 'SY' => 'Syria', 'EG' => 'Egypt', 'IQ' => 'Iraq'];
|
||
$countryName = $countryNameMap[$country] ?? $country;
|
||
|
||
// اختيار أفضل معادلة للتسعير حسب الأولوية:
|
||
// 1. Economy tier (الأكثر تنافسية)
|
||
// 2. Standard tier (إذا ما في Economy)
|
||
// 3. أي tier ثاني بأعلى R²
|
||
$tierPriority = ['economy', 'standard', 'premium'];
|
||
$bestFormula = null;
|
||
$bestTierIdx = 999;
|
||
|
||
foreach ($countryFormulas as $f) {
|
||
$tier = $f['tier'] ?? 'standard';
|
||
$tierIdx = array_search($tier, $tierPriority);
|
||
if ($tierIdx === false) $tierIdx = 999;
|
||
|
||
$currentRSq = (float)($f['r_squared'] ?? 0);
|
||
$currentKm = (float)($f['price_per_km'] ?? 0);
|
||
|
||
// أفضلية: Tier أعلى أولوية، ثم R² أعلى
|
||
if ($currentKm > 0 && (
|
||
$bestFormula === null ||
|
||
$tierIdx < $bestTierIdx ||
|
||
($tierIdx === $bestTierIdx && $currentRSq > (float)($bestFormula['r_squared'] ?? 0))
|
||
)) {
|
||
$bestTierIdx = $tierIdx;
|
||
$bestFormula = $f;
|
||
}
|
||
}
|
||
|
||
$bestTier = null;
|
||
if ($bestFormula) {
|
||
$bestKmRate = (float)$bestFormula['price_per_km'];
|
||
$bestMinRate = (float)$bestFormula['price_per_min'];
|
||
$bestBaseFare = (float)$bestFormula['base_fare'];
|
||
$bestMinFare = (float)$bestFormula['min_fare'];
|
||
$bestRSq = (float)($bestFormula['r_squared'] ?? 0);
|
||
$bestTier = $bestFormula['tier'] ?? 'standard';
|
||
}
|
||
|
||
if ($bestKmRate === null) {
|
||
echo " ⚠️ No valid formula for $countryName. Skipping.\n";
|
||
continue;
|
||
}
|
||
|
||
// تسعير Siro: أرخص بنسبة 6.5% من المنافس مع الحفاظ على هيكل التسعير
|
||
$discountFactor = 1 - 0.065;
|
||
$ourKmRate = round($bestKmRate * $discountFactor, 3);
|
||
$ourMinRate = round($bestMinRate * $discountFactor, 3);
|
||
$ourBase = round($bestBaseFare * $discountFactor, 3);
|
||
|
||
echo " 📊 $countryName: Using formula [tier=$bestTier] (R²=$bestRSq)\n";
|
||
echo " Competitor: KM=$bestKmRate, MIN=$bestMinRate, Base=$bestBaseFare, MinFare=$bestMinFare\n";
|
||
echo " Siro (6.5% less): KM=$ourKmRate, MIN=$ourMinRate, Base=$ourBase\n";
|
||
|
||
// تحديث جدول kazan
|
||
$speedPrice = $ourKmRate;
|
||
$comfortPrice = round($ourKmRate * 1.30, 3);
|
||
$ladyPrice = round($ourKmRate * 1.10, 3);
|
||
$electricPrice = round($ourKmRate * 1.20, 3);
|
||
$vanPrice = round($ourKmRate * 1.50, 3);
|
||
$deliveryPrice = round($ourKmRate * 0.90, 3);
|
||
$mishwarVipPrice = round($ourKmRate * 1.40, 3);
|
||
$fixedPrice = $speedPrice;
|
||
$awfarPrice = round($ourKmRate * 0.85, 3);
|
||
|
||
// أسعار الدقائق
|
||
if ($country === 'JO') {
|
||
$normalMin = $ourMinRate > 0 ? $ourMinRate : 0.05;
|
||
$peakMin = round($normalMin * 1.15, 3);
|
||
$lateMin = $normalMin;
|
||
} else {
|
||
$normalMin = $ourMinRate > 0 ? $ourMinRate : round($speedPrice / 4, 3);
|
||
$peakMin = round($normalMin * 1.15, 3);
|
||
$lateMin = round($normalMin * 1.25, 3);
|
||
}
|
||
|
||
$updateSql = "UPDATE kazan
|
||
SET speedPrice = :speedPrice,
|
||
comfortPrice = :comfortPrice,
|
||
ladyPrice = :ladyPrice,
|
||
electricPrice = :electricPrice,
|
||
vanPrice = :vanPrice,
|
||
deliveryPrice = :deliveryPrice,
|
||
mishwarVipPrice = :mishwarVipPrice,
|
||
fixedPrice = :fixedPrice,
|
||
awfarPrice = :awfarPrice,
|
||
normalMinPrice = :normalMin,
|
||
peakMinPrice = :peakMin,
|
||
lateMinPrice = :lateMin,
|
||
startPrice = :startPrice
|
||
WHERE country = :countryName";
|
||
|
||
$upStmt = $con->prepare($updateSql);
|
||
$upStmt->execute([
|
||
':speedPrice' => $speedPrice,
|
||
':comfortPrice' => $comfortPrice,
|
||
':ladyPrice' => $ladyPrice,
|
||
':electricPrice' => $electricPrice,
|
||
':vanPrice' => $vanPrice,
|
||
':deliveryPrice' => $deliveryPrice,
|
||
':mishwarVipPrice' => $mishwarVipPrice,
|
||
':fixedPrice' => $fixedPrice,
|
||
':awfarPrice' => $awfarPrice,
|
||
':normalMin' => $normalMin,
|
||
':peakMin' => $peakMin,
|
||
':lateMin' => $lateMin,
|
||
':startPrice' => $ourBase,
|
||
':countryName' => $countryName
|
||
]);
|
||
|
||
echo " ✅ Updated $countryName pricing in kazan table.\n";
|
||
|
||
// ── تخزين معاملات المنافس الحالي بـ Redis لميزة "أرباحك أعلى" للسائق ──
|
||
// get.php / add_ride.php يقرأوا هذا المفتاح فقط (بدون أي استعلام DB
|
||
// وقت إنشاء الرحلة) عبر estimateDriverEarningsAdvantage()
|
||
try {
|
||
$commStmt = $con->prepare("
|
||
SELECT commission_pct FROM competitor_commission_assumptions
|
||
WHERE country_code = :cc AND competitor_name = :name LIMIT 1
|
||
");
|
||
$commStmt->execute([':cc' => $country, ':name' => $bestFormula['competitor_name']]);
|
||
$commissionPct = (float)($commStmt->fetchColumn() ?: 20.0);
|
||
|
||
$driverComparisonData = [
|
||
'competitor_name' => $bestFormula['competitor_name'],
|
||
'base_fare' => $bestBaseFare,
|
||
'km_rate' => $bestKmRate,
|
||
'min_rate' => $bestMinRate,
|
||
'commission_pct' => $commissionPct,
|
||
'currency' => getCurrencyByCountry($countryName),
|
||
'updated_at' => date('Y-m-d H:i:s'),
|
||
];
|
||
// TTL أطول من فترة الجدولة (3 ساعات) بهامش أمان
|
||
$redis->setex("pricing:driver_comparison:{$country}", 14400, json_encode($driverComparisonData));
|
||
echo " 💰 Cached driver-comparison data for $countryName (commission assumed: {$commissionPct}%)\n";
|
||
} catch (Exception $e) {
|
||
echo " ⚠️ Failed to cache driver-comparison data: " . $e->getMessage() . "\n";
|
||
}
|
||
}
|
||
}
|
||
} catch (Exception $e) {
|
||
echo " ❌ Error in Pricing Module: " . $e->getMessage() . "\n";
|
||
}
|
||
|
||
// ==========================================
|
||
// 2. قراءة Surge Insights من الـ Pricing Engine
|
||
// ==========================================
|
||
echo "2. Reading surge insights from Pricing Engine...\n";
|
||
try {
|
||
$sql = "SELECT * FROM competitor_surge_insights
|
||
WHERE detected_at >= DATE_SUB(NOW(), INTERVAL 12 HOUR)
|
||
ORDER BY surge_multiplier DESC
|
||
LIMIT 20";
|
||
$stmt = $con->query($sql);
|
||
$surgeRecords = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
if (!empty($surgeRecords)) {
|
||
$surgeByCountry = [];
|
||
foreach ($surgeRecords as $sr) {
|
||
$country = $sr['country_code'];
|
||
if (!isset($surgeByCountry[$country])) {
|
||
$surgeByCountry[$country] = [
|
||
'avg_multiplier' => 0,
|
||
'count' => 0,
|
||
'peak_hours' => []
|
||
];
|
||
}
|
||
$surgeByCountry[$country]['avg_multiplier'] += $sr['surge_multiplier'];
|
||
$surgeByCountry[$country]['count']++;
|
||
$surgeByCountry[$country]['peak_hours'][] = "{$sr['peak_start_hour']}:00-{$sr['peak_end_hour']}:00";
|
||
}
|
||
|
||
foreach ($surgeByCountry as $country => $data) {
|
||
$avgMult = round($data['avg_multiplier'] / $data['count'], 3);
|
||
$peakHoursStr = implode(', ', array_unique($data['peak_hours']));
|
||
|
||
// تخزين ملخص إحصائي (object) على مفتاح خاص ومستقل — لا نكتب على
|
||
// surge:opportunities / surge:opportunities:{country} لأنها خرائط
|
||
// grid_id → multiplier يملأها ويقرأها نظام آخر بالكامل
|
||
// (cron_surge_opportunity.php / get.php / get_surge_heatmap.php /
|
||
// winback_hotspot_targets.php / cron_kazan_adjuster.php). كتابة
|
||
// object مسطّح على نفس المفتاح كانت تبهدل تلك الخريطة كل 3 ساعات.
|
||
$surgeData = [
|
||
'avg_competitor_surge' => $avgMult,
|
||
'suggested_multiplier' => round(1.0 + ($avgMult - 1.0) * 0.6, 3),
|
||
'peak_hours' => $data['peak_hours'],
|
||
'updated_at' => date('Y-m-d H:i:s')
|
||
];
|
||
$redis->setex("pricing_engine:competitor_surge_summary:{$country}", 7200, json_encode($surgeData));
|
||
|
||
echo " ⚡ $country: Avg competitor surge = {$avgMult}x, peak hours: {$peakHoursStr}\n";
|
||
}
|
||
} else {
|
||
echo " ℹ️ No recent surge insights found.\n";
|
||
}
|
||
} catch (Exception $e) {
|
||
echo " ❌ Error in Surge Module: " . $e->getMessage() . "\n";
|
||
}
|
||
|
||
// ==========================================
|
||
// 3. وحدة استهداف الركاب الخاملين (Smart Retention) - كما هي
|
||
// ==========================================
|
||
echo "3. Running Smart Retention Module...\n";
|
||
try {
|
||
$sql = "SELECT source, COUNT(*) as opens
|
||
FROM passenger_opening_locations
|
||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 HOUR)
|
||
GROUP BY source
|
||
HAVING opens >= 3";
|
||
|
||
$stmt = $con->query($sql);
|
||
$idleRiders = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||
$notifiedCount = count($idleRiders);
|
||
echo " 📱 Identified $notifiedCount idle riders requiring push notifications.\n";
|
||
} catch (Exception $e) {
|
||
echo " ❌ Error in Smart Retention: " . $e->getMessage() . "\n";
|
||
}
|
||
|
||
// ==========================================
|
||
// 4. Export AI Hotzones to Redis for Captains
|
||
// ==========================================
|
||
echo "4. Exporting AI Hotzones to Redis...\n";
|
||
try {
|
||
$sql = "SELECT latitude, longitude, competitor_name AS top_competitor, avg_ppk AS avg_price
|
||
FROM competitor_surge_zones
|
||
WHERE detected_at >= DATE_SUB(NOW(), INTERVAL 6 HOUR)
|
||
AND surge_multiplier > 1.1";
|
||
|
||
$stmt = $con->query($sql);
|
||
$zones = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
if (!empty($zones)) {
|
||
$redisData = [
|
||
'status' => 'success',
|
||
'data' => $zones
|
||
];
|
||
$redis->setex('siro:cache:ai:hotzones', 3600, json_encode($redisData));
|
||
echo " 🗺️ Exported " . count($zones) . " hot zones to Redis (siro:cache:ai:hotzones).\n";
|
||
} else {
|
||
// Clear if no surge to avoid stale data
|
||
$redis->del('siro:cache:ai:hotzones');
|
||
echo " ℹ️ No active hot zones to export.\n";
|
||
}
|
||
} catch (Exception $e) {
|
||
echo " ❌ Error in Hotzones Export: " . $e->getMessage() . "\n";
|
||
}
|
||
|
||
echo "AI Engine v2 finished successfully.\n";
|