feat: استيراد كود سيرو إلى تريبز (سيرو @ecfe7568) — بلا تعديل
قرار المالك 2026-07-27: باك إند سيرو PHP هو المعتمد، وتطبيقاته المجرّبة ميدانياً تحل محل إعادة البناء المؤرشفة. سيرو نفسه لم يُمسّ. الخريطة: backend · payment_server · loction_server · ride_server · passenger_server · docker · dashboard · stress_test → الجذر siro_rider → apps/rider siro_driver → apps/driver siro_admin → dashboards/admin siro_service → dashboards/service android_bot → apps/android_bot socialBot → apps/socialBot نُسخ المتعقَّب في git سيرو فقط عبر `git archive` (3,198 ملفاً / ~169 م.ب) لا `cp -r` — فاستُثنيت مخلفات البناء تلقائياً. بلا أي تعديل محتوى عمداً: كل ما يلي يصير فرقاً مقروءاً مقابل المصدر. لم يُستورد وسببه: siromove.com (الموقع التسويقي يبقى marketing/ في تريبز، سيرو فيه 8 ملفات) · docs و planning (تريبز له docs/ الخاص) · deploy.sh (ليس نشراً على سيرفر بل `git add . && git push origin --all` — فخّ في مستودع آخر) · transit_dashboard (بانتظار قرار مصير backend-transit و dashboards/transit-web). ⚠️ لا يبني بعد — ثلاثة نواقص متوقعة ومقصودة: 1. `.env` و `lib/env/env.g.dart` غير متعقَّبين في سيرو (أسرار لكل مستأجر): كل تطبيق فلاتر يحتاج .env خاصاً ثم توليد env.g.dart بـ build_runner. 2. إعدادات Firebase (9 ملفات google-services.json و GoogleService-Info.plist) يستبعدها .gitignore تريبز — ولكل مستأجر مشروع Firebase خاص أصلاً. 3. apps/driver في سيرو يشير إلى `../../Intaleq/packages/get` خارج المستودع → يجب ضمّ الحزم داخله أسوة بـ apps/rider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9909d9b4c1
commit
4d8414c96b
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed\n");
|
||||
}
|
||||
|
||||
$sql = "ALTER TABLE kazan ADD COLUMN startPrice varchar(10) NOT NULL DEFAULT '0' AFTER currency";
|
||||
try {
|
||||
$con->exec($sql);
|
||||
echo "Successfully added startPrice to kazan table.\n";
|
||||
} catch (PDOException $e) {
|
||||
echo "Error adding column: " . $e->getMessage() . "\n";
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
/**
|
||||
* ai_formula_solver.php
|
||||
* مكتشف خوارزميات المنافسين (AI Competitor Formula Solver)
|
||||
* يستخدم الانحدار الخطي المتعدد (Multiple Linear Regression) لاكتشاف
|
||||
* أجرة فتح العداد، وسعر الكيلومتر، وسعر الدقيقة لكل تطبيق منافس.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Starting AI Formula Discovery Engine...\n";
|
||||
|
||||
// نجلب التطبيقات التي لديها بيانات (مسافة ووقت وسعر)
|
||||
$sqlApps = "SELECT DISTINCT competitor_name, country_code
|
||||
FROM scraped_competitor_prices
|
||||
WHERE distance_km > 0 AND duration_min > 0 AND price_amount > 0";
|
||||
$stmtApps = $con->query($sqlApps);
|
||||
$apps = $stmtApps->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($apps)) {
|
||||
echo "No sufficient data (Distance/Duration) found to perform regression.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// دالة لحل نظام معادلات خطية (Gaussian Elimination)
|
||||
function solveLinearSystem($A, $B) {
|
||||
$n = count($A);
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
// Search for maximum in this column
|
||||
$maxEl = abs($A[$i][$i]);
|
||||
$maxRow = $i;
|
||||
for ($k = $i + 1; $k < $n; $k++) {
|
||||
if (abs($A[$k][$i]) > $maxEl) {
|
||||
$maxEl = abs($A[$k][$i]);
|
||||
$maxRow = $k;
|
||||
}
|
||||
}
|
||||
|
||||
// Swap maximum row with current row
|
||||
for ($k = $i; $k < $n; $k++) {
|
||||
$tmp = $A[$maxRow][$k];
|
||||
$A[$maxRow][$k] = $A[$i][$k];
|
||||
$A[$i][$k] = $tmp;
|
||||
}
|
||||
$tmp = $B[$maxRow];
|
||||
$B[$maxRow] = $B[$i];
|
||||
$B[$i] = $tmp;
|
||||
|
||||
// Make all rows below this one 0 in current column
|
||||
for ($k = $i + 1; $k < $n; $k++) {
|
||||
if ($A[$i][$i] == 0) continue;
|
||||
$c = -$A[$k][$i] / $A[$i][$i];
|
||||
for ($j = $i; $j < $n; $j++) {
|
||||
if ($i == $j) {
|
||||
$A[$k][$j] = 0;
|
||||
} else {
|
||||
$A[$k][$j] += $c * $A[$i][$j];
|
||||
}
|
||||
}
|
||||
$B[$k] += $c * $B[$i];
|
||||
}
|
||||
}
|
||||
|
||||
// Solve equation Ax=b for an upper triangular matrix A
|
||||
$x = array_fill(0, $n, 0);
|
||||
for ($i = $n - 1; $i >= 0; $i--) {
|
||||
if ($A[$i][$i] == 0) continue;
|
||||
$x[$i] = $B[$i] / $A[$i][$i];
|
||||
for ($k = $i - 1; $k >= 0; $k--) {
|
||||
$B[$k] -= $A[$k][$i] * $x[$i];
|
||||
}
|
||||
}
|
||||
return $x;
|
||||
}
|
||||
|
||||
foreach ($apps as $app) {
|
||||
$competitor = $app['competitor_name'];
|
||||
$countryCode = $app['country_code'];
|
||||
|
||||
echo "Analyzing: $competitor ($countryCode)...\n";
|
||||
|
||||
// سحب أحدث 5000 رحلة لتكوين نموذج رياضي دقيق
|
||||
$sqlData = "SELECT distance_km, duration_min, price_amount
|
||||
FROM scraped_competitor_prices
|
||||
WHERE competitor_name = :comp
|
||||
AND country_code = :country
|
||||
AND distance_km > 0 AND duration_min > 0 AND price_amount > 0
|
||||
ORDER BY id DESC LIMIT 5000";
|
||||
$stmtData = $con->prepare($sqlData);
|
||||
$stmtData->execute([':comp' => $competitor, ':country' => $countryCode]);
|
||||
$samples = $stmtData->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$N = count($samples);
|
||||
if ($N < 10) {
|
||||
echo " -> Not enough samples ($N). Skipping.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// بناء مصفوفات Least Squares (X^T X) * Beta = (X^T Y)
|
||||
// Beta = [Base_Fare, KM_Price, Min_Price]
|
||||
$sum_x1 = 0; $sum_x2 = 0; $sum_y = 0;
|
||||
$sum_x1_sq = 0; $sum_x2_sq = 0; $sum_x1_x2 = 0;
|
||||
$sum_x1_y = 0; $sum_x2_y = 0;
|
||||
|
||||
foreach ($samples as $s) {
|
||||
$x1 = (float)$s['distance_km'];
|
||||
$x2 = (float)$s['duration_min'];
|
||||
$y = (float)$s['price_amount'];
|
||||
|
||||
$sum_x1 += $x1;
|
||||
$sum_x2 += $x2;
|
||||
$sum_y += $y;
|
||||
|
||||
$sum_x1_sq += ($x1 * $x1);
|
||||
$sum_x2_sq += ($x2 * $x2);
|
||||
$sum_x1_x2 += ($x1 * $x2);
|
||||
|
||||
$sum_x1_y += ($x1 * $y);
|
||||
$sum_x2_y += ($x2 * $y);
|
||||
}
|
||||
|
||||
$matrixA = [
|
||||
[$N, $sum_x1, $sum_x2],
|
||||
[$sum_x1, $sum_x1_sq, $sum_x1_x2],
|
||||
[$sum_x2, $sum_x1_x2, $sum_x2_sq]
|
||||
];
|
||||
|
||||
$matrixB = [
|
||||
$sum_y,
|
||||
$sum_x1_y,
|
||||
$sum_x2_y
|
||||
];
|
||||
|
||||
// حل المصفوفة
|
||||
try {
|
||||
$beta = solveLinearSystem($matrixA, $matrixB);
|
||||
|
||||
$baseFare = round(max(0, $beta[0]), 3); // Base fare cannot be negative
|
||||
$kmPrice = round(max(0, $beta[1]), 3);
|
||||
$minPrice = round(max(0, $beta[2]), 3);
|
||||
|
||||
echo " -> [DISCOVERED] Base Fare: $baseFare, KM: $kmPrice, Min: $minPrice\n";
|
||||
|
||||
// حفظ في جدول المعادلات السرية
|
||||
$sqlUpsert = "INSERT INTO competitor_secret_formulas
|
||||
(competitor_name, country_code, base_fare, price_per_km, price_per_min, sample_size)
|
||||
VALUES (:comp, :country, :base, :km, :min, :size)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
base_fare = :base, price_per_km = :km, price_per_min = :min, sample_size = :size, last_updated = NOW()";
|
||||
|
||||
$stmtUp = $con->prepare($sqlUpsert);
|
||||
$stmtUp->execute([
|
||||
':comp' => $competitor,
|
||||
':country' => $countryCode,
|
||||
':base' => $baseFare,
|
||||
':km' => $kmPrice,
|
||||
':min' => $minPrice,
|
||||
':size' => $N
|
||||
]);
|
||||
|
||||
echo " -> Saved successfully.\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo " -> Error solving matrix: " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
?>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed\n");
|
||||
}
|
||||
|
||||
$stmt = $con->query("SHOW COLUMNS FROM kazan");
|
||||
$cols = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo "Columns in kazan table:\n";
|
||||
foreach ($cols as $col) {
|
||||
echo "- " . $col['Field'] . "\n";
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<?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");
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_auto_marketing_pusher.php
|
||||
* الكرون جوب النهائي لإغلاق حلقة "أتمتة التسويق"
|
||||
*
|
||||
* الوظيفة:
|
||||
* 1. استخراج الركاب الخاملين (فتحوا التطبيق في آخر ساعتين ولم يطلبوا)
|
||||
* 2. طلب رسالة تسويقية ذكية ومغرية من Gemini مبنية على الأسعار الحالية
|
||||
* 3. إرسال الرسالة آلياً كـ Push Notification للركاب لدفعهم للطلب فوراً
|
||||
*
|
||||
* يُنصح بتشغيل هذا الملف كل ساعة أو كل ساعتين.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../core/Services/SiroGeminiService.php';
|
||||
require_once __DIR__ . '/../core/Security/EncryptionHelper.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
set_time_limit(0);
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
$conRide = Database::get('ride');
|
||||
$redis = getRedisConnection();
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Starting Auto Marketing Pusher Engine...\n";
|
||||
|
||||
// 1. استخراج الركاب الخاملين في آخر ساعتين (الذين لم يقموا بأي طلب اليوم)
|
||||
// First, fetch active passenger IDs from the ride database
|
||||
$stmtRide = $conRide->query("SELECT DISTINCT passenger_id FROM ride WHERE DATE(created_at) = CURDATE()");
|
||||
$activePassengerIds = $stmtRide->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
if (!empty($activePassengerIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($activePassengerIds), '?'));
|
||||
$notInSql = "WHERE p.id NOT IN ($placeholders) AND p.status = 'notDeleted'";
|
||||
} else {
|
||||
$notInSql = "WHERE p.status = 'notDeleted'";
|
||||
}
|
||||
|
||||
// Then, fetch idle passengers from the main database
|
||||
$sqlIdle = "
|
||||
SELECT p.id as passenger_id, t.token, IFNULL(MAX(pol.country_code), 'JO') as country_code
|
||||
FROM passengers p
|
||||
JOIN tokens t ON p.id = t.passengerID
|
||||
LEFT JOIN passenger_opening_locations pol ON pol.passenger_id = p.id
|
||||
$notInSql
|
||||
GROUP BY p.id, t.token
|
||||
LIMIT 1000
|
||||
";
|
||||
|
||||
$stmtIdle = $con->prepare($sqlIdle);
|
||||
$stmtIdle->execute($activePassengerIds);
|
||||
$idlePassengers = $stmtIdle->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($idlePassengers)) {
|
||||
echo "No idle passengers found at this moment.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// تجميع الركاب حسب الدولة لتقليل استدعاءات Gemini
|
||||
$passengersByCountry = [];
|
||||
foreach ($idlePassengers as $p) {
|
||||
$country = strtoupper($p['country_code'] ?? 'SY');
|
||||
$passengersByCountry[$country][] = $p;
|
||||
}
|
||||
|
||||
$geminiService = new SiroGeminiService();
|
||||
$encryptionHelper = new EncryptionHelper();
|
||||
|
||||
$totalPushes = 0;
|
||||
|
||||
foreach ($passengersByCountry as $countryCode => $passengers) {
|
||||
echo "Processing country: $countryCode (" . count($passengers) . " idle passengers)\n";
|
||||
|
||||
// 2. سحب أحدث أسعار المنافسين كمرجع لجيميناي
|
||||
$sqlPrices = "SELECT competitor_name, base_fare, price_per_km, price_per_min
|
||||
FROM competitor_secret_formulas
|
||||
WHERE country_code = :country
|
||||
ORDER BY last_updated DESC LIMIT 3";
|
||||
$stmtPrices = $con->prepare($sqlPrices);
|
||||
$stmtPrices->execute([':country' => $countryCode]);
|
||||
$competitorFormulas = $stmtPrices->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($competitorFormulas)) {
|
||||
// Fallback
|
||||
$competitorFormulas = [['competitor_name' => 'Market Average', 'base_fare' => 1]];
|
||||
}
|
||||
|
||||
// 3. طلب رسالة تسويقية من Gemini
|
||||
$siroBasePrice = 1.0; // يمكن استخراج السعر الافتراضي لسيرو من جدول kazan
|
||||
$regionName = ($countryCode === 'JO') ? 'Amman' : 'Damascus';
|
||||
|
||||
$aiCampaign = $geminiService->analyzeMarketAndDraftCampaign(
|
||||
$competitorFormulas,
|
||||
$siroBasePrice,
|
||||
$regionName,
|
||||
$countryCode
|
||||
);
|
||||
|
||||
if (!$aiCampaign || $aiCampaign['opportunity_detected'] !== true) {
|
||||
echo " -> Gemini found no compelling marketing opportunity for $countryCode right now. Skipping push.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = $aiCampaign['push_title'] ?? 'خصم حصري من سيرو!';
|
||||
$body = $aiCampaign['push_body'] ?? 'سيارتك جاهزة وبأرخص سعر في السوق. اطلب الآن.';
|
||||
$promoCode = $aiCampaign['promo_code'] ?? '';
|
||||
|
||||
echo " -> Gemini generated campaign: [$title] $body\n";
|
||||
|
||||
// 4. إرسال الـ Push Notification لكل راكب
|
||||
foreach ($passengers as $p) {
|
||||
$passengerId = $p['passenger_id'];
|
||||
$encryptedToken = $p['token'];
|
||||
$decryptedToken = $encryptionHelper->decryptData($encryptedToken);
|
||||
|
||||
if (!$decryptedToken) continue;
|
||||
|
||||
// ── Anti-spam: لا يُرسَل لنفس الراكب أكثر من مرة/24 ساعة ──
|
||||
$redisKey = "marketing_push:sent:{$passengerId}";
|
||||
if ($redis && $redis->exists($redisKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dataPayload = [
|
||||
'type' => 'marketing_promo',
|
||||
'promo_code' => $promoCode,
|
||||
'screen' => 'home',
|
||||
];
|
||||
|
||||
// ── إرسال FCM الفعلي ──────────────────────────────────────
|
||||
sendFCM_Internal(
|
||||
$decryptedToken,
|
||||
$title,
|
||||
$body,
|
||||
$dataPayload,
|
||||
'Marketing',
|
||||
false,
|
||||
'ding'
|
||||
);
|
||||
|
||||
// ── تسجيل anti-spam في Redis (TTL 24 ساعة) ───────────────
|
||||
if ($redis) {
|
||||
$redis->setex($redisKey, 86400, '1');
|
||||
}
|
||||
|
||||
// ── حفظ في جدول notifications الداخلي ────────────────────
|
||||
try {
|
||||
$ins = $con->prepare(
|
||||
"INSERT INTO notifications (title, body, passenger_id) VALUES (:t, :b, :pid)"
|
||||
);
|
||||
$ins->execute([':t' => $title, ':b' => $body, ':pid' => $passengerId]);
|
||||
} catch (Exception $ignored) {}
|
||||
|
||||
$totalPushes++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Done. Sent $totalPushes automated marketing pushes via Gemini AI.\n";
|
||||
?>
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_dashboard_snapshot.php
|
||||
* يجمع بيانات التحليلات من Redis وقاعدة البيانات ويخزنها كملفات JSON مؤرشفة.
|
||||
*
|
||||
* الجدولة المقترحة:
|
||||
* - لقطة سريعة (ساعي): 0 * * * * php cron_dashboard_snapshot.php hourly
|
||||
* - لقطة يومية: 0 3 * * * php cron_dashboard_snapshot.php daily
|
||||
* - لقطة أسبوعية: 0 4 * * 1 php cron_dashboard_snapshot.php weekly
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
set_time_limit(180);
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
$mode = $argv[1] ?? 'hourly';
|
||||
$today = date('Y-m-d');
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$cacheBase = __DIR__ . '/../cache/analytics';
|
||||
$todayDir = "$cacheBase/$today";
|
||||
|
||||
if (!is_dir($todayDir)) {
|
||||
mkdir($todayDir, 0755, true);
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
$conRide = Database::get('ride');
|
||||
$redis = getRedisConnection();
|
||||
} catch (Exception $e) {
|
||||
die("[DashboardSnapshot] Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "[DashboardSnapshot] Mode: $mode | $now\n";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// الوظائف المساعدة
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function saveSnapshot(string $filename, array $data, string $dir): void {
|
||||
$data['_generated_at'] = date('Y-m-d H:i:s');
|
||||
$data['_mode'] = $GLOBALS['mode'];
|
||||
$path = "$dir/$filename";
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
echo " ✓ Saved: $path\n";
|
||||
}
|
||||
|
||||
function safeQuery(PDO $db, string $sql, array $params = []): array {
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
function safeScalar(PDO $db, string $sql, array $params = []) {
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 1. لقطة اللحظة الحالية (كل ساعة)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
if (in_array($mode, ['hourly', 'daily', 'weekly'])) {
|
||||
echo "[Snapshot] Realtime stats...\n";
|
||||
|
||||
$activeRides = (int)safeScalar($conRide, "SELECT COUNT(*) FROM ride WHERE status IN ('wait','started','arrived')");
|
||||
$onlineDrivers = (int)safeScalar($con, "SELECT COUNT(*) FROM car_locations WHERE status = 'on'");
|
||||
$revenueToday = (float)safeScalar($conRide, "SELECT IFNULL(SUM(price_for_passenger),0) FROM ride WHERE status='Finished' AND DATE(created_at)=CURDATE()");
|
||||
$revenueYesterday = (float)safeScalar($conRide, "SELECT IFNULL(SUM(price_for_passenger),0) FROM ride WHERE status='Finished' AND DATE(created_at)=DATE_SUB(CURDATE(), INTERVAL 1 DAY)");
|
||||
$openComplaints = (int)safeScalar($con, "SELECT COUNT(*) FROM complaint WHERE statusComplaint='Open'");
|
||||
$expiringLicenses = (int)safeScalar($con, "SELECT COUNT(*) FROM driver WHERE expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 15 DAY)");
|
||||
|
||||
$hour = date('H');
|
||||
saveSnapshot("realtime_{$hour}.json", [
|
||||
'active_rides' => $activeRides,
|
||||
'online_drivers' => $onlineDrivers,
|
||||
'revenue_today' => $revenueToday,
|
||||
'revenue_yesterday' => $revenueYesterday,
|
||||
'open_complaints' => $openComplaints,
|
||||
'expiring_licenses' => $expiringLicenses,
|
||||
], $todayDir);
|
||||
|
||||
// ─── فجوة العرض والطلب (Supply-Demand Gap) ───
|
||||
echo "[Snapshot] Supply-Demand gap...\n";
|
||||
|
||||
$GRID = 0.01;
|
||||
$demandRaw = safeQuery($conRide, "
|
||||
SELECT
|
||||
ROUND(SUBSTRING_INDEX(start_location,',',1) / $GRID) * $GRID AS lat,
|
||||
ROUND(SUBSTRING_INDEX(start_location,',',-1) / $GRID) * $GRID AS lng,
|
||||
COUNT(*) AS demand
|
||||
FROM ride
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR)
|
||||
AND start_location IS NOT NULL AND start_location != ''
|
||||
GROUP BY lat, lng
|
||||
HAVING demand >= 1
|
||||
");
|
||||
|
||||
$supplyRaw = safeQuery($con, "
|
||||
SELECT
|
||||
ROUND(latitude / $GRID) * $GRID AS lat,
|
||||
ROUND(longitude / $GRID) * $GRID AS lng,
|
||||
COUNT(*) AS supply
|
||||
FROM car_locations
|
||||
WHERE status = 'on'
|
||||
AND latitude != 0 AND longitude != 0
|
||||
GROUP BY lat, lng
|
||||
");
|
||||
|
||||
$supplyMap = [];
|
||||
foreach ($supplyRaw as $s) {
|
||||
$key = $s['lat'] . '_' . $s['lng'];
|
||||
$supplyMap[$key] = (int)$s['supply'];
|
||||
}
|
||||
|
||||
$gapCells = [];
|
||||
foreach ($demandRaw as $d) {
|
||||
$key = $d['lat'] . '_' . $d['lng'];
|
||||
$supply = $supplyMap[$key] ?? 0;
|
||||
$demand = (int)$d['demand'];
|
||||
$gap = $demand - $supply;
|
||||
$gapCells[] = [
|
||||
'lat' => (float)$d['lat'],
|
||||
'lng' => (float)$d['lng'],
|
||||
'demand' => $demand,
|
||||
'supply' => $supply,
|
||||
'gap' => $gap,
|
||||
'ratio' => $supply > 0 ? round($demand / $supply, 2) : ($demand > 0 ? 99.0 : 0),
|
||||
];
|
||||
}
|
||||
|
||||
usort($gapCells, fn($a, $b) => $b['gap'] <=> $a['gap']);
|
||||
$gapCells = array_slice($gapCells, 0, 200);
|
||||
|
||||
saveSnapshot("supply_demand_gap.json", [
|
||||
'cells' => $gapCells,
|
||||
'total_demand_cells' => count($demandRaw),
|
||||
'total_supply_cells' => count($supplyRaw),
|
||||
], $todayDir);
|
||||
|
||||
// ─── كاش الخريطة الحرارية من Redis ───
|
||||
echo "[Snapshot] Heatmap cache from Redis...\n";
|
||||
$heatmapRedis = $redis ? $redis->get('siro:cache:heatmap:data') : null;
|
||||
if ($heatmapRedis) {
|
||||
$heatmapData = json_decode($heatmapRedis, true);
|
||||
saveSnapshot("heatmap.json", $heatmapData ?: [], $todayDir);
|
||||
}
|
||||
|
||||
// ─── كاش التسعير من Redis ───
|
||||
echo "[Snapshot] Pricing cache from Redis...\n";
|
||||
$pricingRedis = $redis ? $redis->get('siro:cache:pricing:grids') : null;
|
||||
if ($pricingRedis) {
|
||||
$pricingData = json_decode($pricingRedis, true);
|
||||
saveSnapshot("pricing_grids.json", $pricingData ?: [], $todayDir);
|
||||
}
|
||||
|
||||
// ─── بيانات الكرون Predictive Demand من Redis ───
|
||||
$predictiveRedis = $redis ? $redis->get('siro:predictive_demand:latest') : null;
|
||||
if ($predictiveRedis) {
|
||||
saveSnapshot("predictive_demand.json", json_decode($predictiveRedis, true) ?: [], $todayDir);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 2. لقطة يومية (تحليلات ثقيلة)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
if (in_array($mode, ['daily', 'weekly'])) {
|
||||
echo "[Snapshot] Daily analytics...\n";
|
||||
|
||||
// ─── الإيرادات اليومية (30 يوم) ───
|
||||
$revenueDaily = safeQuery($conRide, "
|
||||
SELECT DATE(created_at) as date,
|
||||
SUM(price) as total_revenue,
|
||||
SUM(price - price_for_driver) as company_profit,
|
||||
COUNT(*) as total_rides,
|
||||
AVG(price) as avg_fare
|
||||
FROM ride
|
||||
WHERE status = 'Finished' AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
");
|
||||
saveSnapshot("revenue_30d.json", ['daily' => $revenueDaily], $todayDir);
|
||||
|
||||
// ─── النمو (ركاب + كباتن) ───
|
||||
$passengerGrowth = safeQuery($con, "
|
||||
SELECT DATE(created_at) as date, COUNT(*) as count
|
||||
FROM passengers
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at) ORDER BY date ASC
|
||||
");
|
||||
$driverGrowth = safeQuery($con, "
|
||||
SELECT DATE(created_at) as date, COUNT(*) as count
|
||||
FROM driver
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at) ORDER BY date ASC
|
||||
");
|
||||
$totalPassengers = (int)safeScalar($con, "SELECT COUNT(*) FROM passengers");
|
||||
$totalDrivers = (int)safeScalar($con, "SELECT COUNT(*) FROM driver");
|
||||
|
||||
saveSnapshot("growth_30d.json", [
|
||||
'passengers' => $passengerGrowth,
|
||||
'drivers' => $driverGrowth,
|
||||
'totals' => ['passengers' => $totalPassengers, 'drivers' => $totalDrivers],
|
||||
], $todayDir);
|
||||
|
||||
// ─── توزيع الرحلات حسب الساعة (لاكتشاف الأنماط) ───
|
||||
$hourlyPattern = safeQuery($conRide, "
|
||||
SELECT HOUR(created_at) as hour, COUNT(*) as rides,
|
||||
AVG(price) as avg_price
|
||||
FROM ride
|
||||
WHERE status = 'Finished' AND created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||
GROUP BY HOUR(created_at) ORDER BY hour
|
||||
");
|
||||
saveSnapshot("hourly_pattern.json", ['hours' => $hourlyPattern], $todayDir);
|
||||
|
||||
// ─── توزيع حالات الرحلات (Funnel) ───
|
||||
$rideFunnel = safeQuery($conRide, "
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM ride
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||
GROUP BY status
|
||||
");
|
||||
saveSnapshot("ride_funnel.json", ['statuses' => $rideFunnel], $todayDir);
|
||||
|
||||
// ─── أسعار المنافسين (تاريخي ٢٤ ساعة) ───
|
||||
$competitorHourly = safeQuery($con, "
|
||||
SELECT DATE_FORMAT(created_at,'%Y-%m-%d %H:00:00') AS hour_bucket,
|
||||
competitor_name,
|
||||
AVG(price_per_km) AS avg_price,
|
||||
COUNT(*) AS samples
|
||||
FROM scraped_competitor_prices
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
|
||||
GROUP BY hour_bucket, competitor_name
|
||||
ORDER BY hour_bucket ASC
|
||||
");
|
||||
saveSnapshot("competitor_prices_24h.json", ['hourly' => $competitorHourly], $todayDir);
|
||||
|
||||
// ─── صحة السوق (market health) ───
|
||||
$countries = ['JO', 'SY', 'EG', 'IQ'];
|
||||
$marketHealth = [];
|
||||
foreach ($countries as $cc) {
|
||||
$rows = safeQuery($con, "
|
||||
SELECT report_date, average_pci, market_share_percent, total_anomalies
|
||||
FROM market_health_reports
|
||||
WHERE country_code = :cc
|
||||
ORDER BY report_date DESC LIMIT 12
|
||||
", [':cc' => $cc]);
|
||||
if (!empty($rows)) {
|
||||
$marketHealth[$cc] = array_reverse($rows);
|
||||
}
|
||||
}
|
||||
saveSnapshot("market_health.json", ['countries' => $marketHealth], $todayDir);
|
||||
|
||||
// ─── شكاوى مفتوحة ───
|
||||
$complaints = safeQuery($con, "
|
||||
SELECT complaint_type, COUNT(*) as count
|
||||
FROM complaint
|
||||
WHERE statusComplaint = 'Open'
|
||||
GROUP BY complaint_type
|
||||
ORDER BY count DESC
|
||||
");
|
||||
saveSnapshot("complaints_open.json", ['by_type' => $complaints], $todayDir);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 3. لقطة أسبوعية (مقارنات وتجميعات)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
if ($mode === 'weekly') {
|
||||
echo "[Snapshot] Weekly deep analytics...\n";
|
||||
|
||||
// ─── مقارنة أسبوع بأسبوع ───
|
||||
$weeklyComparison = safeQuery($conRide, "
|
||||
SELECT
|
||||
YEARWEEK(created_at, 1) as yw,
|
||||
MIN(DATE(created_at)) as week_start,
|
||||
COUNT(*) as rides,
|
||||
SUM(price) as revenue,
|
||||
SUM(price - price_for_driver) as profit,
|
||||
COUNT(DISTINCT driver_id) as active_drivers,
|
||||
COUNT(DISTINCT passenger_id) as active_passengers
|
||||
FROM ride
|
||||
WHERE status = 'Finished' AND created_at >= DATE_SUB(CURDATE(), INTERVAL 12 WEEK)
|
||||
GROUP BY yw ORDER BY yw ASC
|
||||
");
|
||||
saveSnapshot("weekly_comparison.json", ['weeks' => $weeklyComparison], $todayDir);
|
||||
|
||||
// ─── أعلى مناطق (أكثر 20 خلية نشاطاً) ───
|
||||
$topZones = safeQuery($conRide, "
|
||||
SELECT
|
||||
ROUND(SUBSTRING_INDEX(start_location,',',1) / 0.01) * 0.01 AS lat,
|
||||
ROUND(SUBSTRING_INDEX(start_location,',',-1) / 0.01) * 0.01 AS lng,
|
||||
COUNT(*) AS rides,
|
||||
AVG(price) AS avg_price
|
||||
FROM ride
|
||||
WHERE status = 'Finished'
|
||||
AND created_at >= DATE_SUB(CURDATE(), INTERVAL 4 WEEK)
|
||||
AND start_location IS NOT NULL AND start_location != ''
|
||||
GROUP BY lat, lng
|
||||
ORDER BY rides DESC
|
||||
LIMIT 20
|
||||
");
|
||||
saveSnapshot("top_zones.json", ['zones' => $topZones], $todayDir);
|
||||
|
||||
// ─── احتفاظ الركاب (Retention) ───
|
||||
$retention = safeQuery($conRide, "
|
||||
SELECT
|
||||
weeks_since_signup,
|
||||
COUNT(DISTINCT passenger_id) as active_passengers
|
||||
FROM (
|
||||
SELECT r.passenger_id,
|
||||
FLOOR(DATEDIFF(r.created_at, p.created_at) / 7) as weeks_since_signup
|
||||
FROM ride r
|
||||
JOIN passengers p ON r.passenger_id = p.id
|
||||
WHERE r.status = 'Finished'
|
||||
AND r.created_at >= DATE_SUB(CURDATE(), INTERVAL 12 WEEK)
|
||||
) sub
|
||||
GROUP BY weeks_since_signup
|
||||
ORDER BY weeks_since_signup
|
||||
");
|
||||
saveSnapshot("retention_cohort.json", ['cohorts' => $retention], $todayDir);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 4. إنشاء manifest يسهّل على الداشبورد معرفة الملفات المتاحة
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
$files = glob("$todayDir/*.json");
|
||||
$manifest = [];
|
||||
foreach ($files as $f) {
|
||||
$name = basename($f, '.json');
|
||||
if ($name === 'manifest') continue;
|
||||
$manifest[$name] = [
|
||||
'file' => basename($f),
|
||||
'size' => filesize($f),
|
||||
'updated' => date('Y-m-d H:i:s', filemtime($f)),
|
||||
];
|
||||
}
|
||||
saveSnapshot("manifest.json", ['snapshots' => $manifest, 'date' => $today], $todayDir);
|
||||
|
||||
// ─── فهرس الأيام المتاحة (لتسهيل التصفح التاريخي) ───
|
||||
$days = array_map('basename', glob("$cacheBase/20*", GLOB_ONLYDIR));
|
||||
rsort($days);
|
||||
file_put_contents("$cacheBase/index.json", json_encode([
|
||||
'available_dates' => $days,
|
||||
'latest' => $days[0] ?? $today,
|
||||
'updated' => $now,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
echo "[DashboardSnapshot] Done! Files in $todayDir\n";
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_empty_results_to_db.php
|
||||
* سكربت لتفريغ نتائج البوت من ملف results.json إلى قاعدة بيانات MySQL
|
||||
* يُفضل تشغيله برمجياً كل ساعة أو حسب الحاجة
|
||||
*/
|
||||
|
||||
// Allow script to run indefinitely
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
// Mock request to satisfy connect.php dependencies if any
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
$resultsFile = __DIR__ . '/results.json';
|
||||
|
||||
if (!file_exists($resultsFile)) {
|
||||
die("No results file found.\n");
|
||||
}
|
||||
|
||||
$content = file_get_contents($resultsFile);
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (empty($data) || !is_array($data)) {
|
||||
die("No data to process or invalid JSON.\n");
|
||||
}
|
||||
|
||||
$insertedCount = 0;
|
||||
$stmt = $con->prepare("INSERT INTO scraped_competitor_prices (task_id, app_name, competitor_name, start_location, end_location, start_lat, start_lng, end_lat, end_lng, price_amount, price_per_km, distance_km, duration_min, currency, country_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
|
||||
foreach ($data as $row) {
|
||||
if (isset($row['status']) && $row['status'] !== 'success') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$taskId = $row['task_id'] ?? null;
|
||||
$resultData = $row['result_data'] ?? [];
|
||||
|
||||
$appName = $resultData['app'] ?? $row['app'] ?? 'Unknown';
|
||||
$competitorName = $appName; // Assuming app_name is competitor_name for now
|
||||
|
||||
$startLoc = $resultData['start_location'] ?? $row['start_location'] ?? '';
|
||||
if (empty($startLoc) && !empty($resultData['start_lat'])) {
|
||||
$startLoc = "Lat: {$resultData['start_lat']}, Lng: {$resultData['start_lng']}";
|
||||
}
|
||||
|
||||
$endLoc = $resultData['end_location'] ?? $row['end_location'] ?? '';
|
||||
if (empty($endLoc) && !empty($resultData['end_lat'])) {
|
||||
$endLoc = "Lat: {$resultData['end_lat']}, Lng: {$resultData['end_lng']}";
|
||||
}
|
||||
|
||||
$priceRaw = $resultData['price'] ?? $row['price'] ?? 0;
|
||||
|
||||
$amount = 0.0;
|
||||
$currency = 'JOD';
|
||||
|
||||
if (is_numeric($priceRaw)) {
|
||||
$amount = (float)$priceRaw;
|
||||
} else {
|
||||
$priceStr = (string)$priceRaw;
|
||||
// Match numeric parts (including decimals) and text parts
|
||||
if (preg_match('/([\d\.]+)\s*([A-Za-z]+|د\.ا)/', $priceStr, $matches)) {
|
||||
$amount = (float)$matches[1];
|
||||
$currency = $matches[2];
|
||||
if ($currency === 'د.ا') {
|
||||
$currency = 'JOD';
|
||||
}
|
||||
} else {
|
||||
$amount = (float)$priceStr;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore invalid entries without an amount
|
||||
if ($amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$distanceKm = (float)($resultData['distance_km'] ?? 1);
|
||||
$durationMin = isset($resultData['duration_min']) ? (int)$resultData['duration_min'] : null;
|
||||
|
||||
$startLat = $resultData['start_lat'] ?? null;
|
||||
$startLng = $resultData['start_lng'] ?? null;
|
||||
$endLat = $resultData['end_lat'] ?? null;
|
||||
$endLng = $resultData['end_lng'] ?? null;
|
||||
$countryCode = $row['country_code'] ?? 'JO'; // Default
|
||||
|
||||
// --- OSRM Integration (Dynamic Distance & Duration) ---
|
||||
require_once __DIR__ . '/../core/osrm_routing.php';
|
||||
if ($startLat && $endLat && (!$durationMin || $durationMin <= 0)) {
|
||||
$osrmResult = getOsrmRouteDetails($startLat, $startLng, $endLat, $endLng, $countryCode);
|
||||
if ($osrmResult) {
|
||||
$distanceKm = $osrmResult['distance_km']; // Override with accurate OSRM distance
|
||||
$durationMin = $osrmResult['duration_min'];
|
||||
}
|
||||
}
|
||||
// ------------------------------------------------------
|
||||
|
||||
if ($distanceKm <= 0) $distanceKm = 1; // Prevent division by zero
|
||||
$pricePerKm = $amount / $distanceKm;
|
||||
|
||||
$countryCode = $row['country_code'] ?? 'JO'; // Default
|
||||
|
||||
if ($stmt->execute([
|
||||
$taskId,
|
||||
$appName,
|
||||
$competitorName,
|
||||
$startLoc,
|
||||
$endLoc,
|
||||
$startLat,
|
||||
$startLng,
|
||||
$endLat,
|
||||
$endLng,
|
||||
$amount,
|
||||
$pricePerKm,
|
||||
$distanceKm,
|
||||
$durationMin,
|
||||
$currency,
|
||||
$countryCode
|
||||
])) {
|
||||
$insertedCount++;
|
||||
} else {
|
||||
echo "Failed to insert task_id: $taskId. Error: " . implode(" ", $stmt->errorInfo()) . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the JSON file after successfully inserting data
|
||||
file_put_contents($resultsFile, json_encode([], JSON_PRETTY_PRINT));
|
||||
|
||||
echo "Successfully inserted $insertedCount records into the database and cleared results.json.\n";
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_gemini_advisor.php - Gemini Market Advisor
|
||||
*
|
||||
* يأخذ المعادلات المُكتشَفة من Pricing Engine (مع الفئات والمؤشرات الإحصائية)
|
||||
* ويُرسلها إلى Gemini لتحليل استراتيجي - لم يعُد التحليل الإحصائي من مسؤوليته.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../core/Services/SiroGeminiService.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Starting Gemini Market Advisor Engine v2...\n";
|
||||
|
||||
// 1. إنشاء جدول الإحصائيات الذكية إذا لم يكن موجوداً
|
||||
$sqlInit = "
|
||||
CREATE TABLE IF NOT EXISTS `gemini_market_insights` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`insight_html` TEXT NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
";
|
||||
$con->exec($sqlInit);
|
||||
|
||||
// 2. سحب المعادلات المُطوّرة (مع الفئات والمؤشرات)
|
||||
$stmt = $con->query("
|
||||
SELECT csf.*,
|
||||
(SELECT surge_multiplier FROM competitor_surge_insights
|
||||
WHERE competitor_name = csf.competitor_name
|
||||
AND country_code = csf.country_code
|
||||
ORDER BY detected_at DESC LIMIT 1) as recent_surge
|
||||
FROM competitor_secret_formulas csf
|
||||
WHERE csf.tier IS NOT NULL
|
||||
ORDER BY csf.country_code, csf.competitor_name, csf.tier
|
||||
");
|
||||
$formulas = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($formulas)) {
|
||||
echo "No enriched formulas found. Run pricing-engine first.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. تمرير البيانات المُثراة إلى Gemini
|
||||
$geminiService = new SiroGeminiService();
|
||||
echo "Sending enriched data to Gemini AI for strategic analysis...\n";
|
||||
|
||||
$result = $geminiService->analyzeCompetitorFormulas($formulas);
|
||||
|
||||
if ($result && $result['status'] === 'success') {
|
||||
$htmlReport = $result['html_report'];
|
||||
|
||||
$stmtInsert = $con->prepare("INSERT INTO gemini_market_insights (insight_html) VALUES (:html)");
|
||||
$stmtInsert->execute([':html' => $htmlReport]);
|
||||
|
||||
echo "Gemini analysis saved successfully! Admin can view the strategic report.\n";
|
||||
} else {
|
||||
echo "Failed to get analysis from Gemini. Check API keys and logs.\n";
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_generate_heatmap_cache.php
|
||||
* يجمع بيانات الخريطة الحرارية ويخزنها في Redis
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
$redis = getRedisConnection();
|
||||
} catch (Exception $e) {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Starting Heatmap Cache Generation (Redis)...\n";
|
||||
|
||||
// مربعات المدن الكبرى لتمثيل الدول (لتجنب حساب المضلعات المعقدة)
|
||||
// الأردن (عمان والزرقاء)
|
||||
// سوريا (دمشق)
|
||||
// مصر (القاهرة والإسكندرية)
|
||||
// العراق (بغداد)
|
||||
$cityBounds = [
|
||||
'JO' => [ // Amman & Zarqa rough bounding box
|
||||
'lat' => [31.80, 32.20],
|
||||
'lng' => [35.80, 36.20]
|
||||
],
|
||||
'SY' => [ // Damascus
|
||||
'lat' => [33.40, 33.60],
|
||||
'lng' => [36.20, 36.40]
|
||||
],
|
||||
'EG' => [ // Cairo & Alexandria
|
||||
'lat' => [29.80, 31.30],
|
||||
'lng' => [29.80, 31.50]
|
||||
],
|
||||
'IQ' => [ // Baghdad
|
||||
'lat' => [33.10, 33.50],
|
||||
'lng' => [44.20, 44.60]
|
||||
]
|
||||
];
|
||||
|
||||
try {
|
||||
$sql = "SELECT latitude, longitude, source, created_at
|
||||
FROM passenger_opening_locations
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20000";
|
||||
|
||||
$stmt = $con->query($sql);
|
||||
$locations = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$stats = ['geofence' => 0, 'app_usage' => 0, 'silent_push' => 0];
|
||||
|
||||
// تقسيم البيانات حسب الدولة (لتسهيل قراءتها من الـ API)
|
||||
$countryData = [
|
||||
'JO' => [], 'SY' => [], 'EG' => [], 'IQ' => [], 'OTHER' => []
|
||||
];
|
||||
|
||||
foreach ($locations as $loc) {
|
||||
$lat = (float)$loc['latitude'];
|
||||
$lng = (float)$loc['longitude'];
|
||||
if ($lat == 0 || $lng == 0) continue;
|
||||
|
||||
$src = $loc['source'] ?? 'app_usage';
|
||||
$date = substr($loc['created_at'], 0, 10);
|
||||
|
||||
$assignedCountry = 'OTHER';
|
||||
// البحث عن المربع الذي يقع فيه الإحداثي
|
||||
foreach ($cityBounds as $cc => $b) {
|
||||
if ($lat >= $b['lat'][0] && $lat <= $b['lat'][1] &&
|
||||
$lng >= $b['lng'][0] && $lng <= $b['lng'][1]) {
|
||||
$assignedCountry = $cc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($stats[$src])) $stats[$src]++;
|
||||
|
||||
$countryData[$assignedCountry][] = [
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'source' => $src,
|
||||
'date' => $date
|
||||
];
|
||||
}
|
||||
|
||||
$redisData = [
|
||||
'last_updated' => date('Y-m-d H:i:s'),
|
||||
'total' => count($locations),
|
||||
'stats' => $stats,
|
||||
'data' => $countryData // مقسمة وجاهزة
|
||||
];
|
||||
|
||||
$redis->set('siro:cache:heatmap:data', json_encode($redisData, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
echo "Heatmap Cache Generated Successfully. Points: " . count($locations) . "\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error generating heatmap cache: " . $e->getMessage());
|
||||
echo "Error: " . $e->getMessage();
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_generate_pricing_cache.php
|
||||
* يجمع أسعار المنافسين لكل مربع جغرافي (2.5km) ويحفظ النتيجة في Redis
|
||||
* يتم تشغيله كـ Cron Job (CLI) لتخفيف الضغط تماماً عن الاستعلام المباشر للركاب.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
$redis = getRedisConnection();
|
||||
} catch (Exception $e) {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Starting Pricing Cache Generation (Redis)...\n";
|
||||
|
||||
try {
|
||||
// 1. جلب متوسط الأسعار من المنافسين في آخر ساعة للشبكة
|
||||
$sql = "SELECT country_code,
|
||||
ROUND(latitude / 0.025) * 0.025 AS grid_lat,
|
||||
ROUND(longitude / 0.025) * 0.025 AS grid_lng,
|
||||
competitor_name,
|
||||
AVG(price) as avg_price,
|
||||
COUNT(*) as requests_count
|
||||
FROM competitor_prices
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
|
||||
GROUP BY country_code, grid_lat, grid_lng, competitor_name";
|
||||
|
||||
$stmt = $con->query($sql);
|
||||
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// بناء مصفوفة الشبكات
|
||||
$grids = [];
|
||||
foreach ($results as $row) {
|
||||
$cc = strtoupper($row['country_code']);
|
||||
$gLat = number_format((float)$row['grid_lat'], 3);
|
||||
$gLng = number_format((float)$row['grid_lng'], 3);
|
||||
|
||||
$key = "{$cc}_{$gLat}_{$gLng}";
|
||||
|
||||
if (!isset($grids[$key])) {
|
||||
$grids[$key] = [
|
||||
'competitors' => [],
|
||||
'total_price' => 0,
|
||||
'total_competitors' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$grids[$key]['competitors'][$row['competitor_name']] = (float)$row['avg_price'];
|
||||
$grids[$key]['total_price'] += (float)$row['avg_price'];
|
||||
$grids[$key]['total_competitors']++;
|
||||
}
|
||||
|
||||
$processedGrids = [];
|
||||
$fallbackData = []; // لمتوسط البلد بالكامل كبديل
|
||||
|
||||
foreach ($grids as $key => $data) {
|
||||
if ($data['total_competitors'] == 0) continue;
|
||||
|
||||
$overallAvg = $data['total_price'] / $data['total_competitors'];
|
||||
|
||||
// إيجاد المنافس الأرخص في هذا المربع
|
||||
$cheapestComp = '';
|
||||
$cheapestPrice = 999999;
|
||||
foreach ($data['competitors'] as $name => $price) {
|
||||
if ($price < $cheapestPrice) {
|
||||
$cheapestPrice = $price;
|
||||
$cheapestComp = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$gridInfo = [
|
||||
'avg_price' => round($overallAvg, 2),
|
||||
'top_competitor' => $cheapestComp,
|
||||
'cheapest_price' => round($cheapestPrice, 2)
|
||||
];
|
||||
|
||||
$processedGrids[$key] = $gridInfo;
|
||||
|
||||
// حفظ المتوسط للـ Fallback
|
||||
$cc = explode('_', $key)[0];
|
||||
if (!isset($fallbackData[$cc])) {
|
||||
$fallbackData[$cc] = ['sum' => 0, 'count' => 0, 'cheapest_comp' => $cheapestComp];
|
||||
}
|
||||
$fallbackData[$cc]['sum'] += $overallAvg;
|
||||
$fallbackData[$cc]['count']++;
|
||||
}
|
||||
|
||||
// إضافة Fallback لكل دولة (في حال الراكب كان في مربع فارغ)
|
||||
foreach ($fallbackData as $cc => $d) {
|
||||
if ($d['count'] > 0) {
|
||||
$processedGrids["{$cc}_FALLBACK"] = [
|
||||
'avg_price' => round($d['sum'] / $d['count'], 2),
|
||||
'top_competitor' => $d['cheapest_comp'],
|
||||
'cheapest_price' => round($d['sum'] / $d['count'], 2)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. الحفظ في Redis
|
||||
// نحفظ المصفوفة بالكامل كـ JSON String داخل مفتاح رئيسي واحد للسرعة العالية في القراءة
|
||||
// مفتاح: siro:cache:pricing:grids
|
||||
$redisData = [
|
||||
'last_updated' => date('Y-m-d H:i:s'),
|
||||
'grids' => $processedGrids
|
||||
];
|
||||
|
||||
$redis->set('siro:cache:pricing:grids', json_encode($redisData, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
echo "Pricing Cache Generated Successfully in Redis. Grids: " . count($processedGrids) . "\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error generating pricing cache (Redis): " . $e->getMessage());
|
||||
echo "Error: " . $e->getMessage();
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_kazan_adjuster.php
|
||||
* يضبط عمولة كازان ويضيف حوافز للسائقين المتواجدين في مناطق ذروة المنافسين
|
||||
*
|
||||
* المبدأ:
|
||||
* - تقليل العمولة للمنطقة النشطة (مثال: من 15% إلى 10%)
|
||||
* - يتم تشغيله كل 10 دقائق
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
$redis = getRedisConnection();
|
||||
} catch (Exception $e) {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
$countries = ['SY', 'JO', 'EG', 'IQ'];
|
||||
|
||||
foreach ($countries as $country) {
|
||||
$surgeKey = "surge:opportunities:{$country}";
|
||||
$hotspotsJson = $redis->get($surgeKey);
|
||||
$hotspots = $hotspotsJson ? json_decode($hotspotsJson, true) : [];
|
||||
|
||||
// In a real scenario, Kazan configs might be stored in a `kazan_commissions` table
|
||||
// or applied dynamically at the ride request time.
|
||||
// Here we will save the "active discounted Kazan multiplier" in Redis so the ride logic can apply it instantly.
|
||||
|
||||
$kazanDiscountConfig = [];
|
||||
|
||||
if (!empty($hotspots)) {
|
||||
foreach ($hotspots as $grid => $multiplier) {
|
||||
// If competitor surge is high enough, we lower our Kazan by a factor
|
||||
// Standard Kazan is let's say 15%.
|
||||
if ($multiplier > 1.2) {
|
||||
// Apply a 30% reduction to Kazan Commission for this grid
|
||||
$kazanDiscountConfig[$grid] = 0.70; // 70% of standard commission
|
||||
} elseif ($multiplier > 1.05) {
|
||||
// Apply a 15% reduction
|
||||
$kazanDiscountConfig[$grid] = 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$kazanKey = "surge:kazan_discounts:{$country}";
|
||||
|
||||
if (empty($kazanDiscountConfig)) {
|
||||
$redis->del($kazanKey);
|
||||
echo "[$country] No active Kazan discounts.\n";
|
||||
} else {
|
||||
$redis->setex($kazanKey, 1200, json_encode($kazanDiscountConfig));
|
||||
echo "[$country] Updated Kazan discounts for " . count($kazanDiscountConfig) . " active hotspots.\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_passenger_reengagement.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* Re-engagement cron: يرسل إشعار تسويقي مخصص (عبر Gemini AI)
|
||||
* للركاب الذين لم يفتحوا التطبيق منذ 3 أيام أو أكثر.
|
||||
*
|
||||
* المصدر: جدول passenger_opening_locations (آخر نشاط مسجّل)
|
||||
* Anti-spam: Redis — لا يُرسَل لنفس الراكب أكثر من مرة كل 72 ساعة
|
||||
*
|
||||
* جدولة مقترحة (crontab):
|
||||
* 0 10 * * * php /var/www/backend/bot/cron_passenger_reengagement.php
|
||||
* (يعمل يومياً الساعة 10 صباحاً)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../core/Services/SiroGeminiService.php';
|
||||
require_once __DIR__ . '/../core/Security/EncryptionHelper.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
$redis = getRedisConnection();
|
||||
} catch (Exception $e) {
|
||||
die("[ReEngagement] Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "[ReEngagement] Starting Passenger Re-engagement Engine...\n";
|
||||
|
||||
$REDIS_TTL = 72 * 3600; // anti-spam: 72 ساعة
|
||||
$INACTIVE_DAYS = 3; // عدد أيام الخمول
|
||||
$BATCH_LIMIT = 500; // أقصى عدد ركاب لكل تشغيل
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 1. جلب الركاب الخاملين (آخر نشاطهم منذ 3 أيام أو أكثر)
|
||||
// نعتمد على passenger_opening_locations كمصدر لآخر نشاط
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
$sql = "
|
||||
SELECT
|
||||
p.id AS passenger_id,
|
||||
IFNULL(MAX(pol.country_code), 'JO') AS country_code,
|
||||
t.token,
|
||||
MAX(pol.created_at) AS last_active
|
||||
FROM passengers p
|
||||
JOIN tokens t ON t.passengerID = p.id
|
||||
LEFT JOIN passenger_opening_locations pol ON pol.passenger_id = p.id
|
||||
WHERE p.status = 'notDeleted'
|
||||
GROUP BY p.id, t.token
|
||||
HAVING
|
||||
last_active IS NULL
|
||||
OR last_active < DATE_SUB(NOW(), INTERVAL :days DAY)
|
||||
ORDER BY last_active ASC
|
||||
LIMIT :limit
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindValue(':days', $INACTIVE_DAYS, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':limit', $BATCH_LIMIT, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$inactivePassengers = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($inactivePassengers)) {
|
||||
echo "[ReEngagement] No inactive passengers found. All good!\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
echo "[ReEngagement] Found " . count($inactivePassengers) . " inactive passengers (≥{$INACTIVE_DAYS} days).\n";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 2. تجميع حسب الدولة لتقليل استدعاءات Gemini
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
$byCountry = [];
|
||||
foreach ($inactivePassengers as $p) {
|
||||
$cc = strtoupper($p['country_code'] ?? 'SY');
|
||||
$byCountry[$cc][] = $p;
|
||||
}
|
||||
|
||||
$geminiService = new SiroGeminiService();
|
||||
$encryptionHelper = new EncryptionHelper();
|
||||
|
||||
$sentCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
foreach ($byCountry as $countryCode => $passengers) {
|
||||
echo "[ReEngagement] Country: $countryCode — " . count($passengers) . " passengers.\n";
|
||||
|
||||
// ─── رسالة Gemini مخصصة لدولة واحدة ───────────────────
|
||||
$regionName = match($countryCode) {
|
||||
'JO' => 'Amman',
|
||||
'SY' => 'Damascus',
|
||||
'EG' => 'Cairo',
|
||||
'IQ' => 'Baghdad',
|
||||
default => 'your city'
|
||||
};
|
||||
|
||||
// سحب أسعار المنافسين كمرجع للرسالة
|
||||
$stmtComp = $con->prepare(
|
||||
"SELECT competitor_name, base_fare FROM competitor_secret_formulas
|
||||
WHERE country_code = :cc ORDER BY last_updated DESC LIMIT 3"
|
||||
);
|
||||
$stmtComp->execute([':cc' => $countryCode]);
|
||||
$competitors = $stmtComp->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (empty($competitors)) {
|
||||
$competitors = [['competitor_name' => 'Market Average', 'base_fare' => 1.0]];
|
||||
}
|
||||
|
||||
$aiCampaign = $geminiService->analyzeMarketAndDraftCampaign(
|
||||
$competitors,
|
||||
1.0,
|
||||
$regionName,
|
||||
$countryCode
|
||||
);
|
||||
|
||||
// Fallback إذا Gemini ما أعطى فرصة
|
||||
if (!$aiCampaign || ($aiCampaign['opportunity_detected'] ?? false) !== true) {
|
||||
$countryEmoji = match($countryCode) { 'JO' => '🇯🇴', 'SY' => '🇸🇾', 'EG' => '🇪🇬', default => '🌍' };
|
||||
$title = "وينك؟ نفتقدك! {$countryEmoji}";
|
||||
$body = "مش شايفينك من {$INACTIVE_DAYS} أيام. سيارتك جاهزة وبأرخص سعر — اطلب الآن! 🚗";
|
||||
} else {
|
||||
$title = $aiCampaign['push_title'] ?? '🚗 سيرو يفتقدك!';
|
||||
$body = $aiCampaign['push_body'] ?? "لم تطلب منذ {$INACTIVE_DAYS} أيام — العروض لا تنتظر!";
|
||||
}
|
||||
|
||||
echo "[ReEngagement] Message: [{$title}] {$body}\n";
|
||||
|
||||
// ─── إرسال لكل راكب مع anti-spam ───────────────────────
|
||||
foreach ($passengers as $p) {
|
||||
$passengerId = $p['passenger_id'];
|
||||
$redisKey = "reengagement:sent:{$passengerId}";
|
||||
|
||||
// Anti-spam: تخطّى إذا أرسلنا له خلال 72 ساعة
|
||||
if ($redis && $redis->exists($redisKey)) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$decryptedToken = $encryptionHelper->decryptData($p['token'] ?? '');
|
||||
if (!$decryptedToken) continue;
|
||||
|
||||
$result = sendFCM_Internal(
|
||||
$decryptedToken,
|
||||
$title,
|
||||
$body,
|
||||
[
|
||||
'type' => 're_engagement',
|
||||
'days_inactive' => $INACTIVE_DAYS,
|
||||
'screen' => 'home',
|
||||
],
|
||||
'Marketing',
|
||||
false,
|
||||
'ding'
|
||||
);
|
||||
|
||||
// حفظ في Redis لمنع التكرار
|
||||
if ($redis) {
|
||||
$redis->setex($redisKey, $REDIS_TTL, '1');
|
||||
}
|
||||
|
||||
// حفظ في جدول notifications الداخلي (يظهر في صفحة الإشعارات)
|
||||
try {
|
||||
$ins = $con->prepare(
|
||||
"INSERT INTO notifications (title, body, passenger_id)
|
||||
VALUES (:title, :body, :pid)"
|
||||
);
|
||||
$ins->execute([':title' => $title, ':body' => $body, ':pid' => $passengerId]);
|
||||
} catch (Exception $e) {
|
||||
error_log("[ReEngagement] DB insert error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$sentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "[ReEngagement] Done ✅ — Sent: {$sentCount}, Skipped (anti-spam): {$skippedCount}\n";
|
||||
?>
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_predictive_demand.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* التنبؤ الذكي بالطلب: يحلل بيانات الرحلات التاريخية ويرسل
|
||||
* إشعارات للسائقين المتاحين يخبرهم بالمناطق المتوقع ارتفاع
|
||||
* الطلب فيها خلال الـ 30 دقيقة القادمة.
|
||||
*
|
||||
* المنطق:
|
||||
* 1. يحسب متوسط عدد الطلبات لكل خلية (grid 0.01°×0.01°) في
|
||||
* نفس اليوم + نفس الساعة من آخر 4 أسابيع
|
||||
* 2. يرتب الخلايا تنازلياً ويختار أفضل 10
|
||||
* 3. يجد السائقين المتاحين (is_online=1) داخل 5km من كل خلية
|
||||
* 4. يرسل FCM مخصص لكل سائق (مرة كل ساعتين anti-spam)
|
||||
* 5. يحفظ النتائج في Redis للـ API
|
||||
*
|
||||
* جدولة مقترحة (crontab):
|
||||
* 0 * * * * php /var/www/backend/bot/cron_predictive_demand.php
|
||||
* (كل ساعة)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
require_once __DIR__ . '/../core/Security/EncryptionHelper.php';
|
||||
|
||||
set_time_limit(120);
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
$conRide = Database::get('ride');
|
||||
$redis = getRedisConnection();
|
||||
} catch (Exception $e) {
|
||||
die("[PredictiveDemand] Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "[PredictiveDemand] Starting...\n";
|
||||
|
||||
$GRID_SIZE = 0.01; // حجم الخلية (~1.1km)
|
||||
$RADIUS_DEGREES = 0.045; // ~5km
|
||||
$ANTI_SPAM_TTL = 7200; // 2 ساعة
|
||||
$TOP_ZONES = 10; // أفضل 10 مناطق
|
||||
$WEEKS_HISTORY = 4; // تحليل آخر 4 أسابيع
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 1. تحليل الطلبات التاريخية (نفس الساعة + نفس اليوم من الأسبوع)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
$currentHour = (int)date('H');
|
||||
$currentDow = (int)date('N'); // 1=Mon ... 7=Sun
|
||||
$targetHourNext = ($currentHour + 1) % 24; // الساعة القادمة (المتوقع)
|
||||
|
||||
$sqlHistory = "
|
||||
SELECT
|
||||
ROUND(SUBSTRING_INDEX(start_location, ',', 1) / {$GRID_SIZE}) * {$GRID_SIZE} AS cell_lat,
|
||||
ROUND(SUBSTRING_INDEX(start_location, ',', -1) / {$GRID_SIZE}) * {$GRID_SIZE} AS cell_lng,
|
||||
'JO' AS country_code,
|
||||
COUNT(*) AS demand_score
|
||||
FROM ride
|
||||
WHERE
|
||||
status IN ('completed', 'cancelled_by_driver', 'timeout')
|
||||
AND HOUR(created_at) = :next_hour
|
||||
AND DAYOFWEEK(created_at) = :dow
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL :weeks WEEK)
|
||||
AND start_location != ''
|
||||
AND start_location IS NOT NULL
|
||||
GROUP BY cell_lat, cell_lng, country_code
|
||||
HAVING demand_score >= 2
|
||||
ORDER BY demand_score DESC
|
||||
LIMIT :top
|
||||
";
|
||||
|
||||
try {
|
||||
$stmt = $conRide->prepare($sqlHistory);
|
||||
$stmt->bindValue(':next_hour', $targetHourNext, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':dow', $currentDow, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':weeks', $WEEKS_HISTORY, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':top', $TOP_ZONES * 3, PDO::PARAM_INT); // نأخذ أكثر لنصفيها لاحقاً
|
||||
$stmt->execute();
|
||||
$hotZones = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
die("[PredictiveDemand] Query error: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
if (empty($hotZones)) {
|
||||
echo "[PredictiveDemand] Not enough historical data for this hour. Exiting.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
echo "[PredictiveDemand] Found " . count($hotZones) . " hot zones.\n";
|
||||
|
||||
// خذ أفضل N منطقة
|
||||
$hotZones = array_slice($hotZones, 0, $TOP_ZONES);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 2. تسمية المناطق باستخدام geofence_zones الموجودة أو اسم
|
||||
// عام بناءً على الإحداثيات
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function getZoneName($con, $lat, $lng, $radius)
|
||||
{
|
||||
try {
|
||||
$stmt = $con->prepare("
|
||||
SELECT zone_name
|
||||
FROM geofence_zones
|
||||
WHERE is_active = 1
|
||||
AND ABS(latitude - :lat) < :r
|
||||
AND ABS(longitude - :lng) < :r
|
||||
ORDER BY ABS(latitude - :lat) + ABS(longitude - :lng) ASC
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([':lat' => $lat, ':lng' => $lng, ':r' => $radius]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ? $row['zone_name'] : null;
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 3. حفظ النتائج في Redis للـ API
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
$redisData = [];
|
||||
foreach ($hotZones as &$zone) {
|
||||
$zoneName = getZoneName($con, $zone['cell_lat'], $zone['cell_lng'], $RADIUS_DEGREES);
|
||||
$zone['zone_name'] = $zoneName ?? "منطقة ({$zone['cell_lat']}, {$zone['cell_lng']})";
|
||||
$redisData[] = [
|
||||
'lat' => (float)$zone['cell_lat'],
|
||||
'lng' => (float)$zone['cell_lng'],
|
||||
'zone_name' => $zone['zone_name'],
|
||||
'demand_score' => (int)$zone['demand_score'],
|
||||
'predicted_hour' => $targetHourNext,
|
||||
'country_code' => $zone['country_code'],
|
||||
];
|
||||
}
|
||||
unset($zone);
|
||||
|
||||
if ($redis) {
|
||||
$redis->setex(
|
||||
'siro:cache:predictive_demand',
|
||||
3600, // TTL ساعة واحدة
|
||||
json_encode([
|
||||
'last_updated' => date('Y-m-d H:i:s'),
|
||||
'predicted_hour' => $targetHourNext,
|
||||
'zones' => $redisData,
|
||||
], JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
echo "[PredictiveDemand] Zones cached in Redis.\n";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 4. إيجاد السائقين المتاحين قرب كل منطقة وإرسال إشعار
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
$encryptionHelper = new EncryptionHelper();
|
||||
$totalSent = 0;
|
||||
$totalSkipped = 0;
|
||||
$notifiedDrivers = []; // لتجنب إرسال نفس السائق أكثر من إشعار
|
||||
|
||||
foreach ($hotZones as $zone) {
|
||||
$cellLat = (float)$zone['cell_lat'];
|
||||
$cellLng = (float)$zone['cell_lng'];
|
||||
$zoneName = $zone['zone_name'];
|
||||
$score = (int)$zone['demand_score'];
|
||||
$minutes = 30; // التوقع دائماً 30 دقيقة للأمام
|
||||
|
||||
// استخرج رسالة مناسبة بناءً على حجم الطلب
|
||||
if ($score >= 10) {
|
||||
$intensity = 'مرتفع جداً 🔥🔥';
|
||||
} elseif ($score >= 5) {
|
||||
$intensity = 'مرتفع 🔥';
|
||||
} else {
|
||||
$intensity = 'متوسط 📈';
|
||||
}
|
||||
|
||||
$title = "🔮 توقع ذكي من سيرو";
|
||||
$body = "نتوقع طلب {$intensity} من منطقة «{$zoneName}» خلال {$minutes} دقيقة. اتجه الآن! 🚗";
|
||||
|
||||
// ── جلب السائقين المتاحين في نطاق 5km ──────────────────
|
||||
$sqlDrivers = "
|
||||
SELECT
|
||||
d.id AS driver_id,
|
||||
t.token,
|
||||
dl.lat,
|
||||
dl.lng
|
||||
FROM drivers d
|
||||
JOIN driver_live_location dl ON dl.driver_id = d.id
|
||||
JOIN tokens t ON t.driverID = d.id
|
||||
WHERE d.status_driver = 'free'
|
||||
AND d.verified = 1
|
||||
AND dl.updated_at >= DATE_SUB(NOW(), INTERVAL 15 MINUTE)
|
||||
AND ABS(dl.lat - :lat) < :radius
|
||||
AND ABS(dl.lng - :lng) < :radius
|
||||
LIMIT 50
|
||||
";
|
||||
|
||||
try {
|
||||
$stmtD = $con->prepare($sqlDrivers);
|
||||
$stmtD->execute([
|
||||
':lat' => $cellLat,
|
||||
':lng' => $cellLng,
|
||||
':radius' => $RADIUS_DEGREES,
|
||||
]);
|
||||
$nearbyDrivers = $stmtD->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
// جدول driver_live_location قد يختلف اسمه
|
||||
error_log("[PredictiveDemand] Driver query error: " . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "[PredictiveDemand] Zone [{$zoneName}] score={$score} → " . count($nearbyDrivers) . " nearby drivers.\n";
|
||||
|
||||
foreach ($nearbyDrivers as $driver) {
|
||||
$driverId = $driver['driver_id'];
|
||||
|
||||
// تخطّى إذا أرسلنا لهذا السائق سابقاً في نفس الـ cron run
|
||||
if (isset($notifiedDrivers[$driverId])) {
|
||||
$totalSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anti-spam: مرة كل ساعتين لكل سائق
|
||||
$redisKey = "predictive:sent:{$driverId}";
|
||||
if ($redis && $redis->exists($redisKey)) {
|
||||
$totalSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$decryptedToken = $encryptionHelper->decryptData($driver['token'] ?? '');
|
||||
if (!$decryptedToken) continue;
|
||||
|
||||
sendFCM_Internal(
|
||||
$decryptedToken,
|
||||
$title,
|
||||
$body,
|
||||
[
|
||||
'type' => 'predictive_demand',
|
||||
'zone_name' => $zoneName,
|
||||
'zone_lat' => $cellLat,
|
||||
'zone_lng' => $cellLng,
|
||||
'demand_score' => $score,
|
||||
'predicted_hour' => $targetHourNext,
|
||||
],
|
||||
'System',
|
||||
false,
|
||||
'ding'
|
||||
);
|
||||
|
||||
// حفظ في Redis anti-spam
|
||||
if ($redis) {
|
||||
$redis->setex($redisKey, $ANTI_SPAM_TTL, '1');
|
||||
}
|
||||
|
||||
$notifiedDrivers[$driverId] = true;
|
||||
$totalSent++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "[PredictiveDemand] Done ✅ — Sent: {$totalSent}, Skipped: {$totalSkipped}\n";
|
||||
?>
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_pricing_stability_engine.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* محرك الثبات والاستقرار — وضع مراقبة (Shadow Mode) فقط.
|
||||
*
|
||||
* ما بيلمس جدول kazan أبداً. بيقارن معامل سعر الكيلو الحالي لكل
|
||||
* منافس/دولة (من competitor_secret_formulas) مع الوسيط المرجعي
|
||||
* لآخر 7 أيام (من competitor_formula_history)، ويسجّل تصنيفه
|
||||
* والإجراء المقترح بجدول pricing_stability_log لمراجعته يدوياً.
|
||||
*
|
||||
* التصنيف:
|
||||
* - drift <= -15% → temporary_promo (برومو مؤقت — لا يُغيَّر السعر الأساسي)
|
||||
* - |drift| > 5% مستمر عبر آخر 48 ساعة → sustained_change (تغيير حقيقي مقترح)
|
||||
* - غير هيك → stable
|
||||
*
|
||||
* فترة سكون: أي تغيير "حقيقي" مقترح يُحتجز إذا كان آخر تغيير حقيقي
|
||||
* (would_apply=1 من نوع sustained_change) بآخر 3 أيام.
|
||||
*
|
||||
* جدولة مقترحة: كل 3 ساعات، بالتوازي مع cron_ai_engine.php.
|
||||
*
|
||||
* ملاحظة: competitor_formula_history جدول جديد — بيضل فاضي/قليل
|
||||
* البيانات لأول أسبوع بعد النشر لحد ما يتراكم تاريخ كافي لحساب
|
||||
* وسيط 7 أيام موثوق. هذا متوقع وطبيعي، مو خطأ.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Starting Pricing Stability Engine (Shadow Mode)...\n";
|
||||
|
||||
// ==========================================
|
||||
// 0. إنشاء الجداول إذا لم تكن موجودة
|
||||
// ==========================================
|
||||
$con->exec("
|
||||
CREATE TABLE IF NOT EXISTS `pricing_stability_log` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`country_code` VARCHAR(5) NOT NULL,
|
||||
`tier` VARCHAR(20) NOT NULL DEFAULT 'economy',
|
||||
`reference_median_km_rate` DECIMAL(10,4) NOT NULL,
|
||||
`current_km_rate` DECIMAL(10,4) NOT NULL,
|
||||
`drift_pct` DECIMAL(6,2) NOT NULL,
|
||||
`classification` ENUM('stable','temporary_promo','sustained_change') NOT NULL,
|
||||
`suggested_action` TEXT NULL,
|
||||
`would_apply` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`hold_reason` VARCHAR(100) NULL,
|
||||
`evaluated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_country_time` (`country_code`, `evaluated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
|
||||
$con->exec("
|
||||
CREATE TABLE IF NOT EXISTS `country_pricing_floor` (
|
||||
`country_code` VARCHAR(5) NOT NULL,
|
||||
`min_km_rate` DECIMAL(10,4) NOT NULL,
|
||||
`min_base_fare` DECIMAL(10,4) NOT NULL DEFAULT 0,
|
||||
`updated_by` VARCHAR(100) NULL,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`country_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
|
||||
// ==========================================
|
||||
// إعدادات
|
||||
// ==========================================
|
||||
$countryNameMap = ['JO' => 'Jordan', 'SY' => 'Syria', 'EG' => 'Egypt', 'IQ' => 'Iraq'];
|
||||
$tierPriority = ['economy', 'standard', 'premium'];
|
||||
$promoDropPct = -15.0; // انخفاض مفاجئ أكبر من هذا = برومو مؤقت
|
||||
$driftThreshold = 5.0; // انحراف أكبر من هذا (مطلق) يستاهل الانتباه
|
||||
$sustainedWindow = 48; // ساعة — لازم الانحراف يستمر عبرها
|
||||
$holdDays = 3; // فترة سكون بعد أي تغيير حقيقي مقترح
|
||||
$maxStepPct = 3.0; // أقصى تغيير مسموح بالمرة الوحدة
|
||||
$discountFactor = 1 - 0.065;
|
||||
|
||||
/** وسيط بسيط لمصفوفة أرقام */
|
||||
function median_of(array $nums): ?float {
|
||||
$nums = array_values(array_filter($nums, fn($n) => $n !== null && $n > 0));
|
||||
if (empty($nums)) return null;
|
||||
sort($nums);
|
||||
$count = count($nums);
|
||||
$mid = intdiv($count, 2);
|
||||
if ($count % 2 === 0) {
|
||||
return ($nums[$mid - 1] + $nums[$mid]) / 2;
|
||||
}
|
||||
return $nums[$mid];
|
||||
}
|
||||
|
||||
foreach ($countryNameMap as $cc => $countryName) {
|
||||
echo "\n[$cc] Evaluating...\n";
|
||||
|
||||
// ── 1. سعر Siro الحالي بجدول kazan (للأرضية وحساب التغيير المقترح) ──
|
||||
$stmtKazan = $con->prepare("SELECT speedPrice, startPrice FROM kazan WHERE country = :country LIMIT 1");
|
||||
$stmtKazan->execute([':country' => $countryName]);
|
||||
$kazanRow = $stmtKazan->fetch(PDO::FETCH_ASSOC);
|
||||
$currentSiroKmRate = $kazanRow ? (float)$kazanRow['speedPrice'] : 0.0;
|
||||
|
||||
if ($currentSiroKmRate <= 0) {
|
||||
echo " ⚠️ No kazan row for $countryName. Skipping.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 2. أرضية ربحية — زرعها تلقائياً أول مرة بـ85% من السعر الحالي ──
|
||||
$stmtFloor = $con->prepare("SELECT min_km_rate FROM country_pricing_floor WHERE country_code = :cc");
|
||||
$stmtFloor->execute([':cc' => $cc]);
|
||||
$floorRow = $stmtFloor->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$floorRow) {
|
||||
$seedFloor = round($currentSiroKmRate * 0.85, 4);
|
||||
$insFloor = $con->prepare("
|
||||
INSERT INTO country_pricing_floor (country_code, min_km_rate, min_base_fare, updated_by)
|
||||
VALUES (:cc, :floor, 0, 'auto_seed_default')
|
||||
");
|
||||
$insFloor->execute([':cc' => $cc, ':floor' => $seedFloor]);
|
||||
$floorKmRate = $seedFloor;
|
||||
echo " 🌱 Seeded default profit floor: $seedFloor (85% of current price)\n";
|
||||
} else {
|
||||
$floorKmRate = (float)$floorRow['min_km_rate'];
|
||||
}
|
||||
|
||||
// ── 3. اختيار أفضل فئة متوفرة (economy أولاً) من المعادلات الحالية ──
|
||||
$currentTier = null;
|
||||
$currentKmRate = null;
|
||||
foreach ($tierPriority as $tier) {
|
||||
$stmtCur = $con->prepare("
|
||||
SELECT price_per_km FROM competitor_secret_formulas
|
||||
WHERE country_code = :cc AND tier = :tier
|
||||
ORDER BY last_updated DESC LIMIT 1
|
||||
");
|
||||
$stmtCur->execute([':cc' => $cc, ':tier' => $tier]);
|
||||
$row = $stmtCur->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row && (float)$row['price_per_km'] > 0) {
|
||||
$currentTier = $tier;
|
||||
$currentKmRate = (float)$row['price_per_km'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($currentKmRate === null) {
|
||||
echo " ⚠️ No current competitor formula for $countryName. Skipping.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 4. الوسيط المرجعي لآخر 7 أيام من سجل التاريخ ──
|
||||
$stmtHist = $con->prepare("
|
||||
SELECT price_per_km FROM competitor_formula_history
|
||||
WHERE country_code = :cc AND tier = :tier
|
||||
AND snapshotted_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
ORDER BY snapshotted_at ASC
|
||||
");
|
||||
$stmtHist->execute([':cc' => $cc, ':tier' => $currentTier]);
|
||||
$historyRates = array_map('floatval', $stmtHist->fetchAll(PDO::FETCH_COLUMN));
|
||||
|
||||
$referenceMedian = median_of($historyRates);
|
||||
|
||||
if ($referenceMedian === null) {
|
||||
echo " ℹ️ Not enough history yet for $countryName [tier=$currentTier] — building up (need ~7 days). Skipping evaluation.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 5. حساب الانحراف ──
|
||||
$driftPct = round((($currentKmRate - $referenceMedian) / $referenceMedian) * 100, 2);
|
||||
|
||||
// ── 6. هل الانحراف مستمر آخر 48 ساعة؟ ──
|
||||
$stmtRecent = $con->prepare("
|
||||
SELECT price_per_km FROM competitor_formula_history
|
||||
WHERE country_code = :cc AND tier = :tier
|
||||
AND snapshotted_at >= DATE_SUB(NOW(), INTERVAL :hours HOUR)
|
||||
ORDER BY snapshotted_at ASC
|
||||
");
|
||||
$stmtRecent->execute([':cc' => $cc, ':tier' => $currentTier, ':hours' => $sustainedWindow]);
|
||||
$recentRates = array_map('floatval', $stmtRecent->fetchAll(PDO::FETCH_COLUMN));
|
||||
|
||||
$isSustained = false;
|
||||
if (count($recentRates) >= 2) {
|
||||
$sameDirectionCount = 0;
|
||||
foreach ($recentRates as $r) {
|
||||
$rDrift = (($r - $referenceMedian) / $referenceMedian) * 100;
|
||||
$sameSign = ($driftPct >= 0 && $rDrift >= 0) || ($driftPct < 0 && $rDrift < 0);
|
||||
if ($sameSign && abs($rDrift) > $driftThreshold) {
|
||||
$sameDirectionCount++;
|
||||
}
|
||||
}
|
||||
// كل اللقطات بآخر 48 ساعة (وليس عينة واحدة) تؤكد نفس الاتجاه
|
||||
$isSustained = ($sameDirectionCount === count($recentRates));
|
||||
}
|
||||
|
||||
// ── 7. التصنيف ──
|
||||
if ($driftPct <= $promoDropPct) {
|
||||
$classification = 'temporary_promo';
|
||||
} elseif (abs($driftPct) > $driftThreshold && $isSustained) {
|
||||
$classification = 'sustained_change';
|
||||
} else {
|
||||
$classification = 'stable';
|
||||
}
|
||||
|
||||
// ── 8. الإجراء المقترح + would_apply + فترة السكون ──
|
||||
$wouldApply = false;
|
||||
$holdReason = null;
|
||||
$suggestedAction = 'لا إجراء — السعر ثابت';
|
||||
|
||||
if ($classification === 'temporary_promo') {
|
||||
$wouldApply = true;
|
||||
$suggestedAction = "منافس نازل مؤقتاً بنسبة " . abs($driftPct) . "% تحت المرجع — الرد المقترح: كود خصم مؤقت عبر محرك التسويق، بدون لمس السعر الأساسي";
|
||||
} elseif ($classification === 'sustained_change') {
|
||||
// تحقق من فترة السكون بالاعتماد على سجلّنا الخاص فقط (shadow-only)
|
||||
$stmtLastApply = $con->prepare("
|
||||
SELECT evaluated_at FROM pricing_stability_log
|
||||
WHERE country_code = :cc AND classification = 'sustained_change' AND would_apply = 1
|
||||
ORDER BY evaluated_at DESC LIMIT 1
|
||||
");
|
||||
$stmtLastApply->execute([':cc' => $cc]);
|
||||
$lastApply = $stmtLastApply->fetchColumn();
|
||||
|
||||
$inHoldPeriod = false;
|
||||
if ($lastApply) {
|
||||
$daysSince = (time() - strtotime($lastApply)) / 86400;
|
||||
$inHoldPeriod = $daysSince < $holdDays;
|
||||
}
|
||||
|
||||
// السعر المستهدف: نفس منطق الخصم 6.5% المعتمد، بحد أقصى 3% تغيير بالمرة، ومقيّد بالأرضية
|
||||
$rawTarget = $currentKmRate * $discountFactor;
|
||||
$maxUp = $currentSiroKmRate * (1 + $maxStepPct / 100);
|
||||
$maxDown = $currentSiroKmRate * (1 - $maxStepPct / 100);
|
||||
$clampedTarget = max($maxDown, min($maxUp, $rawTarget));
|
||||
$clampedTarget = max($clampedTarget, $floorKmRate);
|
||||
$clampedTarget = round($clampedTarget, 4);
|
||||
|
||||
if ($inHoldPeriod) {
|
||||
$wouldApply = false;
|
||||
$holdReason = 'hold_period_active';
|
||||
$suggestedAction = "تغيير مستمر ({$driftPct}%) — بس بفترة سكون (آخر تغيير قبل أقل من $holdDays أيام). لو مفعّل: من $currentSiroKmRate إلى $clampedTarget";
|
||||
} else {
|
||||
$wouldApply = true;
|
||||
$suggestedAction = "تغيير مستمر ({$driftPct}%) — سعر كيلو مقترح: من $currentSiroKmRate إلى $clampedTarget (مقيّد بـ{$maxStepPct}% والأرضية $floorKmRate)";
|
||||
}
|
||||
}
|
||||
|
||||
// ── 9. تسجيل النتيجة ──
|
||||
$insLog = $con->prepare("
|
||||
INSERT INTO pricing_stability_log
|
||||
(country_code, tier, reference_median_km_rate, current_km_rate, drift_pct, classification, suggested_action, would_apply, hold_reason)
|
||||
VALUES
|
||||
(:cc, :tier, :ref, :cur, :drift, :cls, :action, :apply, :hold)
|
||||
");
|
||||
$insLog->execute([
|
||||
':cc' => $cc,
|
||||
':tier' => $currentTier,
|
||||
':ref' => $referenceMedian,
|
||||
':cur' => $currentKmRate,
|
||||
':drift' => $driftPct,
|
||||
':cls' => $classification,
|
||||
':action' => $suggestedAction,
|
||||
':apply' => $wouldApply ? 1 : 0,
|
||||
':hold' => $holdReason,
|
||||
]);
|
||||
|
||||
$icon = $classification === 'stable' ? '✅' : ($classification === 'temporary_promo' ? '🎯' : '⚠️');
|
||||
echo " $icon [tier=$currentTier] drift={$driftPct}% → $classification\n";
|
||||
echo " $suggestedAction\n";
|
||||
}
|
||||
|
||||
echo "\nPricing Stability Engine finished (shadow mode — kazan not touched).\n";
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_seasonal_pricing.php
|
||||
* يضبط الأسعار بشكل موسمي (مثال: وقت الإفطار في رمضان، الأعياد)
|
||||
* ويقارن سرعة وصول السائقين (ETA Benchmark) إذا توفرت بيانات
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
$redis = getRedisConnection();
|
||||
} catch (Exception $e) {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
$countries = ['SY' => 'Asia/Damascus', 'JO' => 'Asia/Amman', 'EG' => 'Africa/Cairo', 'IQ' => 'Asia/Baghdad'];
|
||||
|
||||
// Simulated Calendar of Events
|
||||
$seasons = [
|
||||
'ramadan_iftar' => [
|
||||
'is_active' => true,
|
||||
'start_hour' => 18,
|
||||
'end_hour' => 20,
|
||||
'modifier' => 1.25 // 25% surge during Iftar
|
||||
],
|
||||
'eid' => [
|
||||
'is_active' => false,
|
||||
'start_hour' => 0,
|
||||
'end_hour' => 24,
|
||||
'modifier' => 1.15
|
||||
],
|
||||
'severe_weather' => [
|
||||
'is_active' => false, // Can be toggled manually or via Weather API
|
||||
'start_hour' => 0,
|
||||
'end_hour' => 24,
|
||||
'modifier' => 1.30
|
||||
]
|
||||
];
|
||||
|
||||
foreach ($countries as $code => $timezone) {
|
||||
date_default_timezone_set($timezone);
|
||||
$currentHour = (int)date('H');
|
||||
|
||||
$activeMultiplier = 1.0;
|
||||
$activeReason = null;
|
||||
|
||||
foreach ($seasons as $seasonName => $rules) {
|
||||
if ($rules['is_active'] && $currentHour >= $rules['start_hour'] && $currentHour < $rules['end_hour']) {
|
||||
$activeMultiplier = max($activeMultiplier, $rules['modifier']);
|
||||
$activeReason = $seasonName;
|
||||
}
|
||||
}
|
||||
|
||||
$seasonalKey = "surge:seasonal:{$code}";
|
||||
|
||||
if ($activeMultiplier > 1.0) {
|
||||
$data = [
|
||||
'multiplier' => $activeMultiplier,
|
||||
'reason' => $activeReason,
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
];
|
||||
$redis->setex($seasonalKey, 3600, json_encode($data)); // Expires in 1 hour
|
||||
echo "[$code] Applied Seasonal Pricing: $activeMultiplier x ($activeReason)\n";
|
||||
} else {
|
||||
$redis->del($seasonalKey);
|
||||
echo "[$code] No active seasonal pricing.\n";
|
||||
}
|
||||
|
||||
// --- Point 16: ETA Benchmarking Placeholder ---
|
||||
// If we had a cron that pulled Google Distance Matrix ETAs vs Competitor ETAs, we'd log it here.
|
||||
// echo "[$code] ETA Benchmark recorded.\n";
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_silent_push_inactive.php
|
||||
* Cron job to send a Silent Push Notification to inactive users,
|
||||
* forcing their app to wake up in the background and report their current location.
|
||||
* Run this periodically (e.g., every few hours or once a day).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/../core/Services/FcmService.php';
|
||||
|
||||
// Target users who haven't updated their location in the last 24 hours
|
||||
// but have a valid FCM token.
|
||||
// Adjust the timeframe based on your strategy.
|
||||
$sql = "
|
||||
SELECT p.id as passenger_id, t.token
|
||||
FROM passengers p
|
||||
JOIN tokens t ON p.id = t.passengerID
|
||||
LEFT JOIN passenger_opening_locations pol ON p.id = pol.passenger_id
|
||||
WHERE (pol.created_at IS NULL OR pol.created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR))
|
||||
GROUP BY p.id, t.token
|
||||
LIMIT 500 -- Batch size to avoid overloading the server or getting rate-limited
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$inactiveUsers = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$sentCount = 0;
|
||||
$fcmService = new FcmService(); // Assuming we have an FcmService or we can use sendFcmNotification directly if it's in functions.php
|
||||
|
||||
// In Siro, encryptionHelper is typically used for tokens. Assuming it's required here:
|
||||
require_once __DIR__ . '/../core/Security/EncryptionHelper.php';
|
||||
$encryptionHelper = new EncryptionHelper();
|
||||
|
||||
foreach ($inactiveUsers as $user) {
|
||||
$passengerId = $user['passenger_id'];
|
||||
$encryptedToken = $user['token'];
|
||||
|
||||
$decryptedToken = $encryptionHelper->decryptData($encryptedToken);
|
||||
|
||||
if ($decryptedToken) {
|
||||
// Send a silent push notification.
|
||||
// A silent push doesn't have a 'notification' payload (title/body),
|
||||
// it only has a 'data' payload with 'content-available' => 1 for iOS.
|
||||
|
||||
$dataPayload = [
|
||||
'type' => 'location_sync_request',
|
||||
'action' => 'wake_up'
|
||||
];
|
||||
|
||||
// This is a pseudo implementation relying on your existing push notification setup.
|
||||
// Ensure that your FCM implementation correctly maps 'content-available' for iOS.
|
||||
sendSilentFcmNotification($decryptedToken, $dataPayload);
|
||||
$sentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Silent Push triggered for $sentCount inactive users.\n";
|
||||
|
||||
/**
|
||||
* Helper to send Silent FCM
|
||||
*/
|
||||
function sendSilentFcmNotification($token, $data) {
|
||||
if (function_exists('sendFCM_Internal')) {
|
||||
return sendFCM_Internal($token, '', '', $data, 'SilentPush', false, 'silent');
|
||||
}
|
||||
if (!class_exists('FcmService')) {
|
||||
require_once __DIR__ . '/../core/Services/FcmService.php';
|
||||
}
|
||||
$fcm = new FcmService();
|
||||
return $fcm->send($token, '', '', $data, 'SilentPush', 'silent');
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_surge_opportunity.php
|
||||
* مؤشر فرصة الذروة للعمل في الخلفية كـ Cron Job
|
||||
*
|
||||
* المنطق:
|
||||
* 1. يحسب baseline و current لكل منافس في كل grid.
|
||||
* 2. يحدد المناطق التي تشهد surge شامل من المنافسين.
|
||||
* 3. يحفظ المناطق والمضاعف المقترح (multiplier) في Redis.
|
||||
*/
|
||||
|
||||
// Allow script to run indefinitely
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
// Mock request to satisfy connect.php dependencies if any
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "[".date('Y-m-d H:i:s')."] Starting cron_surge_opportunity...\n";
|
||||
|
||||
try {
|
||||
// 1. حساب الـ baseline و current
|
||||
$sql = "SELECT
|
||||
ROUND(cp.start_lat * 74, 0) / 74 AS lat_group,
|
||||
ROUND(cp.start_lng * 74, 0) / 74 AS lng_group,
|
||||
cp.competitor_name,
|
||||
cp.country_code,
|
||||
AVG(CASE WHEN cp.created_at < DATE_SUB(NOW(), INTERVAL 6 HOUR)
|
||||
THEN cp.price_per_km END) AS baseline_avg,
|
||||
AVG(CASE WHEN cp.created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR)
|
||||
THEN cp.price_per_km END) AS current_avg,
|
||||
COUNT(*) AS total_samples,
|
||||
SUM(CASE WHEN cp.created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR) THEN 1 ELSE 0 END) AS recent_samples
|
||||
FROM scraped_competitor_prices cp
|
||||
WHERE cp.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
AND cp.price_per_km > 0
|
||||
GROUP BY lat_group, lng_group, cp.competitor_name, cp.country_code
|
||||
HAVING recent_samples >= 2
|
||||
ORDER BY lat_group, lng_group, cp.competitor_name";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// 2. تجميع البيانات لكل zone
|
||||
$zones = [];
|
||||
foreach ($rows as $row) {
|
||||
$zoneKey = $row['lat_group'] . '_' . $row['lng_group'];
|
||||
|
||||
$baseline = (float)$row['baseline_avg'];
|
||||
$current = (float)$row['current_avg'];
|
||||
|
||||
$surgeRatio = ($baseline > 0) ? round($current / $baseline, 2) : 1.0;
|
||||
$isSurging = $baseline > 0 && $surgeRatio >= 1.2;
|
||||
|
||||
if (!isset($zones[$zoneKey])) {
|
||||
$zones[$zoneKey] = [
|
||||
'lat' => (float)$row['lat_group'],
|
||||
'lng' => (float)$row['lng_group'],
|
||||
'country_code' => $row['country_code'],
|
||||
'competitors' => [],
|
||||
'total_active' => 0,
|
||||
'total_surging' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$zones[$zoneKey]['competitors'][] = [
|
||||
'name' => $row['competitor_name'],
|
||||
'baseline' => round($baseline, 2),
|
||||
'current' => round($current, 2),
|
||||
'surge_ratio' => $surgeRatio,
|
||||
'is_surging' => $isSurging,
|
||||
];
|
||||
$zones[$zoneKey]['total_active']++;
|
||||
if ($isSurging) {
|
||||
$zones[$zoneKey]['total_surging']++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. تحديد فرص الذروة
|
||||
$opportunities = [];
|
||||
$gridSurgeZones = [];
|
||||
$gridSurgeZonesByCountry = [];
|
||||
|
||||
foreach ($zones as $key => &$zone) {
|
||||
$zone['opportunity'] = (
|
||||
$zone['total_active'] >= 1 &&
|
||||
$zone['total_surging'] === $zone['total_active']
|
||||
);
|
||||
|
||||
if ($zone['opportunity']) {
|
||||
$avgRatio = 0;
|
||||
foreach ($zone['competitors'] as $c) {
|
||||
$avgRatio += $c['surge_ratio'];
|
||||
}
|
||||
$avgRatio /= count($zone['competitors']);
|
||||
|
||||
$suggestedMultiplier = round(1.0 + ($avgRatio - 1.0) * 0.6, 2);
|
||||
if ($suggestedMultiplier < 1.0) $suggestedMultiplier = 1.0;
|
||||
|
||||
$zone['suggested_multiplier'] = $suggestedMultiplier;
|
||||
|
||||
$opportunities[] = [
|
||||
'lat' => $zone['lat'],
|
||||
'lng' => $zone['lng'],
|
||||
'country_code' => $zone['country_code'],
|
||||
'avg_competitor_surge_ratio' => round($avgRatio, 2),
|
||||
'suggested_siro_multiplier' => $suggestedMultiplier,
|
||||
];
|
||||
|
||||
// حفظ المنطقة مع المضاعف المقترح في Redis (للقراءة من get.php بعدين)
|
||||
$gridSurgeZones[$key] = $suggestedMultiplier;
|
||||
|
||||
// نفس البيانات مجمّعة حسب الدولة — يقرأها get_surge_heatmap.php،
|
||||
// winback_hotspot_targets.php، و cron_kazan_adjuster.php كخريطة
|
||||
// grid_id → multiplier لكل دولة على حدة
|
||||
$countryCode = $zone['country_code'];
|
||||
if (!isset($gridSurgeZonesByCountry[$countryCode])) {
|
||||
$gridSurgeZonesByCountry[$countryCode] = [];
|
||||
}
|
||||
$gridSurgeZonesByCountry[$countryCode][$key] = $suggestedMultiplier;
|
||||
}
|
||||
}
|
||||
unset($zone);
|
||||
|
||||
// 4. تخزين فرص الذروة في Redis بصلاحية 10 دقائق (600 ثانية)
|
||||
if (!empty($gridSurgeZones) && isset($redis) && $redis !== null) {
|
||||
$redisKey = 'surge:opportunities';
|
||||
$redis->setex($redisKey, 600, json_encode($gridSurgeZones));
|
||||
echo "[".date('Y-m-d H:i:s')."] Successfully stored ".count($gridSurgeZones)." surge zones in Redis.\n";
|
||||
} else {
|
||||
if (!isset($redis) || $redis === null) {
|
||||
echo "[".date('Y-m-d H:i:s')."] Error: Redis connection not available.\n";
|
||||
} else {
|
||||
// مسح الكي في حال لم يعد هناك أي ذروة
|
||||
$redis->del('surge:opportunities');
|
||||
echo "[".date('Y-m-d H:i:s')."] No active surge opportunities found. Cleared Redis key.\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 5. تخزين/مسح النسخة المقسّمة حسب الدولة — نفس مفتاح surge:opportunities:{CC}
|
||||
// اللي يقرأه get_surge_heatmap.php و winback_hotspot_targets.php و cron_kazan_adjuster.php
|
||||
if (isset($redis) && $redis !== null) {
|
||||
$knownCountries = ['SY', 'JO', 'EG', 'IQ'];
|
||||
foreach ($knownCountries as $cc) {
|
||||
$countryKey = "surge:opportunities:{$cc}";
|
||||
if (!empty($gridSurgeZonesByCountry[$cc])) {
|
||||
$redis->setex($countryKey, 600, json_encode($gridSurgeZonesByCountry[$cc]));
|
||||
echo "[".date('Y-m-d H:i:s')."] [$cc] Stored ".count($gridSurgeZonesByCountry[$cc])." surge zones.\n";
|
||||
} else {
|
||||
$redis->del($countryKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "[".date('Y-m-d H:i:s')."] cron_surge_opportunity completed successfully.\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "[".date('Y-m-d H:i:s')."] Error: " . $e->getMessage() . "\n";
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_weekly_health_report.php
|
||||
* يجري تشغيله في نهاية الأسبوع (يوم الأحد ليلاً) لتلخيص صحة السوق التنافسية.
|
||||
*/
|
||||
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "[".date('Y-m-d H:i:s')."] Starting Weekly Market Health Report...\n";
|
||||
|
||||
try {
|
||||
$countries = ['SY', 'JO', 'EG', 'IQ']; // Countries we operate in or monitor
|
||||
|
||||
foreach ($countries as $countryCode) {
|
||||
|
||||
// 1. Calculate Average PCI and Market Share
|
||||
$sql = "SELECT (price_amount / price_per_km) AS distance_km, price_amount AS total_price
|
||||
FROM scraped_competitor_prices
|
||||
WHERE country_code = :country
|
||||
AND price_per_km > 0
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([':country' => $countryCode]);
|
||||
$trips = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($trips)) {
|
||||
continue; // Skip if no data for this country
|
||||
}
|
||||
|
||||
$sqlKazan = "SELECT speedPrice FROM kazan WHERE country = :country LIMIT 1";
|
||||
$stmtKazan = $con->prepare($sqlKazan);
|
||||
$countryNameMap = ['SY' => 'Syria', 'JO' => 'Jordan', 'EG' => 'Egypt', 'IQ' => 'Iraq'];
|
||||
$stmtKazan->execute([':country' => $countryNameMap[$countryCode] ?? 'Syria']);
|
||||
$kazanRow = $stmtKazan->fetch(PDO::FETCH_ASSOC);
|
||||
$currentSpeedPrice = $kazanRow ? (float)$kazanRow['speedPrice'] : 0;
|
||||
|
||||
$pciSum = 0;
|
||||
$cheaperCount = 0;
|
||||
$totalTrips = count($trips);
|
||||
|
||||
foreach ($trips as $trip) {
|
||||
$compPrice = (float)$trip['total_price'];
|
||||
$siroPrice = (float)$trip['distance_km'] * $currentSpeedPrice;
|
||||
|
||||
if ($compPrice > 0) {
|
||||
$pciSum += ($siroPrice / $compPrice);
|
||||
if ($siroPrice < $compPrice) {
|
||||
$cheaperCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$averagePci = round($pciSum / $totalTrips, 2);
|
||||
$marketSharePct = round(($cheaperCount / $totalTrips) * 100, 2);
|
||||
|
||||
// 2. Count Anomalies (Last 7 Days)
|
||||
$sqlAnomalies = "SELECT COUNT(*) as count FROM price_anomalies
|
||||
WHERE country_code = :country
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)";
|
||||
$stmtAnomalies = $con->prepare($sqlAnomalies);
|
||||
$stmtAnomalies->execute([':country' => $countryCode]);
|
||||
$anomaliesCount = (int)$stmtAnomalies->fetchColumn();
|
||||
|
||||
// 3. Automated Campaigns / Surge Tracking (If applicable in logs)
|
||||
$sqlCampaigns = "SELECT COUNT(*) as count FROM marketing_campaigns_log
|
||||
WHERE country_code = :country
|
||||
AND sent_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)";
|
||||
$stmtCampaigns = $con->prepare($sqlCampaigns);
|
||||
$stmtCampaigns->execute([':country' => $countryCode]);
|
||||
$campaignsCount = (int)$stmtCampaigns->fetchColumn();
|
||||
|
||||
// 4. Save to Database
|
||||
$reportData = [
|
||||
'total_competitor_samples' => $totalTrips,
|
||||
'automated_campaigns_sent' => $campaignsCount,
|
||||
'current_speed_price' => $currentSpeedPrice
|
||||
];
|
||||
|
||||
$sqlInsert = "INSERT INTO market_health_reports
|
||||
(report_date, country_code, average_pci, market_share_percent, total_anomalies, total_surge_opportunities, report_data_json)
|
||||
VALUES (CURDATE(), :country, :pci, :market_share, :anomalies, :surge, :report_data)";
|
||||
$stmtInsert = $con->prepare($sqlInsert);
|
||||
$stmtInsert->execute([
|
||||
':country' => $countryCode,
|
||||
':pci' => $averagePci,
|
||||
':market_share' => $marketSharePct,
|
||||
':anomalies' => $anomaliesCount,
|
||||
':surge' => 0, // Placeholder for actual surge count if tracked in DB
|
||||
':report_data' => json_encode($reportData)
|
||||
]);
|
||||
|
||||
echo "[".date('Y-m-d H:i:s')."] Report generated for $countryCode. PCI: $averagePci, Share: $marketSharePct%\n";
|
||||
}
|
||||
|
||||
echo "[".date('Y-m-d H:i:s')."] cron_weekly_health_report completed successfully.\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "[".date('Y-m-d H:i:s')."] Error: " . $e->getMessage() . "\n";
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
// =========================================================
|
||||
// backend/bot/fill_durations.php
|
||||
// One-off script to fill missing duration_min values
|
||||
// in the scraped_competitor_prices table using Intaleq Maps.
|
||||
// =========================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
require_once __DIR__ . '/../core/osrm_routing.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Fetching rows where duration_min IS NULL...\n";
|
||||
|
||||
// Get rows needing update
|
||||
$stmt = $con->prepare("
|
||||
SELECT id, start_lat, start_lng, end_lat, end_lng, country_code
|
||||
FROM scraped_competitor_prices
|
||||
WHERE duration_min IS NULL
|
||||
AND start_lat IS NOT NULL
|
||||
AND start_lng IS NOT NULL
|
||||
");
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$rows || count($rows) === 0) {
|
||||
echo "No rows found needing duration updates.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "Found " . count($rows) . " rows to update. Processing...\n";
|
||||
|
||||
$updateStmt = $con->prepare("
|
||||
UPDATE scraped_competitor_prices
|
||||
SET duration_min = :duration_min, distance_km = :distance_km
|
||||
WHERE id = :id
|
||||
");
|
||||
|
||||
$successCount = 0;
|
||||
$failCount = 0;
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
$id = $row['id'];
|
||||
$countryCode = $row['country_code'];
|
||||
|
||||
// Call GraphHopper / Intaleq Maps
|
||||
$routeInfo = getOsrmRouteDetails($row['start_lat'], $row['start_lng'], $row['end_lat'], $row['end_lng'], $countryCode);
|
||||
|
||||
if ($routeInfo && isset($routeInfo['duration_min']) && isset($routeInfo['distance_km'])) {
|
||||
$updateStmt->execute([
|
||||
':duration_min' => (int) round($routeInfo['duration_min']),
|
||||
':distance_km' => $routeInfo['distance_km'],
|
||||
':id' => $id
|
||||
]);
|
||||
$successCount++;
|
||||
echo "Row $id: Distance {$routeInfo['distance_km']}km, Duration " . round($routeInfo['duration_min']) . "min [SUCCESS]\n";
|
||||
} else {
|
||||
$failCount++;
|
||||
echo "Row $id: Failed to fetch route details.\n";
|
||||
}
|
||||
|
||||
// Small sleep to avoid hitting API rate limits too hard
|
||||
usleep(100000); // 100ms
|
||||
}
|
||||
|
||||
echo "======================================\n";
|
||||
echo "Completed processing " . count($rows) . " rows.\n";
|
||||
echo "Success: $successCount\n";
|
||||
echo "Failed: $failCount\n";
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
// =========================================================
|
||||
// backend/bot/generate_10_tasks_cli.php
|
||||
// CLI script: Clears tasks.json and generates 10 individual tasks
|
||||
// in STANDARD format (start_location/end_location at top level).
|
||||
//
|
||||
// Usage: php generate_10_tasks_cli.php
|
||||
// Run this on the server where tasks.json is located.
|
||||
// =========================================================
|
||||
|
||||
define('TASKS_FILE', __DIR__ . '/tasks.json');
|
||||
|
||||
$ammanLocations = [
|
||||
['name' => 'Abdoun', 'lat' => 31.9392, 'lng' => 35.8942],
|
||||
['name' => 'Jabal Amman', 'lat' => 31.9511, 'lng' => 35.9189],
|
||||
['name' => 'Sweileh', 'lat' => 32.0167, 'lng' => 35.8333],
|
||||
['name' => 'Khalda', 'lat' => 31.9861, 'lng' => 35.8450],
|
||||
['name' => 'Al-Jubaiha', 'lat' => 32.0194, 'lng' => 35.8753],
|
||||
['name' => 'Tla Al-Ali', 'lat' => 31.9961, 'lng' => 35.8647],
|
||||
['name' => 'Shmeisani', 'lat' => 31.9680, 'lng' => 35.9020],
|
||||
['name' => 'Um Uthaina', 'lat' => 31.9610, 'lng' => 35.8770],
|
||||
['name' => 'Marj Al-Hamam', 'lat' => 31.9000, 'lng' => 35.8500],
|
||||
['name' => 'Al-Muqabalain', 'lat' => 31.8720, 'lng' => 35.8900],
|
||||
['name' => 'Al-Qweismeh', 'lat' => 31.8900, 'lng' => 35.9200],
|
||||
['name' => 'Dabouq', 'lat' => 31.9960, 'lng' => 35.8115],
|
||||
['name' => 'Al-Madina', 'lat' => 31.8500, 'lng' => 35.8000],
|
||||
['name' => 'Sports City', 'lat' => 31.9820, 'lng' => 35.8880],
|
||||
];
|
||||
|
||||
$tripPairs = [
|
||||
[13, 5], // Sports City → Tla Al-Ali (~2km)
|
||||
[6, 0], // Shmeisani → Abdoun (~3km)
|
||||
[7, 1], // Um Uthaina → Jabal Amman (~4km)
|
||||
[3, 13], // Khalda → Sports City (~5km)
|
||||
[4, 2], // Al-Jubaiha → Sweileh (~5km)
|
||||
[0, 8], // Abdoun → Marj Al-Hamam (~6km)
|
||||
[1, 10], // Jabal Amman → Al-Qweismeh (~9km)
|
||||
[6, 9], // Shmeisani → Al-Muqabalain (~11km)
|
||||
[2, 12], // Sweileh → Al-Madina (~17km)
|
||||
[5, 11], // Tla Al-Ali → Dabouq (~5km)
|
||||
];
|
||||
|
||||
// Step 1: Clear old tasks
|
||||
file_put_contents(TASKS_FILE, json_encode([]));
|
||||
echo "Cleared tasks.json\n";
|
||||
|
||||
// Step 2: Generate 10 standard tasks
|
||||
$tasks = [];
|
||||
foreach ($tripPairs as $pair) {
|
||||
$start = $ammanLocations[$pair[0]];
|
||||
$end = $ammanLocations[$pair[1]];
|
||||
$taskId = "prc_" . uniqid();
|
||||
|
||||
$tasks[] = [
|
||||
'task_id' => $taskId,
|
||||
'type' => 'price_check',
|
||||
'app' => 'com.taxif.passenger',
|
||||
'start_location' => $start['name'],
|
||||
'end_location' => $end['name'],
|
||||
'payload' => [
|
||||
'start_lat' => $start['lat'],
|
||||
'start_lng' => $start['lng'],
|
||||
'end_lat' => $end['lat'],
|
||||
'end_lng' => $end['lng'],
|
||||
],
|
||||
];
|
||||
echo " {$start['name']} → {$end['name']} ($taskId)\n";
|
||||
}
|
||||
|
||||
file_put_contents(TASKS_FILE, json_encode($tasks, JSON_PRETTY_PRINT));
|
||||
echo "\nDone! Generated " . count($tasks) . " standard tasks in tasks.json\n";
|
||||
echo "The bot will process them one-by-one via normal polling.\n";
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
// =========================================================
|
||||
// backend/bot/generate_price_tasks.php
|
||||
// Cron Job: Runs every 15 minutes
|
||||
// Generates random Short & Long trips in Damascus
|
||||
// =========================================================
|
||||
|
||||
// CLI or Web execution
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed\n");
|
||||
}
|
||||
|
||||
// 1. Ensure Table Exists
|
||||
$sql = "
|
||||
CREATE TABLE IF NOT EXISTS `scraped_competitor_prices` (
|
||||
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`task_id` varchar(100) DEFAULT NULL,
|
||||
`app_name` varchar(100) NOT NULL,
|
||||
`competitor_name` varchar(100) NOT NULL,
|
||||
`start_location` varchar(255) NOT NULL,
|
||||
`end_location` varchar(255) NOT NULL,
|
||||
`start_lat` decimal(10,7) DEFAULT NULL,
|
||||
`start_lng` decimal(10,7) DEFAULT NULL,
|
||||
`end_lat` decimal(10,7) DEFAULT NULL,
|
||||
`end_lng` decimal(10,7) DEFAULT NULL,
|
||||
`price_amount` decimal(8,2) NOT NULL,
|
||||
`price_per_km` decimal(8,2) NOT NULL,
|
||||
`distance_km` decimal(8,2) DEFAULT NULL,
|
||||
`duration_min` int DEFAULT NULL,
|
||||
`currency` varchar(10) NOT NULL DEFAULT 'JOD',
|
||||
`country_code` varchar(10) NOT NULL DEFAULT 'JO',
|
||||
`scraped_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY `idx_app_name` (`app_name`),
|
||||
KEY `idx_competitor_name` (`competitor_name`),
|
||||
KEY `idx_start_location` (`start_location`),
|
||||
KEY `idx_country_code` (`country_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
";
|
||||
$con->exec($sql);
|
||||
|
||||
// [AI Formula Discovery] Create table for reverse-engineered formulas
|
||||
$sqlFormula = "
|
||||
CREATE TABLE IF NOT EXISTS `competitor_secret_formulas` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`competitor_name` varchar(100) NOT NULL,
|
||||
`country_code` varchar(10) NOT NULL,
|
||||
`tier` varchar(20) DEFAULT 'standard',
|
||||
`base_fare` decimal(8,3) NOT NULL,
|
||||
`price_per_km` decimal(8,3) NOT NULL,
|
||||
`price_per_min` decimal(8,3) NOT NULL,
|
||||
`min_fare` decimal(8,3) DEFAULT 0,
|
||||
`rmse` decimal(10,4) DEFAULT 0,
|
||||
`r_squared` decimal(10,4) DEFAULT 0,
|
||||
`surge_multiplier` decimal(5,3) DEFAULT 1.0,
|
||||
`sample_size` int DEFAULT 0,
|
||||
`last_updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `idx_comp_country_tier` (`competitor_name`, `country_code`, `tier`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
";
|
||||
$con->exec($sqlFormula);
|
||||
|
||||
// Auto-patch existing table if upgrading
|
||||
try {
|
||||
$con->exec("ALTER TABLE `scraped_competitor_prices` ADD COLUMN `distance_km` decimal(8,2) DEFAULT NULL AFTER `price_per_km`");
|
||||
$con->exec("ALTER TABLE `scraped_competitor_prices` ADD COLUMN `duration_min` int DEFAULT NULL AFTER `distance_km`");
|
||||
} catch (Exception $e) {
|
||||
// Columns might already exist, ignore error
|
||||
}
|
||||
|
||||
|
||||
// 2. Ten Key Regions in Damascus (Syria) and Amman (Jordan)
|
||||
$countriesConfig = [
|
||||
'SY' => [
|
||||
'competitors' => ['yallago', 'zaken', 'tufaddal'],
|
||||
'regions' => [
|
||||
['name' => 'Umayyad Square', 'lat' => 33.5138, 'lng' => 36.2765],
|
||||
['name' => 'Mezzeh', 'lat' => 33.5074, 'lng' => 36.2530],
|
||||
['name' => 'Malki', 'lat' => 33.5220, 'lng' => 36.2840],
|
||||
['name' => 'Kafersouseh', 'lat' => 33.4981, 'lng' => 36.2730],
|
||||
['name' => 'Al-Midan', 'lat' => 33.4947, 'lng' => 36.2995],
|
||||
['name' => 'Bab Tuma', 'lat' => 33.5126, 'lng' => 36.3150],
|
||||
['name' => 'Rukneddine', 'lat' => 33.5350, 'lng' => 36.2950],
|
||||
['name' => 'Dummar', 'lat' => 33.5385, 'lng' => 36.2250],
|
||||
['name' => 'Baramkeh', 'lat' => 33.5100, 'lng' => 36.2885],
|
||||
['name' => 'Muhajireen', 'lat' => 33.5320, 'lng' => 36.2720],
|
||||
]
|
||||
],
|
||||
'JO' => [
|
||||
'competitors' => ['com.careem.acma', 'com.ubercab', 'com.taxif.passenger', 'me.com.easytaxi'],
|
||||
'regions' => [
|
||||
['name' => 'Abdoun', 'lat' => 31.9392, 'lng' => 35.8942],
|
||||
['name' => 'Jabal Amman', 'lat' => 31.9511, 'lng' => 35.9189],
|
||||
['name' => 'Sweileh', 'lat' => 32.0167, 'lng' => 35.8333],
|
||||
['name' => 'Khalda', 'lat' => 31.9861, 'lng' => 35.8450],
|
||||
['name' => 'Al-Jubaiha', 'lat' => 32.0194, 'lng' => 35.8753],
|
||||
['name' => 'Tla Al-Ali', 'lat' => 31.9961, 'lng' => 35.8647],
|
||||
['name' => 'Shmeisani', 'lat' => 31.9680, 'lng' => 35.9020],
|
||||
['name' => 'Um Uthaina', 'lat' => 31.9610, 'lng' => 35.8770],
|
||||
['name' => 'Jabal Al-Weibdeh', 'lat' => 31.9560, 'lng' => 35.9220],
|
||||
['name' => 'Marj Al-Hamam', 'lat' => 31.9000, 'lng' => 35.8500],
|
||||
['name' => '7th Circle', 'lat' => 31.9525, 'lng' => 35.8600],
|
||||
['name' => 'Zarqa New', 'lat' => 32.0833, 'lng' => 36.1000],
|
||||
['name' => 'Zarqa Camp', 'lat' => 32.0667, 'lng' => 36.0833],
|
||||
['name' => 'Russeifa', 'lat' => 32.0167, 'lng' => 36.0500],
|
||||
['name' => 'Marka', 'lat' => 31.9833, 'lng' => 35.9833],
|
||||
['name' => 'Taj Mall', 'lat' => 31.9423, 'lng' => 35.9038],
|
||||
['name' => 'City Mall', 'lat' => 31.9806, 'lng' => 35.8353],
|
||||
['name' => 'Mecca Mall', 'lat' => 31.9792, 'lng' => 35.8464],
|
||||
['name' => 'Abdali Mall', 'lat' => 31.9644, 'lng' => 35.9117],
|
||||
['name' => 'Alrabieh', 'lat' => 31.9722, 'lng' => 35.8814],
|
||||
['name' => 'Dabouq', 'lat' => 31.9989, 'lng' => 35.8153],
|
||||
['name' => 'Al-Fuheis', 'lat' => 32.0125, 'lng' => 35.7761],
|
||||
['name' => 'Wadi Al-Seer', 'lat' => 31.9533, 'lng' => 35.8197],
|
||||
['name' => 'Bayader', 'lat' => 31.9472, 'lng' => 35.8236],
|
||||
['name' => 'Tariq', 'lat' => 32.0083, 'lng' => 35.9417],
|
||||
['name' => 'Hashmi Shamali', 'lat' => 31.9800, 'lng' => 35.9556],
|
||||
['name' => 'Jabal Al-Hussein', 'lat' => 31.9639, 'lng' => 35.9231],
|
||||
['name' => 'Downtown Amman', 'lat' => 31.9517, 'lng' => 35.9333],
|
||||
['name' => 'Al-Ashrafiyeh', 'lat' => 31.9361, 'lng' => 35.9417],
|
||||
['name' => 'Al-Muqabalain', 'lat' => 31.9011, 'lng' => 35.9228],
|
||||
['name' => 'Al-Wehdat', 'lat' => 31.9250, 'lng' => 35.9417],
|
||||
['name' => 'Abu Nuseir', 'lat' => 32.0469, 'lng' => 35.8750],
|
||||
['name' => 'Shafa Badran', 'lat' => 32.0417, 'lng' => 35.9083],
|
||||
['name' => 'Al-Zaytoonah Uni', 'lat' => 31.8319, 'lng' => 35.9281],
|
||||
['name' => 'German Jordan Uni','lat' => 31.7778, 'lng' => 35.8114],
|
||||
['name' => 'Queen Alia Airport','lat'=> 31.7225, 'lng' => 35.9933],
|
||||
['name' => 'Irbid Center', 'lat' => 32.5514, 'lng' => 35.8500],
|
||||
['name' => 'Jerash', 'lat' => 32.2817, 'lng' => 35.8917],
|
||||
['name' => 'Salt', 'lat' => 32.0333, 'lng' => 35.7333],
|
||||
['name' => 'Madaba', 'lat' => 31.7167, 'lng' => 35.7950],
|
||||
['name' => 'Dead Sea Hotels', 'lat' => 31.7161, 'lng' => 35.5878]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
// Helper to generate a random point within a radius (in km)
|
||||
function generateRandomPoint($lat, $lng, $radius) {
|
||||
$radiusInDegrees = $radius / 111.0; // 1 degree is ~111km
|
||||
$u = lcg_value();
|
||||
$v = lcg_value();
|
||||
$w = $radiusInDegrees * sqrt($u);
|
||||
$t = 2 * pi() * $v;
|
||||
$x = $w * cos($t);
|
||||
$y = $w * sin($t);
|
||||
|
||||
// Adjust longitude based on latitude
|
||||
$new_lng = $x / cos(deg2rad($lat));
|
||||
$new_lat = $y;
|
||||
|
||||
return [
|
||||
'lat' => $lat + $new_lat,
|
||||
'lng' => $lng + $new_lng
|
||||
];
|
||||
}
|
||||
|
||||
$tasksCreated = 0;
|
||||
|
||||
foreach ($countriesConfig as $countryCode => $config) {
|
||||
$competitors = $config['competitors'];
|
||||
$regions = $config['regions'];
|
||||
|
||||
// Randomly select 20 regions (or all if less than 20)
|
||||
shuffle($regions);
|
||||
$selectedRegions = array_slice($regions, 0, 20);
|
||||
|
||||
foreach ($selectedRegions as $region) {
|
||||
// A. Generate Start Point (within 2km of region center)
|
||||
$start = generateRandomPoint($region['lat'], $region['lng'], 2);
|
||||
|
||||
// B. Generate Short Trip (2-5 km from start)
|
||||
$shortDist = rand(20, 50) / 10.0;
|
||||
$shortEnd = generateRandomPoint($start['lat'], $start['lng'], $shortDist);
|
||||
|
||||
// C. Generate Long Trip (10-15 km from start)
|
||||
$longDist = rand(100, 150) / 10.0;
|
||||
$longEnd = generateRandomPoint($start['lat'], $start['lng'], $longDist);
|
||||
|
||||
$trips = [$shortEnd, $longEnd];
|
||||
|
||||
foreach ($trips as $end) {
|
||||
foreach ($competitors as $app) {
|
||||
$taskId = "prc_" . uniqid();
|
||||
|
||||
$taskData = [
|
||||
"task_id" => $taskId,
|
||||
"type" => "price_check",
|
||||
"app" => $app,
|
||||
"payload" => [
|
||||
"start_lat" => $start['lat'],
|
||||
"start_lng" => $start['lng'],
|
||||
"end_lat" => $end['lat'],
|
||||
"end_lng" => $end['lng']
|
||||
]
|
||||
];
|
||||
|
||||
// Push to Redis Queue
|
||||
$redis->lpush('queue:bot:tasks', json_encode($taskData));
|
||||
$tasksCreated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "Successfully generated and queued $tasksCreated pricing tasks.\n";
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
// =========================================================
|
||||
// backend/bot/push_10_tasks.php
|
||||
// CLI script: Clears old Redis queue and pushes 10 individual
|
||||
// tasks in the STANDARD format (start_location/end_location at top level)
|
||||
// so the cf696d05 (single-trip) Kotlin code can process them.
|
||||
// =========================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
die("This script must be run from CLI.\n");
|
||||
}
|
||||
|
||||
if (!$redis) {
|
||||
die("Redis connection failed. Check your .env config.\n");
|
||||
}
|
||||
|
||||
$ammanLocations = [
|
||||
['name' => 'Abdoun', 'lat' => 31.9392, 'lng' => 35.8942],
|
||||
['name' => 'Jabal Amman', 'lat' => 31.9511, 'lng' => 35.9189],
|
||||
['name' => 'Sweileh', 'lat' => 32.0167, 'lng' => 35.8333],
|
||||
['name' => 'Khalda', 'lat' => 31.9861, 'lng' => 35.8450],
|
||||
['name' => 'Al-Jubaiha', 'lat' => 32.0194, 'lng' => 35.8753],
|
||||
['name' => 'Tla Al-Ali', 'lat' => 31.9961, 'lng' => 35.8647],
|
||||
['name' => 'Shmeisani', 'lat' => 31.9680, 'lng' => 35.9020],
|
||||
['name' => 'Um Uthaina', 'lat' => 31.9610, 'lng' => 35.8770],
|
||||
['name' => 'Marj Al-Hamam', 'lat' => 31.9000, 'lng' => 35.8500],
|
||||
['name' => 'Al-Muqabalain', 'lat' => 31.8720, 'lng' => 35.8900],
|
||||
['name' => 'Al-Qweismeh', 'lat' => 31.8900, 'lng' => 35.9200],
|
||||
['name' => 'Hashmi Al-Janoubi', 'lat' => 31.9350, 'lng' => 35.9350],
|
||||
['name' => 'Al-Madina', 'lat' => 31.8500, 'lng' => 35.8000],
|
||||
['name' => 'Sports City', 'lat' => 31.9820, 'lng' => 35.8880],
|
||||
];
|
||||
|
||||
$tripPairs = [
|
||||
[13, 5], // Sports City → Tla Al-Ali (~2km)
|
||||
[6, 0], // Shmeisani → Abdoun (~3km)
|
||||
[7, 1], // Um Uthaina → Jabal Amman (~4km)
|
||||
[3, 13], // Khalda → Sports City (~5km)
|
||||
[4, 2], // Al-Jubaiha → Sweileh (~5km)
|
||||
[0, 8], // Abdoun → Marj Al-Hamam (~6km)
|
||||
[1, 10], // Jabal Amman → Al-Qweismeh (~9km)
|
||||
[6, 9], // Shmeisani → Al-Muqabalain (~11km)
|
||||
[2, 12], // Sweileh → Al-Madina (~17km)
|
||||
[5, 11], // Tla Al-Ali → Hashmi (~5km)
|
||||
];
|
||||
|
||||
// Step 1: Clear the old queue
|
||||
$redis->del('queue:bot:tasks');
|
||||
echo "Cleared old queue: queue:bot:tasks\n";
|
||||
|
||||
// Step 2: Push 10 individual tasks in STANDARD format
|
||||
$count = 0;
|
||||
foreach ($tripPairs as $i => $pair) {
|
||||
$start = $ammanLocations[$pair[0]];
|
||||
$end = $ammanLocations[$pair[1]];
|
||||
$taskId = "prc_" . uniqid();
|
||||
|
||||
$task = [
|
||||
'task_id' => $taskId,
|
||||
'type' => 'price_check',
|
||||
'app' => 'com.taxif.passenger',
|
||||
'start_location' => $start['name'],
|
||||
'end_location' => $end['name'],
|
||||
'payload' => [
|
||||
'start_lat' => $start['lat'],
|
||||
'start_lng' => $start['lng'],
|
||||
'end_lat' => $end['lat'],
|
||||
'end_lng' => $end['lng'],
|
||||
],
|
||||
];
|
||||
|
||||
$redis->lpush('queue:bot:tasks', json_encode($task));
|
||||
$count++;
|
||||
echo " [$count] Pushed: {$start['name']} → {$end['name']} (task_id: $taskId)\n";
|
||||
}
|
||||
|
||||
echo "\nDone! Pushed $count individual tasks to Redis queue.\n";
|
||||
echo "The bot will process them one-by-one through normal polling (every ~15s).\n";
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
// =========================================================
|
||||
// backend/bot/recalculate_all_distances.php
|
||||
// One-off script to recalculate distance_km and duration_min
|
||||
// for ALL rows in the scraped_competitor_prices table using Intaleq Maps.
|
||||
// =========================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
require_once __DIR__ . '/../core/osrm_routing.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Fetching ALL rows to recalculate distance and duration...\n";
|
||||
|
||||
// Get all valid rows
|
||||
$stmt = $con->prepare("
|
||||
SELECT id, start_lat, start_lng, end_lat, end_lng, country_code, price_amount
|
||||
FROM scraped_competitor_prices
|
||||
WHERE start_lat IS NOT NULL
|
||||
AND start_lng IS NOT NULL
|
||||
");
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$rows || count($rows) === 0) {
|
||||
echo "No rows found.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "Found " . count($rows) . " rows to update. Processing...\n";
|
||||
|
||||
$updateStmt = $con->prepare("
|
||||
UPDATE scraped_competitor_prices
|
||||
SET duration_min = :duration_min,
|
||||
distance_km = :distance_km,
|
||||
price_per_km = :price_per_km
|
||||
WHERE id = :id
|
||||
");
|
||||
|
||||
$successCount = 0;
|
||||
$failCount = 0;
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
$id = $row['id'];
|
||||
$countryCode = $row['country_code'] ?? 'JO';
|
||||
$price = (float)$row['price_amount'];
|
||||
|
||||
// Call Intaleq Maps (OSRM API)
|
||||
$routeInfo = getOsrmRouteDetails($row['start_lat'], $row['start_lng'], $row['end_lat'], $row['end_lng'], $countryCode);
|
||||
|
||||
if ($routeInfo && isset($routeInfo['duration_min']) && isset($routeInfo['distance_km'])) {
|
||||
$dist = (float)$routeInfo['distance_km'];
|
||||
$dur = (int)round($routeInfo['duration_min']);
|
||||
$pricePerKm = $dist > 0 ? ($price / $dist) : 0;
|
||||
|
||||
$updateStmt->execute([
|
||||
':duration_min' => $dur,
|
||||
':distance_km' => $dist,
|
||||
':price_per_km' => $pricePerKm,
|
||||
':id' => $id
|
||||
]);
|
||||
$successCount++;
|
||||
echo "Row $id: Distance {$dist}km, Duration {$dur}min, Price/Km {$pricePerKm} [SUCCESS]\n";
|
||||
} else {
|
||||
$failCount++;
|
||||
echo "Row $id: Failed to fetch route details.\n";
|
||||
}
|
||||
|
||||
// Small sleep to avoid hitting API rate limits too hard (100ms)
|
||||
usleep(100000);
|
||||
}
|
||||
|
||||
echo "======================================\n";
|
||||
echo "Completed processing " . count($rows) . " rows.\n";
|
||||
echo "Success: $successCount\n";
|
||||
echo "Failed: $failCount\n";
|
||||
@@ -0,0 +1,927 @@
|
||||
<?php
|
||||
// ============================================================================
|
||||
// backend/bot/standalone_worker.php
|
||||
// Standalone database-free / Redis-free Task Server & Dashboard for Android Scraper Bot
|
||||
// ============================================================================
|
||||
|
||||
define('TASKS_FILE', __DIR__ . '/tasks.json');
|
||||
define('RESULTS_FILE', __DIR__ . '/results.json');
|
||||
define('SECRET_KEY', 'SIRO_BOT_SUPER_SECRET_123'); // Matches WorkerClient.kt secret key
|
||||
|
||||
// Ensure JSON data files exist
|
||||
if (!file_exists(TASKS_FILE)) {
|
||||
file_put_contents(TASKS_FILE, json_encode([]));
|
||||
}
|
||||
if (!file_exists(RESULTS_FILE)) {
|
||||
file_put_contents(RESULTS_FILE, json_encode([]));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helper Security functions
|
||||
// ----------------------------------------------------------------------------
|
||||
function validateSignature($device_id, $ts, $sig, $secret_key) {
|
||||
// Prevent replay attacks (valid for 15 minutes to allow debugging tolerance)
|
||||
if (abs(time() - $ts) > 900) {
|
||||
return false;
|
||||
}
|
||||
// Generate the expected signature
|
||||
$expected_sig = hash_hmac('sha256', $device_id . $ts, $secret_key);
|
||||
// Secure comparison
|
||||
return hash_equals($expected_sig, $sig);
|
||||
}
|
||||
|
||||
function jsonError($message, $code = 400) {
|
||||
http_response_code($code);
|
||||
echo json_encode(['status' => 'failure', 'message' => $message]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// API Request Routing
|
||||
// ----------------------------------------------------------------------------
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
|
||||
// Check if request is API call from the Bot
|
||||
$isApiCall = false;
|
||||
$device_id = null;
|
||||
$ts = null;
|
||||
$sig = null;
|
||||
|
||||
if ($method === 'GET' && isset($_GET['device_id'], $_GET['ts'], $_GET['sig'])) {
|
||||
$isApiCall = true;
|
||||
$device_id = $_GET['device_id'];
|
||||
$ts = intval($_GET['ts']);
|
||||
$sig = $_GET['sig'];
|
||||
} elseif ($method === 'POST') {
|
||||
// Check if JSON body has device credentials
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$input = json_decode($rawInput, true);
|
||||
if ($input && isset($input['device_id'], $input['ts'], $input['sig'])) {
|
||||
$isApiCall = true;
|
||||
$device_id = $input['device_id'];
|
||||
$ts = intval($input['ts']);
|
||||
$sig = $input['sig'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($isApiCall) {
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
|
||||
// Validate signature
|
||||
if (!validateSignature($device_id, $ts, $sig, SECRET_KEY)) {
|
||||
jsonError("Unauthorized device signature or expired timestamp. Device ID: $device_id, ts: $ts", 401);
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
// Dequeue one task for this bot (FIFO)
|
||||
$tasks = json_decode(file_get_contents(TASKS_FILE), true);
|
||||
|
||||
if (count($tasks) > 0) {
|
||||
$task = array_shift($tasks);
|
||||
file_put_contents(TASKS_FILE, json_encode($tasks, JSON_PRETTY_PRINT));
|
||||
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"has_task" => true,
|
||||
"task" => $task
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"has_task" => false
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
} elseif ($method === 'POST') {
|
||||
// Record Scrape result
|
||||
if (empty($input['task_id']) || empty($input['status'])) {
|
||||
jsonError("Missing required parameters in payload");
|
||||
}
|
||||
|
||||
$results = json_decode(file_get_contents(RESULTS_FILE), true);
|
||||
|
||||
$newResult = [
|
||||
'task_id' => $input['task_id'],
|
||||
'device_id' => $device_id,
|
||||
'status' => $input['status'], // 'success' or 'failed'
|
||||
'type' => $input['type'] ?? 'price_check',
|
||||
'recorded_at' => date('Y-m-d H:i:s'),
|
||||
'result_data' => $input['result_data'] ?? []
|
||||
];
|
||||
|
||||
array_unshift($results, $newResult);
|
||||
file_put_contents(RESULTS_FILE, json_encode($results, JSON_PRETTY_PRINT));
|
||||
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"message" => "Result recorded successfully"
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Admin Dashboard UI (Rendered on normal GET browser requests)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Handle Admin Actions
|
||||
$message = '';
|
||||
$msgType = 'success';
|
||||
|
||||
if (isset($_POST['action'])) {
|
||||
if ($_POST['action'] === 'add_task') {
|
||||
$app = $_POST['app'] ?? '';
|
||||
$start_loc = $_POST['start_location'] ?? '';
|
||||
$end_loc = $_POST['end_location'] ?? '';
|
||||
$start_lat = floatval($_POST['start_lat'] ?? 0.0);
|
||||
$start_lng = floatval($_POST['start_lng'] ?? 0.0);
|
||||
$end_lat = floatval($_POST['end_lat'] ?? 0.0);
|
||||
$end_lng = floatval($_POST['end_lng'] ?? 0.0);
|
||||
|
||||
if ($app && $start_loc && $end_loc) {
|
||||
$tasks = json_decode(file_get_contents(TASKS_FILE), true);
|
||||
$taskId = "prc_" . uniqid();
|
||||
|
||||
$newTask = [
|
||||
"task_id" => $taskId,
|
||||
"type" => "price_check",
|
||||
"app" => $app,
|
||||
"start_location" => $start_loc,
|
||||
"end_location" => $end_loc,
|
||||
"payload" => [
|
||||
"start_lat" => $start_lat,
|
||||
"start_lng" => $start_lng,
|
||||
"end_lat" => $end_lat,
|
||||
"end_lng" => $end_lng
|
||||
]
|
||||
];
|
||||
|
||||
$tasks[] = $newTask;
|
||||
file_put_contents(TASKS_FILE, json_encode($tasks, JSON_PRETTY_PRINT));
|
||||
$message = "Task successfully added and queued! Task ID: $taskId";
|
||||
} else {
|
||||
$message = "Please fill in all required fields.";
|
||||
$msgType = 'error';
|
||||
}
|
||||
} elseif ($_POST['action'] === 'generate_batch_trips' || $_POST['action'] === 'generate_10_trips') {
|
||||
$app = $_POST['app'] ?? 'com.taxif.passenger';
|
||||
$numTrips = isset($_POST['num_trips']) ? (int)$_POST['num_trips'] : 30;
|
||||
|
||||
$ammanLocations = [
|
||||
['name' => 'Abdoun', 'lat' => 31.9392, 'lng' => 35.8942],
|
||||
['name' => 'Jabal Amman', 'lat' => 31.9511, 'lng' => 35.9189],
|
||||
['name' => 'Sweileh', 'lat' => 32.0167, 'lng' => 35.8333],
|
||||
['name' => 'Khalda', 'lat' => 31.9861, 'lng' => 35.8450],
|
||||
['name' => 'Al-Jubaiha', 'lat' => 32.0194, 'lng' => 35.8753],
|
||||
['name' => 'Tla Al-Ali', 'lat' => 31.9961, 'lng' => 35.8647],
|
||||
['name' => 'Shmeisani', 'lat' => 31.9680, 'lng' => 35.9020],
|
||||
['name' => 'Um Uthaina', 'lat' => 31.9610, 'lng' => 35.8770],
|
||||
['name' => 'Marj Al-Hamam', 'lat' => 31.9000, 'lng' => 35.8500],
|
||||
['name' => 'Al-Muqabalain', 'lat' => 31.8720, 'lng' => 35.8900],
|
||||
['name' => 'Al-Qweismeh', 'lat' => 31.8900, 'lng' => 35.9200],
|
||||
['name' => 'Al-Madina', 'lat' => 31.8500, 'lng' => 35.8000],
|
||||
['name' => 'Sports City', 'lat' => 31.9820, 'lng' => 35.8880],
|
||||
['name' => 'Tabarbour', 'lat' => 31.9960, 'lng' => 35.9520],
|
||||
['name' => 'Marka', 'lat' => 31.9810, 'lng' => 35.9860],
|
||||
['name' => 'Abu Nuseir', 'lat' => 32.0460, 'lng' => 35.8820],
|
||||
['name' => 'Shafa Badran', 'lat' => 32.0430, 'lng' => 35.8950],
|
||||
['name' => 'Al-Bayader', 'lat' => 31.9540, 'lng' => 35.8230],
|
||||
['name' => 'Wadi Al-Seer', 'lat' => 31.9520, 'lng' => 35.8160],
|
||||
['name' => 'Dahiyet Al-Rasheed','lat' => 32.0110, 'lng' => 35.8710],
|
||||
['name' => 'Tariq', 'lat' => 32.0080, 'lng' => 35.9380],
|
||||
['name' => 'Ras Al-Ain', 'lat' => 31.9420, 'lng' => 35.9270],
|
||||
['name' => 'Al-Yarmouk', 'lat' => 31.9300, 'lng' => 35.9400],
|
||||
];
|
||||
|
||||
$tasks = json_decode(file_get_contents(TASKS_FILE), true);
|
||||
$count = 0;
|
||||
|
||||
for ($i = 0; $i < $numTrips; $i++) {
|
||||
$startIndex = array_rand($ammanLocations);
|
||||
do {
|
||||
$endIndex = array_rand($ammanLocations);
|
||||
} while ($endIndex === $startIndex);
|
||||
|
||||
$start = $ammanLocations[$startIndex];
|
||||
$end = $ammanLocations[$endIndex];
|
||||
$taskId = "prc_" . uniqid();
|
||||
|
||||
$newTask = [
|
||||
'task_id' => $taskId,
|
||||
'type' => 'price_check',
|
||||
'app' => $app,
|
||||
'start_location' => $start['name'],
|
||||
'end_location' => $end['name'],
|
||||
'payload' => [
|
||||
'start_lat' => $start['lat'],
|
||||
'start_lng' => $start['lng'],
|
||||
'end_lat' => $end['lat'],
|
||||
'end_lng' => $end['lng'],
|
||||
],
|
||||
];
|
||||
$tasks[] = $newTask;
|
||||
$count++;
|
||||
}
|
||||
file_put_contents(TASKS_FILE, json_encode($tasks, JSON_PRETTY_PRINT));
|
||||
$message = "Generated $count random standard tasks for $app to map surge zones!";
|
||||
} elseif ($_POST['action'] === 'clear_tasks') {
|
||||
file_put_contents(TASKS_FILE, json_encode([]));
|
||||
$message = "Task queue cleared successfully.";
|
||||
} elseif ($_POST['action'] === 'clear_results') {
|
||||
file_put_contents(RESULTS_FILE, json_encode([]));
|
||||
$message = "Scrape results history cleared successfully.";
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch stats and lists for display
|
||||
$currentTasks = json_decode(file_get_contents(TASKS_FILE), true);
|
||||
$scrapedResults = json_decode(file_get_contents(RESULTS_FILE), true);
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Siro Bot - Standalone Server Control Panel</title>
|
||||
<!-- Premium Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=Space+Grotesk:wght@400;700&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #0b0f19;
|
||||
--card-bg: rgba(22, 28, 45, 0.45);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--accent-color: #3b82f6;
|
||||
--accent-gradient: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
--text-color: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--success-color: #10b981;
|
||||
--error-color: #ef4444;
|
||||
--glass-blur: blur(12px);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
background-image:
|
||||
radial-gradient(at 10% 10%, rgba(29, 78, 216, 0.15) 0px, transparent 50%),
|
||||
radial-gradient(at 90% 90%, rgba(16, 185, 129, 0.1) 0px, transparent 50%);
|
||||
color: var(--text-color);
|
||||
min-height: 100vh;
|
||||
padding: 2rem 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header design */
|
||||
header {
|
||||
margin-bottom: 2.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.logo-section h1 {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(to right, #3b82f6, #60a5fa, #10b981);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.logo-section p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid var(--success-color);
|
||||
color: var(--success-color);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--success-color);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.9); opacity: 0.6; }
|
||||
50% { transform: scale(1.2); opacity: 1; }
|
||||
100% { transform: scale(0.9); opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Notification alert */
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 2rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Grid Dashboard Layout */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
border-radius: 20px;
|
||||
padding: 1.8rem;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 15px 35px rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Form Inputs */
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
input[type="text"], select {
|
||||
width: 100%;
|
||||
padding: 0.8rem 1rem;
|
||||
background: rgba(10, 15, 30, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: var(--accent-gradient);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 4px 15px rgba(29, 78, 216, 0.4);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(29, 78, 216, 0.55);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #e5e7eb;
|
||||
box-shadow: none;
|
||||
font-weight: 600;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 8px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* List components */
|
||||
.list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.list-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.list-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.list-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
background: rgba(10, 15, 30, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background: rgba(10, 15, 30, 0.6);
|
||||
}
|
||||
|
||||
.task-info h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.task-info p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge-app {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-yallago { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }
|
||||
.app-zaken { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
|
||||
.app-tufaddal { background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
|
||||
.app-careem { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
|
||||
.app-uber { background: rgba(255, 255, 255, 0.1); color: #f3f4f6; border: 1px solid rgba(255, 255, 255, 0.2); }
|
||||
.app-taxif { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
|
||||
.app-jeeny { background: rgba(139, 92, 246, 0.15); color: #a78bfa; border: 1px solid rgba(139, 92, 246, 0.3); }
|
||||
|
||||
.time-badge {
|
||||
font-size: 0.80rem;
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
}
|
||||
|
||||
.result-success { border-left: 4px solid var(--success-color); }
|
||||
.result-failed { border-left: 4px solid var(--error-color); }
|
||||
|
||||
.price-text {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #fff;
|
||||
border-bottom-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="logo-section">
|
||||
<h1 id="main-title">Siro Bot Control Center</h1>
|
||||
<p>Standalone API Endpoint & Simulation Controller Panel</p>
|
||||
</div>
|
||||
<div class="badge-status" id="server-status">
|
||||
<span class="status-dot"></span>
|
||||
Server Online
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<?php if ($message): ?>
|
||||
<div class="alert alert-<?php echo $msgType; ?>" id="info-alert">
|
||||
<span><?php echo htmlspecialchars($message); ?></span>
|
||||
<button class="btn-secondary" onclick="document.getElementById('info-alert').style.display='none'">Dismiss</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Left Side: Task Generator Form -->
|
||||
<div class="card">
|
||||
<h2 class="card-title">
|
||||
Task Generator
|
||||
</h2>
|
||||
<form method="POST" action="">
|
||||
<input type="hidden" name="action" value="add_task">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="app">Competitor Application</label>
|
||||
<select name="app" id="app" required>
|
||||
<option value="ae.com.yalla.go.dubai.client">YallaGo (ae.com.yalla.go.dubai.client)</option>
|
||||
<option value="com.zakinn.app">Zaken / Zakinn (com.zakinn.app)</option>
|
||||
<option value="com.bis.taxi">Tfadal (com.bis.taxi)</option>
|
||||
<option value="com.careem.acma">Careem (com.careem.acma)</option>
|
||||
<option value="com.ubercab">Uber (com.ubercab)</option>
|
||||
<option value="com.taxif.passenger">TaxiF (com.taxif.passenger)</option>
|
||||
<option value="me.com.easytaxi">Jeeny (me.com.easytaxi)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="start_location">Start Location (Text hint)</label>
|
||||
<input type="text" name="start_location" id="start_location" placeholder="e.g. Abdoun, Amman" required value="Abdoun">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="end_location">End Location (Text hint)</label>
|
||||
<input type="text" name="end_location" id="end_location" placeholder="e.g. Queen Alia Airport, Amman" required value="Queen Alia Airport">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="start_lat">Start Latitude</label>
|
||||
<input type="text" name="start_lat" id="start_lat" placeholder="31.9392" value="31.9392">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="start_lng">Start Longitude</label>
|
||||
<input type="text" name="start_lng" id="start_lng" placeholder="35.8942" value="35.8942">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="end_lat">End Latitude</label>
|
||||
<input type="text" name="end_lat" id="end_lat" placeholder="31.7225" value="31.7225">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="end_lng">End Longitude</label>
|
||||
<input type="text" name="end_lng" id="end_lng" placeholder="35.9892" value="35.9892">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" id="submit-task-btn">Queue Scraping Task</button>
|
||||
</form>
|
||||
|
||||
<!-- Batch generator form -->
|
||||
<form method="POST" action="" style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border-color);">
|
||||
<input type="hidden" name="action" value="generate_batch_trips">
|
||||
<h3 style="font-size:1rem; font-weight:600; margin-bottom:0.75rem; color:#fff;">Generate Random Amman Trips</h3>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 0.75rem;">
|
||||
<label for="batch_app">Target App</label>
|
||||
<select name="app" id="batch_app" required style="padding: 0.5rem; font-size: 0.85rem; width: 100%; background: rgba(10, 15, 30, 0.6); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; color: #fff;">
|
||||
<option value="com.taxif.passenger">TaxiF (com.taxif.passenger)</option>
|
||||
<option value="me.com.easytaxi">Jeeny (me.com.easytaxi)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 1rem;">
|
||||
<label for="num_trips">Number of Trips</label>
|
||||
<input type="number" name="num_trips" id="num_trips" value="30" min="5" max="100" required style="width:100%; padding: 0.5rem; font-size: 0.85rem; background: rgba(10, 15, 30, 0.6); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; color: #fff;">
|
||||
</div>
|
||||
|
||||
<p style="font-size:0.85rem; color:var(--text-muted); margin-bottom:1rem;">
|
||||
Queues random standard tasks (one per trip) with varied distances for the selected app to map surge zones.
|
||||
</p>
|
||||
<button type="submit" class="btn" id="batch-btn" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);">
|
||||
Generate Random Trips
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Lists Dashboard -->
|
||||
<div class="card">
|
||||
<div class="tabs-header">
|
||||
<button id="tab-btn-tasks" class="tab-btn active" onclick="switchTab('tasks')">Pending Tasks (<?php echo count($currentTasks); ?>)</button>
|
||||
<button id="tab-btn-results" class="tab-btn" onclick="switchTab('results')">Scrape History (<?php echo count($scrapedResults); ?>)</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Tasks Queue -->
|
||||
<div id="tasks-tab" class="tab-content active">
|
||||
<div class="card-title" style="border:none; margin-bottom:1rem; font-size:1rem; color:var(--text-muted);">
|
||||
<span>Queued task list (Sent to bot via GET request)</span>
|
||||
<?php if (count($currentTasks) > 0): ?>
|
||||
<form method="POST" style="margin: 0;">
|
||||
<input type="hidden" name="action" value="clear_tasks">
|
||||
<button type="submit" class="btn-secondary" id="clear-tasks-btn">Clear All</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div id="tasks-list-container" class="list-container">
|
||||
<?php if (count($currentTasks) === 0): ?>
|
||||
<div class="empty-state">No pending tasks. Queue some tasks using the form on the left!</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($currentTasks as $task):
|
||||
$app = $task['app'] ?? '';
|
||||
if ($app === 'ae.com.yalla.go.dubai.client') {
|
||||
$appLabel = 'yallago';
|
||||
} elseif ($app === 'com.zakinn.app') {
|
||||
$appLabel = 'zaken';
|
||||
} elseif ($app === 'com.bis.taxi') {
|
||||
$appLabel = 'tufaddal';
|
||||
} elseif ($app === 'com.careem.acma') {
|
||||
$appLabel = 'careem';
|
||||
} elseif ($app === 'com.ubercab') {
|
||||
$appLabel = 'uber';
|
||||
} elseif ($app === 'com.taxif.passenger') {
|
||||
$appLabel = 'taxif';
|
||||
} elseif ($app === 'me.com.easytaxi') {
|
||||
$appLabel = 'jeeny';
|
||||
} else {
|
||||
$appLabel = 'unknown';
|
||||
}
|
||||
?>
|
||||
<div class="list-item">
|
||||
<div class="task-info">
|
||||
<h4>
|
||||
<span class="badge-app app-<?php echo $appLabel; ?>"><?php echo htmlspecialchars($appLabel); ?></span>
|
||||
<span><?php echo htmlspecialchars($task['task_id']); ?></span>
|
||||
</h4>
|
||||
<?php
|
||||
$isBatch = ($task['type'] ?? '') === 'batch_multi_trip';
|
||||
if ($isBatch && isset($task['trips'])) {
|
||||
$tripCount = count($task['trips']);
|
||||
$firstTrip = $task['trips'][0];
|
||||
echo '<p>Batch: <strong>' . $tripCount . ' trips</strong> | First: <strong>' . htmlspecialchars($firstTrip['start_location']) . '</strong> → <strong>' . htmlspecialchars($firstTrip['end_location']) . '</strong></p>';
|
||||
} else {
|
||||
echo '<p>Route: <strong>' . htmlspecialchars($task['start_location']) . '</strong> → <strong>' . htmlspecialchars($task['end_location']) . '</strong></p>';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<div class="time-badge">
|
||||
Pending
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Scrape History -->
|
||||
<div id="results-tab" class="tab-content">
|
||||
<div class="card-title" style="border:none; margin-bottom:1rem; font-size:1rem; color:var(--text-muted);">
|
||||
<span>Results submitted by Bot via POST request</span>
|
||||
<?php if (count($scrapedResults) > 0): ?>
|
||||
<form method="POST" style="margin: 0;">
|
||||
<input type="hidden" name="action" value="clear_results">
|
||||
<button type="submit" class="btn-secondary" id="clear-results-btn">Clear History</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div id="results-list-container" class="list-container">
|
||||
<?php if (count($scrapedResults) === 0): ?>
|
||||
<div class="empty-state">No scraping results recorded yet. Results will appear here when the Android bot submits them.</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($scrapedResults as $res):
|
||||
$data = $res['result_data'];
|
||||
$app = $data['app'] ?? 'unknown';
|
||||
if (strpos($app, 'yalla') !== false) {
|
||||
$appLabel = 'yallago';
|
||||
} elseif (strpos($app, 'zakinn') !== false || strpos($app, 'zaken') !== false) {
|
||||
$appLabel = 'zaken';
|
||||
} elseif (strpos($app, 'bis.taxi') !== false || strpos($app, 'tufaddal') !== false) {
|
||||
$appLabel = 'tufaddal';
|
||||
} elseif (strpos($app, 'careem') !== false) {
|
||||
$appLabel = 'careem';
|
||||
} elseif (strpos($app, 'ubercab') !== false || strpos($app, 'uber') !== false) {
|
||||
$appLabel = 'uber';
|
||||
} elseif (strpos($app, 'taxif') !== false) {
|
||||
$appLabel = 'taxif';
|
||||
} elseif (strpos($app, 'easytaxi') !== false || strpos($app, 'jeeny') !== false) {
|
||||
$appLabel = 'jeeny';
|
||||
} else {
|
||||
$appLabel = 'unknown';
|
||||
}
|
||||
$isSuccess = ($res['status'] === 'success');
|
||||
$price = $data['price'] ?? 0;
|
||||
$dist = $data['distance_km'] ?? 0;
|
||||
?>
|
||||
<div class="list-item <?php echo $isSuccess ? 'result-success' : 'result-failed'; ?>">
|
||||
<div class="task-info">
|
||||
<h4>
|
||||
<span class="badge-app app-<?php echo $appLabel; ?>"><?php echo htmlspecialchars($appLabel); ?></span>
|
||||
<span>Task ID: <?php echo htmlspecialchars($res['task_id']); ?></span>
|
||||
</h4>
|
||||
<p>Device: <?php echo htmlspecialchars($res['device_id']); ?></p>
|
||||
<p>Distance: <strong><?php echo number_format($dist, 2); ?> km</strong> | Time: <?php echo htmlspecialchars($res['recorded_at']); ?></p>
|
||||
</div>
|
||||
<div class="price-text">
|
||||
<?php if ($isSuccess): ?>
|
||||
<?php
|
||||
$currency = (in_array($appLabel, ['careem', 'uber', 'taxif', 'jeeny'])) ? 'JOD' : 'SYP';
|
||||
$decimals = ($currency === 'JOD') ? 2 : 0;
|
||||
echo number_format($price, $decimals) . ' ' . $currency;
|
||||
?>
|
||||
<?php else: ?>
|
||||
<span style="color:var(--error-color); font-size:0.9rem;">Scrape Failed</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(tabId) {
|
||||
// Remove active classes
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||
|
||||
if (tabId === 'tasks') {
|
||||
document.getElementById('tab-btn-tasks').classList.add('active');
|
||||
document.getElementById('tasks-tab').classList.add('active');
|
||||
} else {
|
||||
document.getElementById('tab-btn-results').classList.add('active');
|
||||
document.getElementById('results-tab').classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto refresh dashboard data every 3 seconds
|
||||
setInterval(() => {
|
||||
fetch(window.location.href)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const newTasksBtn = doc.getElementById('tab-btn-tasks');
|
||||
const newTasksList = doc.getElementById('tasks-list-container');
|
||||
const currentTasksBtn = document.getElementById('tab-btn-tasks');
|
||||
const currentTasksList = document.getElementById('tasks-list-container');
|
||||
|
||||
if (newTasksBtn && currentTasksBtn) {
|
||||
currentTasksBtn.innerHTML = newTasksBtn.innerHTML;
|
||||
}
|
||||
if (newTasksList && currentTasksList) {
|
||||
currentTasksList.innerHTML = newTasksList.innerHTML;
|
||||
}
|
||||
|
||||
const newResultsBtn = doc.getElementById('tab-btn-results');
|
||||
const newResultsList = doc.getElementById('results-list-container');
|
||||
const currentResultsBtn = document.getElementById('tab-btn-results');
|
||||
const currentResultsList = document.getElementById('results-list-container');
|
||||
|
||||
if (newResultsBtn && currentResultsBtn) {
|
||||
currentResultsBtn.innerHTML = newResultsBtn.innerHTML;
|
||||
}
|
||||
if (newResultsList && currentResultsList) {
|
||||
currentResultsList.innerHTML = newResultsList.innerHTML;
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Failed to fetch updates', err));
|
||||
}, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
// =========================================================
|
||||
// backend/bot/update_jordan_kazan.php
|
||||
// Updates the base pricing for Jordan based on the agreed strategy
|
||||
// =========================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Updating Kazan pricing for Jordan...\n";
|
||||
|
||||
// Update the kazan table where country is Jordan
|
||||
$sql = "UPDATE kazan
|
||||
SET startPrice = '0.35',
|
||||
speedPrice = '0.22',
|
||||
fixedPrice = '0.22',
|
||||
awfarPrice = '0.19',
|
||||
normalMinPrice = '0.05',
|
||||
peakMinPrice = '0.06',
|
||||
lateMinPrice = '0.05'
|
||||
WHERE country = 'Jordan' OR country = 'JO'";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$result = $stmt->execute();
|
||||
|
||||
if ($result) {
|
||||
$rowCount = $stmt->rowCount();
|
||||
echo "Successfully updated $rowCount row(s) for Jordan in the kazan table.\n";
|
||||
echo "New Pricing: Base=0.35 JOD, Per Km=0.22 JOD, Per Min=0.05 JOD\n";
|
||||
} else {
|
||||
echo "Failed to update kazan table.\n";
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Database connection failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// backend/bot/worker.php
|
||||
// Endpoint for the standalone Android Bot (Worker Node)
|
||||
// Handles Wallet Payments (ShamCash) and Competitor Pricing
|
||||
// =========================================================
|
||||
|
||||
// 1. Security & Configuration
|
||||
$SECRET_KEY = getenv('BOT_SECRET_KEY');
|
||||
if (!$SECRET_KEY) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Server configuration error: BOT_SECRET_KEY not set in environment']);
|
||||
exit;
|
||||
}
|
||||
$ALLOWED_DEVICES = ['SHAM_CASH_BOT_01', 'PRICE_SCRAPER_BOT_01', '77a9528112ef7c2a'];
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// 2. Validate Security (HMAC-SHA256 Signature)
|
||||
function validateSignature($device_id, $ts, $sig, $secret_key) {
|
||||
// Prevent replay attacks (valid for 5 minutes)
|
||||
if (abs(time() - $ts) > 300) {
|
||||
return false;
|
||||
}
|
||||
// Generate the expected signature
|
||||
$expected_sig = hash_hmac('sha256', $device_id . $ts, $secret_key);
|
||||
// Secure comparison to prevent timing attacks
|
||||
return hash_equals($expected_sig, $sig);
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
// ---------------------------------------------------------
|
||||
// GET: Fetch Pending Task for the Bot
|
||||
// ---------------------------------------------------------
|
||||
$device_id = filterRequest('device_id');
|
||||
$ts = filterRequest('ts');
|
||||
$sig = filterRequest('sig');
|
||||
|
||||
if (!$device_id || !$ts || !$sig) {
|
||||
jsonError("Missing security parameters");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!in_array($device_id, $ALLOWED_DEVICES)) {
|
||||
jsonError("Device not authorized: " . $device_id);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!validateSignature($device_id, $ts, $sig, $SECRET_KEY)) {
|
||||
jsonError("Invalid signature or expired timestamp");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check Redis Queue for tasks
|
||||
// Queue Name: queue:bot:tasks (Using Main Redis)
|
||||
$taskJson = $redis->rpop('queue:bot:tasks');
|
||||
|
||||
if ($taskJson) {
|
||||
$task = json_decode($taskJson, true);
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"has_task" => true,
|
||||
"task" => $task
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"has_task" => false
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
|
||||
} elseif ($method === 'POST') {
|
||||
// ---------------------------------------------------------
|
||||
// POST: Submit Result from the Bot
|
||||
// ---------------------------------------------------------
|
||||
// Read raw JSON body
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$input) {
|
||||
jsonError("Invalid JSON body");
|
||||
exit;
|
||||
}
|
||||
|
||||
$device_id = $input['device_id'] ?? null;
|
||||
$ts = $input['ts'] ?? null;
|
||||
$sig = $input['sig'] ?? null;
|
||||
$task_id = $input['task_id'] ?? null;
|
||||
$task_status = $input['status'] ?? null; // 'success' or 'failed'
|
||||
$result_data = $input['result_data'] ?? [];
|
||||
|
||||
if (!$device_id || !$ts || !$sig || !$task_id || !$task_status) {
|
||||
jsonError("Missing required parameters");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!in_array($device_id, $ALLOWED_DEVICES)) {
|
||||
jsonError("Device not authorized");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!validateSignature($device_id, $ts, $sig, $SECRET_KEY)) {
|
||||
jsonError("Invalid signature or expired timestamp");
|
||||
exit;
|
||||
}
|
||||
|
||||
$task_type = $input['type'] ?? 'payment';
|
||||
|
||||
// Process the result based on task type
|
||||
if ($task_status === 'success') {
|
||||
if ($task_type === 'price_check') {
|
||||
$app_name = $result_data['app'] ?? 'unknown';
|
||||
$price = (float)($result_data['price'] ?? 0);
|
||||
$distance_km = (float)($result_data['distance_km'] ?? 0);
|
||||
$start_lat = (float)($result_data['start_lat'] ?? 0);
|
||||
$start_lng = (float)($result_data['start_lng'] ?? 0);
|
||||
$end_lat = (float)($result_data['end_lat'] ?? 0);
|
||||
$end_lng = (float)($result_data['end_lng'] ?? 0);
|
||||
|
||||
$pricePerKm = $distance_km > 0 ? ($price / $distance_km) : 0.0;
|
||||
$country_code = $result_data['country_code'] ?? 'SY';
|
||||
|
||||
// --- OSRM Integration (Dynamic Distance & Duration) ---
|
||||
require_once __DIR__ . '/../core/osrm_routing.php';
|
||||
$duration_min = 0;
|
||||
if ($start_lat != 0 && $end_lat != 0) {
|
||||
$osrmResult = getOsrmRouteDetails($start_lat, $start_lng, $end_lat, $end_lng, $country_code);
|
||||
if ($osrmResult) {
|
||||
$distance_km = $osrmResult['distance_km']; // Override with accurate OSRM distance
|
||||
$duration_min = $osrmResult['duration_min'];
|
||||
$pricePerKm = $distance_km > 0 ? ($price / $distance_km) : 0.0; // Recalculate based on OSRM
|
||||
}
|
||||
}
|
||||
// ------------------------------------------------------
|
||||
|
||||
// 1. Save to MySQL
|
||||
$stmt = $con->prepare("
|
||||
INSERT INTO scraped_competitor_prices
|
||||
(task_id, app_name, competitor_name, start_location, end_location, start_lat, start_lng, end_lat, end_lng, price_amount, price_per_km, distance_km, duration_min, currency, country_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$start_loc = "Lat: $start_lat, Lng: $start_lng";
|
||||
$end_loc = "Lat: $end_lat, Lng: $end_lng";
|
||||
$stmt->execute([$task_id, $app_name, $app_name, $start_loc, $end_loc, (string)$start_lat, (string)$start_lng, (string)$end_lat, (string)$end_lng, $price, $pricePerKm, $distance_km, $duration_min, 'JOD', $country_code]);
|
||||
|
||||
// 2. Save to Redis (Calculate Price Per KM)
|
||||
if ($distance_km > 0 && $price > 0) {
|
||||
// Store in Redis (Main) to be used by Pricing Engine
|
||||
// Store recent 50 prices for the app
|
||||
$redis->lpush("competitor:price_history:$app_name", $pricePerKm);
|
||||
$redis->ltrim("competitor:price_history:$app_name", 0, 49);
|
||||
|
||||
error_log("[Bot Worker] Price Check $app_name: Dist $distance_km, Price $price, Country $country_code");
|
||||
}
|
||||
} else {
|
||||
// It's a payment task
|
||||
$transaction_id = $result_data['transaction_id'] ?? 'N/A';
|
||||
|
||||
// TODO: Update MySQL driver balance/payout status
|
||||
// $stmt = $con->prepare("UPDATE payouts SET status = 'paid', transaction_ref = ? WHERE task_id = ?");
|
||||
// $stmt->execute([$transaction_id, $task_id]);
|
||||
|
||||
error_log("[Bot Worker] Task $task_id SUCCESS on $device_id. Ref: $transaction_id");
|
||||
}
|
||||
} else {
|
||||
$error_msg = $result_data['error'] ?? 'Unknown Error';
|
||||
error_log("[Bot Worker] Task $task_id FAILED on $device_id. Reason: $error_msg");
|
||||
// Optional: Re-queue the task if it failed due to a temporary issue
|
||||
}
|
||||
|
||||
echo json_encode(["status" => "success", "message" => "Result recorded successfully"]);
|
||||
exit;
|
||||
|
||||
} else {
|
||||
http_response_code(405);
|
||||
jsonError("Method Not Allowed");
|
||||
}
|
||||
Reference in New Issue
Block a user