Files
2026-08-09 16:56:13 +03:00

345 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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";