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:
Hamza-Ayed
2026-07-27 05:14:13 +03:00
co-authored by Claude Opus 5
parent 9909d9b4c1
commit 4d8414c96b
3198 changed files with 766859 additions and 0 deletions
+16
View File
@@ -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";
}
+176
View File
@@ -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";
?>
+17
View File
@@ -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";
}
+336
View File
@@ -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";
+163
View File
@@ -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";
?>
+350
View File
@@ -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";
+138
View File
@@ -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";
+64
View File
@@ -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";
}
+103
View File
@@ -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();
}
?>
+119
View File
@@ -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();
}
?>
+59
View File
@@ -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";
+178
View File
@@ -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";
?>
+257
View File
@@ -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";
+76
View File
@@ -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";
+74
View File
@@ -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');
}
?>
+169
View File
@@ -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";
}
+111
View File
@@ -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";
}
+74
View File
@@ -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";
+72
View File
@@ -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";
+210
View File
@@ -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";
+80
View File
@@ -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";
+81
View File
@@ -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";
+927
View File
@@ -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> &rarr; <strong>' . htmlspecialchars($firstTrip['end_location']) . '</strong></p>';
} else {
echo '<p>Route: <strong>' . htmlspecialchars($task['start_location']) . '</strong> &rarr; <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>
+38
View File
@@ -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";
}
+188
View File
@@ -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");
}