Update codebase
This commit is contained in:
@@ -19,6 +19,14 @@ try {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// كل مخرجات هذا المحرّك تُكتب في Redis (مقارنة السائقين، ملخّص ذروة
|
||||
// المنافسين، المناطق الساخنة)، فلا معنى لمتابعة التشغيل بدونه.
|
||||
if (!$redis) {
|
||||
error_log('[AI Engine] ABORT: Redis unavailable — no cache target to write to.');
|
||||
die("Redis unavailable — aborting AI engine run.\n");
|
||||
}
|
||||
|
||||
echo "Starting Siro AI Engine v2 (Powered by Pricing Engine)...\n";
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -26,6 +26,14 @@ try {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// مفتاح مكافحة التكرار (TTL يوم كامل) يعيش في Redis؛ بدونه يُعاد إرسال
|
||||
// نفس الحملة التسويقية كل ساعة.
|
||||
if (!$redis) {
|
||||
error_log('[AutoMarketingPusher] ABORT: Redis unavailable — anti-spam guard would fail open.');
|
||||
die("Redis unavailable — aborting to avoid duplicate campaign pushes.\n");
|
||||
}
|
||||
|
||||
echo "Starting Auto Marketing Pusher Engine...\n";
|
||||
|
||||
// 1. استخراج الركاب الخاملين في آخر ساعتين (الذين لم يقموا بأي طلب اليوم)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_driver_score.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* يغذّي إشارات ترتيب محرك الإسناد في مفاتيح driver:score:* على Redis.
|
||||
*
|
||||
* بدون هذا الكرون يعمل المحرك بالقرب وملاءمة الوجهة فقط، وتُحسب بقية
|
||||
* المكوّنات محايدة (٠٫٥) — أي أن ٥٠٪ من وزن الترتيب معطّل عملياً:
|
||||
* • معدّل القبول ٢٠٪ ← يُحسب هنا
|
||||
* • التقييم ١٥٪ ← يُحسب هنا
|
||||
* • مدة الخمول ١٥٪ ← يختمها finish_ride_updates.php، لا نلمسها
|
||||
*
|
||||
* ── معدّل القبول ──
|
||||
* لا يوجد في MySQL أي سجل لعرضٍ لم يُقبل: driver_orders يسجّل المقبول
|
||||
* فقط. لذلك يُعدّ العرض في السوكِت (حقل offers) والقبول في acceptRide.php
|
||||
* (حقل accepts)، وهذا الكرون يحوّلهما لنسبة.
|
||||
*
|
||||
* الاضمحلال: نضرب العدّادين بمعامل كل تشغيل، فتصير النسبة نافذة متحرّكة
|
||||
* تعكس السلوك الأخير لا سجلّ العمر كله. سائق أصلح سلوكه يتعافى خلال أيام
|
||||
* بدل أن يظل مسجوناً في ماضيه.
|
||||
*
|
||||
* تنعيم Laplace: سائق عُرض عليه عرضان وقَبِل واحداً لا يستحق ٥٠٪ بثقة
|
||||
* كاملة. نضيف عروضاً وهمية محايدة فتقترب نسبته من المحايد حتى تتراكم
|
||||
* بيانات كافية.
|
||||
*
|
||||
* جدولة مقترحة (crontab): كل ربع ساعة — الإشارات بطيئة التغيّر ولا تستحق أكثر.
|
||||
* الحقل الأول في السطر: الشرطة المائلة ثم 15
|
||||
* ثم: docker compose exec -T php php /var/www/backend/bot/cron_driver_score.php
|
||||
*
|
||||
* ⚠️ يجب أن يعمل داخل حاوية php: Redis لا ينشر منافذ، فتشغيله على المضيف
|
||||
* يعطي $redisLocation = null ويخرج بلا عمل.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
// ── معاملات المعايرة ──
|
||||
const SCORE_DECAY = 0.85; // ما يبقى من العدّادات كل تشغيل
|
||||
const SCORE_PRIOR_OFFERS = 4.0; // عروض وهمية للتنعيم
|
||||
const SCORE_PRIOR_ACCEPTS = 2.0; // منها مقبولة — أي محايد ٠٫٥
|
||||
const SCORE_KEY_TTL = 2592000; // 30 يوماً: سائق اختفى يُنسى وحده
|
||||
|
||||
if (!isset($redisLocation) || !$redisLocation) {
|
||||
fwrite(STDERR, "[driver_score] Redis غير متاح — هل يعمل الكرون داخل حاوية php؟\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, "[driver_score] DB unavailable: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ١) التقييمات — استعلام واحد مجمّع لكل السائقين
|
||||
// نافذة ٩٠ يوماً: تقييم قبل سنة لا يصف السائق اليوم.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$ratings = [];
|
||||
try {
|
||||
$stmt = $con->query("
|
||||
SELECT driver_id, AVG(rating) AS avg_rating, COUNT(*) AS cnt
|
||||
FROM ratingDriver
|
||||
WHERE driver_id IS NOT NULL
|
||||
AND rating IS NOT NULL
|
||||
AND created_at >= NOW() - INTERVAL 90 DAY
|
||||
GROUP BY driver_id
|
||||
");
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
// تقييم واحد أو اثنان لا يكفيان لحكم — ننعّمه نحو المحايد (٣٫٥/٥)
|
||||
$n = (int) $row['cnt'];
|
||||
$avg = (float) $row['avg_rating'];
|
||||
$ratings[$row['driver_id']] = round((($avg * $n) + (3.5 * 3)) / ($n + 3), 3);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "[driver_score] تعذّر جلب التقييمات: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٢) المرور على مفاتيح الدرجات الموجودة
|
||||
// scan لا keys: keys تحجب Redis بالكامل على قاعدة كبيرة.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$processed = 0;
|
||||
$iterator = null;
|
||||
|
||||
// بلا هذا الضبط يرجع phpredis false عند دفعة فارغة فتتوقف الحلقة مبكراً
|
||||
$redisLocation->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY);
|
||||
|
||||
while (($keys = $redisLocation->scan($iterator, 'driver:score:*', 200)) !== false) {
|
||||
foreach ($keys as $key) {
|
||||
$driverId = substr($key, strlen('driver:score:'));
|
||||
if ($driverId === '') continue;
|
||||
|
||||
try {
|
||||
$row = $redisLocation->hGetAll($key) ?: [];
|
||||
$offers = (float) ($row['offers'] ?? 0);
|
||||
$accepts = (float) ($row['accepts'] ?? 0);
|
||||
|
||||
// لا يتجاوز المقبول المعروض — يحدث لو أُعيد ضبط أحدهما وحده.
|
||||
$accepts = min($accepts, $offers);
|
||||
|
||||
$rate = ($accepts + SCORE_PRIOR_ACCEPTS) / ($offers + SCORE_PRIOR_OFFERS);
|
||||
|
||||
$update = ['accept_rate' => round($rate, 4)];
|
||||
|
||||
if (isset($ratings[$driverId])) {
|
||||
$update['rating'] = $ratings[$driverId];
|
||||
}
|
||||
|
||||
// الاضمحلال: نافذة متحرّكة بدل سجل العمر كله.
|
||||
$update['offers'] = round($offers * SCORE_DECAY, 3);
|
||||
$update['accepts'] = round($accepts * SCORE_DECAY, 3);
|
||||
|
||||
$redisLocation->hMSet($key, $update);
|
||||
$redisLocation->expire($key, SCORE_KEY_TTL);
|
||||
$processed++;
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "[driver_score] خطأ على $key: " . $e->getMessage() . "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٣) سائقون لهم تقييم ولم يُعرض عليهم شيء بعد
|
||||
// بلا هذه الخطوة يظل التقييم معطّلاً حتى أول عرض — أي أن السائق
|
||||
// الممتاز الجديد لا يستفيد من تقييمه في أول ترتيب له.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$seeded = 0;
|
||||
foreach ($ratings as $driverId => $rating) {
|
||||
$key = "driver:score:$driverId";
|
||||
try {
|
||||
if (!$redisLocation->exists($key)) {
|
||||
$redisLocation->hMSet($key, [
|
||||
'rating' => $rating,
|
||||
'accept_rate' => round(SCORE_PRIOR_ACCEPTS / SCORE_PRIOR_OFFERS, 4),
|
||||
]);
|
||||
$redisLocation->expire($key, SCORE_KEY_TTL);
|
||||
$seeded++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// تجاهُل — المرور القادم يعالجه
|
||||
}
|
||||
}
|
||||
|
||||
echo "[driver_score] حُدِّث $processed سائق، وأُنشئ $seeded من التقييمات."
|
||||
. " تقييمات محمّلة: " . count($ratings) . "\n";
|
||||
@@ -14,6 +14,13 @@ try {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// الهدف الوحيد لهذا السكربت هو الكتابة إلى مفتاح Redis للكاش.
|
||||
if (!$redis) {
|
||||
error_log('[HeatmapCache] ABORT: Redis unavailable — nothing to cache into.');
|
||||
die("Redis unavailable — aborting heatmap cache generation.\n");
|
||||
}
|
||||
|
||||
echo "Starting Heatmap Cache Generation (Redis)...\n";
|
||||
|
||||
// مربعات المدن الكبرى لتمثيل الدول (لتجنب حساب المضلعات المعقدة)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_insurance_premiums.php — قيد أقساط التأمين
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* يقيّد قسط كل وثيقة نشطة بلغت دورتها. لا يخصم من رصيد أحد:
|
||||
*
|
||||
* رصيد السائق يعيش في payment_server لا في هذه القاعدة، والخصم
|
||||
* المباشر من هنا كان سيعني كتابةً عابرة للحدود بلا معاملة موحّدة —
|
||||
* أي خطر رصيد ناقص بلا قيد يقابله، أو قيد بلا خصم. فنقيّد استحقاقاً
|
||||
* هنا، ويقرأه محرك التسوية `cron_obligation_settlement.php`.
|
||||
*
|
||||
* تحديث (٢٠٢٦-٠٨-٠٨): صار هذا الملف **مُصدِر استحقاق** في محرك
|
||||
* الالتزامات العام، فيكتب في `obligation_ledger` لا في
|
||||
* `insurance_premium_ledger`. سبب النقل أن الدفتر القديم لم يكن يقرأه
|
||||
* أحد إطلاقاً — «تقرأه التسوية» كان وعداً بلا منفّذ، وكل قسط قُيّد منذ
|
||||
* الإطلاق بقي معلّقاً بلا تحصيل. جداول التأمين تبقى كما هي شاهداً
|
||||
* تاريخياً، وقد رُحّلت بياناتها في
|
||||
* `migrations/2026_08_08_obligations_engine.sql`.
|
||||
*
|
||||
* الحماية من الاحتساب المزدوج طبقتان:
|
||||
* ١. `last_charged_on` في الالتزام — نفلتر به قبل العمل
|
||||
* ٢. مفتاح فريد (obligation_id, charge_date) في الدفتر — يحسم السباق
|
||||
* لو عملت نسختان من الكرون معاً
|
||||
*
|
||||
* الأولى وحدها لا تكفي: بين القراءة والكتابة نافذة، والمفتاح الفريد
|
||||
* هو ما يجعل تكرار القيد مستحيلاً لا نادراً.
|
||||
*
|
||||
* جدولة: مرة يومياً بعد منتصف الليل.
|
||||
* 15 0 * * * docker compose exec -T php php /var/www/backend/bot/cron_insurance_premiums.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, '[insurance] DB unavailable: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
// الشهري يُقيَّد على أول الشهر لا على يوم الاشتراك: دفتر تتوزّع فيه
|
||||
// تواريخ القيد على ثلاثين يوماً يستحيل تسويته مع الشريك.
|
||||
$monthStart = date('Y-m-01');
|
||||
|
||||
$charged = 0;
|
||||
$skipped = 0;
|
||||
|
||||
try {
|
||||
// المصدر الآن الالتزامات لا الوثائق. القيد على `kind = 'recurring'`
|
||||
// لا على المنتج التأميني تحديداً: أي منتج دوري يُضاف لاحقاً يُقيَّد
|
||||
// بالمنطق نفسه بلا سطر جديد هنا.
|
||||
$st = $con->query("
|
||||
SELECT o.id, o.driver_id, o.last_charged_on, o.cycle_amount,
|
||||
pr.code AS product_code, pr.currency, pr.billing_cycle
|
||||
FROM driver_obligations o
|
||||
JOIN obligation_products pr ON pr.id = o.product_id
|
||||
WHERE o.status = 'active'
|
||||
AND pr.kind = 'recurring'
|
||||
AND pr.billing_cycle <> 'none'
|
||||
ORDER BY o.id ASC
|
||||
");
|
||||
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $policy) {
|
||||
$chargeDate = $policy['billing_cycle'] === 'monthly' ? $monthStart : $today;
|
||||
|
||||
// الفلترة الرخيصة أولاً — تجنّبنا محاولة إدراج لكل التزام كل يوم.
|
||||
if ($policy['last_charged_on'] === $chargeDate) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
$con->prepare("
|
||||
INSERT INTO obligation_ledger
|
||||
(obligation_id, driver_id, product_code, charge_date,
|
||||
amount, amount_collected, amount_remaining, currency, status)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?, 'pending')
|
||||
")->execute([
|
||||
$policy['id'], $policy['driver_id'], $policy['product_code'], $chargeDate,
|
||||
$policy['cycle_amount'], $policy['cycle_amount'], $policy['currency'],
|
||||
]);
|
||||
|
||||
$con->prepare("
|
||||
UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
|
||||
")->execute([$chargeDate, $policy['id']]);
|
||||
|
||||
$con->commit();
|
||||
$charged++;
|
||||
} catch (PDOException $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
|
||||
// 23000 = خرق المفتاح الفريد: نسخة أخرى سبقتنا للقيد نفسه.
|
||||
// هذه ليست حالة خطأ — هي بالضبط ما صُمّم المفتاح لمنعه.
|
||||
if ($e->getCode() === '23000') {
|
||||
$con->prepare("
|
||||
UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
|
||||
")->execute([$chargeDate, $policy['id']]);
|
||||
$skipped++;
|
||||
} else {
|
||||
error_log("[insurance] تعذّر قيد استحقاق الالتزام #{$policy['id']}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[insurance] فشل المرور: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "[insurance] قُيِّد {$charged} قسطاً، تُخطّي {$skipped}\n";
|
||||
@@ -18,6 +18,14 @@ try {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() تُعيد المتغير العام $redis فقط، وbootstrap.php يتركه null
|
||||
// عند فشل الاتصال — فهي لا ترمي استثناءً والـ try/catch أعلاه لا يلتقط شيئاً.
|
||||
// بدون هذا الفحص كان السكربت ينهار فوراً بـ "call to a member function get() on null".
|
||||
if (!$redis) {
|
||||
error_log('[cron_kazan_adjuster] ABORT: Redis unavailable — Kazan discounts left untouched.');
|
||||
die("Redis unavailable — aborting without touching Kazan discounts.\n");
|
||||
}
|
||||
|
||||
$countries = ['SY', 'JO', 'EG', 'IQ'];
|
||||
|
||||
foreach ($countries as $country) {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_leak_detection.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* كشف التسرب خارج المنصة — الرحلة تحصل فعلاً والعمولة لا تصل.
|
||||
*
|
||||
* ── لماذا خادمياً ──
|
||||
* جدول driver_ride_scam يملؤه تطبيق السائق عن نفسه، فنسخة معدّلة لا
|
||||
* ترسل شيئاً. هذا الكرون يستنتج النمط من بيانات لا يملك السائق تزويرها:
|
||||
* سجل القبول في driver_orders، وحالة الرحلة في ride.
|
||||
*
|
||||
* ── النمط ──
|
||||
* قَبِل السائق → أُلغيت الرحلة → ولم تبدأ قط
|
||||
* مرة واحدة قد تكون صدفة. تكرار نفس زوج (سائق، راكب) ليس صدفة.
|
||||
*
|
||||
* ── العقوبة ──
|
||||
* لا حظر. تُكتب درجة الاشتباه في driver:score:$id (حقل leak_penalty)
|
||||
* فتخفض ترتيب السائق في محرك الإسناد — يخسر السبق والرحلات الأقرب
|
||||
* تدريجياً. موجع بلا حرمان، وقابل للتراجع فوراً إن تبيّن الخطأ.
|
||||
* الحالات عالية الثقة تُترك في driver_leak_events لمراجعة بشرية.
|
||||
*
|
||||
* جدولة مقترحة (crontab): كل ساعة.
|
||||
* docker compose exec -T php php /var/www/backend/bot/cron_leak_detection.php
|
||||
*
|
||||
* ⚠️ يجب أن يعمل داخل حاوية php — Redis لا ينشر منافذ.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
// ── معاملات المعايرة ──
|
||||
const LEAK_WINDOW_DAYS = 30; // نافذة احتساب التكرار
|
||||
const LEAK_SCAN_HOURS = 48; // عمق الفحص في كل مرور (بهامش للتأخر)
|
||||
const LEAK_PENALTY_MAX = 1.0; // سقف العقوبة
|
||||
const LEAK_REVIEW_THRESHOLD = 0.70; // فوقها تستحق مراجعة بشرية
|
||||
|
||||
// الحالات التي تعني "أُلغيت بعد القبول"
|
||||
const LEAK_CANCEL_STATUSES = ['CancelFromDriverAfterApply', 'cancelled_by_passenger'];
|
||||
|
||||
// الحالات التي تعني أن الرحلة بدأت أو اكتملت — وجودها ينفي الاشتباه.
|
||||
// 'arrived' ليست منها عمداً: الوصول ثم الإلغاء هو النمط المشتبه نفسه،
|
||||
// لا نفيه. (عملياً الإلغاء يكتب فوق الحالة، فالفلتر يعمل على النهائية.)
|
||||
const LEAK_STARTED_STATUSES = ['started', 'Begin', 'Finished', 'finished'];
|
||||
|
||||
if (!isset($redisLocation) || !$redisLocation) {
|
||||
fwrite(STDERR, "[leak] Redis غير متاح — هل يعمل الكرون داخل حاوية php؟\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, "[leak] DB unavailable: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ١) الرحلات المشتبهة في النافذة الأخيرة
|
||||
//
|
||||
// ملاحظة: cancel_ride_by_driver.php يصفّر ride.driver_id عند الإلغاء،
|
||||
// فالسائق لا يُعرف من جدول ride. driver_orders هو من يحتفظ بالربط.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$cancelPlaceholders = implode(',', array_fill(0, count(LEAK_CANCEL_STATUSES), '?'));
|
||||
$startedPlaceholders = implode(',', array_fill(0, count(LEAK_STARTED_STATUSES), '?'));
|
||||
|
||||
$sql = "
|
||||
SELECT dord.driver_id, dord.order_id AS ride_id, dord.status AS cancel_status,
|
||||
r.passenger_id
|
||||
FROM driver_orders dord
|
||||
JOIN ride r ON r.id = dord.order_id
|
||||
WHERE dord.status IN ($cancelPlaceholders)
|
||||
AND dord.created_at >= NOW() - INTERVAL " . LEAK_SCAN_HOURS . " HOUR
|
||||
AND r.status NOT IN ($startedPlaceholders)
|
||||
AND dord.driver_id <> '0'
|
||||
AND r.passenger_id <> ''
|
||||
";
|
||||
|
||||
try {
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute(array_merge(LEAK_CANCEL_STATUSES, LEAK_STARTED_STATUSES));
|
||||
$suspects = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "[leak] فشل الفحص: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٢) تقييم كل حالة وتسجيلها
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$pairStmt = $con->prepare("
|
||||
SELECT COUNT(*) FROM driver_leak_events
|
||||
WHERE driver_id = ? AND passenger_id = ?
|
||||
AND created_at >= NOW() - INTERVAL " . LEAK_WINDOW_DAYS . " DAY
|
||||
");
|
||||
|
||||
$appStmt = $con->prepare("
|
||||
SELECT COUNT(*) FROM driver_ride_scam
|
||||
WHERE driverID = ? AND rideID = ?
|
||||
");
|
||||
|
||||
$insertStmt = $con->prepare("
|
||||
INSERT IGNORE INTO driver_leak_events
|
||||
(driver_id, passenger_id, ride_id, cancel_status, pair_repeat, app_reported, confidence)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
|
||||
$inserted = 0;
|
||||
$flagged = 0;
|
||||
|
||||
foreach ($suspects as $s) {
|
||||
try {
|
||||
$pairStmt->execute([$s['driver_id'], $s['passenger_id']]);
|
||||
$pairRepeat = ((int) $pairStmt->fetchColumn()) + 1;
|
||||
|
||||
$appStmt->execute([$s['driver_id'], $s['ride_id']]);
|
||||
$appReported = ((int) $appStmt->fetchColumn()) > 0 ? 1 : 0;
|
||||
|
||||
// ── الثقة ──
|
||||
// حالة منفردة ضعيفة عمداً: الإلغاء بعد القبول له أسباب مشروعة
|
||||
// كثيرة (عطل، خطأ في العنوان، راكب غيّر رأيه). التكرار مع نفس
|
||||
// الراكب هو ما يحوّلها من حادثة إلى نمط.
|
||||
$confidence = 0.25;
|
||||
if ($pairRepeat >= 2) $confidence = 0.60;
|
||||
if ($pairRepeat >= 3) $confidence = 0.85;
|
||||
|
||||
// بلاغ التطبيق (اتصل ثم أُلغيت) يرفع الثقة ولا يصنعها وحده.
|
||||
if ($appReported) $confidence = min(1.0, $confidence + 0.15);
|
||||
|
||||
$insertStmt->execute([
|
||||
$s['driver_id'], $s['passenger_id'], $s['ride_id'],
|
||||
$s['cancel_status'], $pairRepeat, $appReported, round($confidence, 3),
|
||||
]);
|
||||
|
||||
if ($insertStmt->rowCount() > 0) {
|
||||
$inserted++;
|
||||
if ($confidence >= LEAK_REVIEW_THRESHOLD) $flagged++;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "[leak] خطأ على الرحلة {$s['ride_id']}: " . $e->getMessage() . "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٣) تحويل السجل إلى عقوبة في محرك الإسناد
|
||||
//
|
||||
// العقوبة تُحسب من مجموع ثقة أحداث السائق في النافذة، لا من عددها —
|
||||
// فحالتان ضعيفتان لا تساويان حالة واحدة مؤكدة.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$penalized = 0;
|
||||
try {
|
||||
$scoreStmt = $con->prepare("
|
||||
SELECT driver_id, SUM(confidence) AS total
|
||||
FROM driver_leak_events
|
||||
WHERE created_at >= NOW() - INTERVAL " . LEAK_WINDOW_DAYS . " DAY
|
||||
GROUP BY driver_id
|
||||
");
|
||||
$scoreStmt->execute();
|
||||
|
||||
foreach ($scoreStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
// يُشبَع عند مجموع 3 — أي ثلاث حالات مؤكدة تقريباً = العقوبة كاملة.
|
||||
$penalty = min(LEAK_PENALTY_MAX, ((float) $row['total']) / 3.0);
|
||||
$redisLocation->hSet("driver:score:{$row['driver_id']}", 'leak_penalty', round($penalty, 3));
|
||||
$penalized++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "[leak] تعذّر تطبيق العقوبات: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// تصفير عقوبة من خرج من النافذة: بلا هذا تبقى العقوبة أبدية في Redis
|
||||
// حتى لو نظُف سجل السائق تماماً — أي عقوبة بلا انتهاء.
|
||||
try {
|
||||
$iterator = null;
|
||||
$redisLocation->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY);
|
||||
$activeStmt = $con->prepare("
|
||||
SELECT DISTINCT driver_id FROM driver_leak_events
|
||||
WHERE created_at >= NOW() - INTERVAL " . LEAK_WINDOW_DAYS . " DAY
|
||||
");
|
||||
$activeStmt->execute();
|
||||
$active = array_flip($activeStmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
|
||||
while (($keys = $redisLocation->scan($iterator, 'driver:score:*', 200)) !== false) {
|
||||
foreach ($keys as $key) {
|
||||
$driverId = substr($key, strlen('driver:score:'));
|
||||
if ($driverId === '' || isset($active[$driverId])) continue;
|
||||
if ($redisLocation->hExists($key, 'leak_penalty')) {
|
||||
$redisLocation->hDel($key, 'leak_penalty');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "[leak] تعذّر تنظيف العقوبات المنتهية: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "[leak] فُحص " . count($suspects) . " حالة، سُجّل $inserted جديدة،"
|
||||
. " منها $flagged تستحق مراجعة بشرية. عوقب $penalized سائق.\n";
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_obligation_settlement.php — محرك تسوية الالتزامات
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* الجهة **الوحيدة** التي تحوّل قيداً في `obligation_ledger` إلى خصم فعلي
|
||||
* من محفظة السائق. مُصدِرو الاستحقاق (التأمين، الوقود، الصيانة) يقيّدون
|
||||
* فقط ولا يلمسون مالاً — وهذا ما يجعل إضافة منتج جديد لا تمسّ أخطر
|
||||
* منطق في المنصة.
|
||||
*
|
||||
* هذا الملف يغلق ثغرة إنتاج قائمة: `cron_insurance_premiums.php` يقيّد
|
||||
* الأقساط منذ إطلاقه وتعليقه يقول «تقرأه التسوية» — ولم تكن التسوية
|
||||
* موجودة. كل قسط قُيّد حتى اليوم بقي `pending` بلا تحصيل.
|
||||
*
|
||||
* لماذا كرون منفصل لا خصم عند إنهاء الرحلة؟
|
||||
* لأن `finish_ride_updates.php` مسار حرج والسائق ينتظر رده. نداء شبكي
|
||||
* إلى سيرفر المحفظة داخله يعني تعليق شاشة السائق على بطء طرف ثالث،
|
||||
* وفشلاً في التسوية يظهر للسائق كفشل في إنهاء رحلته.
|
||||
*
|
||||
* ضمانات عدم التكرار طبقتان:
|
||||
* ١. `last_attempt_on` في القيد — محاولة واحدة لكل قيد في اليوم
|
||||
* ٢. `settlement_ref` فريد في سيرفر المحفظة — يحسم السباق ويجعل
|
||||
* إعادة النداء بعد انقطاع شبكي آمنة بالتعريف
|
||||
*
|
||||
* الأولى وحدها لا تكفي: نداء قد ينجح في المحفظة ثم ينقطع قبل أن يصل
|
||||
* الرد، فيبقى القيد بلا تحديث. الطبقة الثانية تجعل إعادة المحاولة تُرجع
|
||||
* نتيجة الخصم الأول بدل خصم ثانٍ.
|
||||
*
|
||||
* جدولة: مرة يومياً في آخر اليوم لا بعد قيد الاستحقاقات مباشرة —
|
||||
* السقف نسبة من أرباح اليوم، وقراءتها بعد منتصف الليل تعني سقفاً صفرياً
|
||||
* كل ليلة وتحصيلاً لا يقع أبداً.
|
||||
* 30 23 * * * docker compose exec -T php php /var/www/backend/bot/cron_obligation_settlement.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../obligations/functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, '[obligation] DB unavailable: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$sharedKey = getenv('S2S_SHARED_KEY') ?: '';
|
||||
if ($sharedKey === '') {
|
||||
// بلا مفتاح لن ينجح أي نداء. الخروج المبكر أوضح من مئة محاولة
|
||||
// تعود 401 وتزيد `attempts` بلا سبب حقيقي.
|
||||
fwrite(STDERR, "[obligation] S2S_SHARED_KEY غير معرّف — لا تسوية\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* ينادي نقطة الخصم ويُرجع ما خُصم فعلاً، أو null عند فشل لا يمكن تفسيره.
|
||||
*
|
||||
* التمييز بين «خُصم صفر» و«فشل» جوهري: الأول حالة طبيعية (لا أرباح
|
||||
* اليوم) تُسجَّل محاولةً، والثاني لا يجوز أن يُحسب محاولة — وإلا ضاع
|
||||
* يوم تحصيل بسبب عطل شبكي عابر.
|
||||
*/
|
||||
function obligationDeduct(string $ref, string $driverId, string $productCode,
|
||||
float $amount, float $capPercent): ?float
|
||||
{
|
||||
$payload = obligationWalletCall(
|
||||
'/v2/main/ride/driverWallet/deduct_s2s_obligation.php',
|
||||
[
|
||||
'settlement_ref' => $ref,
|
||||
'driverID' => $driverId,
|
||||
'product_code' => $productCode,
|
||||
'amount' => number_format($amount, 3, '.', ''),
|
||||
'cap_percent' => number_format($capPercent, 2, '.', ''),
|
||||
]
|
||||
);
|
||||
|
||||
if ($payload === null || !isset($payload['deducted'])) {
|
||||
error_log("[obligation] تعذّر الحصول على نتيجة خصم ($ref)");
|
||||
return null;
|
||||
}
|
||||
|
||||
return round((float) $payload['deducted'], 3);
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$settled = 0;
|
||||
$partial = 0;
|
||||
$idle = 0;
|
||||
$failed = 0;
|
||||
$total = 0.0;
|
||||
|
||||
try {
|
||||
// الترتيب مقصود: السائق أولاً ليكون سقفه اليومي مُستهلَكاً بترتيب
|
||||
// معلوم، ثم أولوية المنتج (التأمين قبل الوقود قبل الصيانة)، ثم
|
||||
// الأقدم فالأحدث — الدَّين الأقدم أولى بالسداد.
|
||||
$st = $con->query("
|
||||
SELECT l.id, l.obligation_id, l.driver_id, l.product_code,
|
||||
l.amount_remaining, l.amount_collected, l.amount, l.attempts,
|
||||
p.daily_cap_percent, p.kind
|
||||
FROM obligation_ledger l
|
||||
JOIN driver_obligations o ON o.id = l.obligation_id
|
||||
JOIN obligation_products p ON p.id = o.product_id
|
||||
WHERE l.status IN ('pending', 'partial')
|
||||
AND l.amount_remaining > 0
|
||||
AND (l.last_attempt_on IS NULL OR l.last_attempt_on <> CURDATE())
|
||||
ORDER BY l.driver_id ASC, p.priority ASC, l.charge_date ASC
|
||||
");
|
||||
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$ledgerId = (int) $row['id'];
|
||||
$remaining = round((float) $row['amount_remaining'], 3);
|
||||
|
||||
// المرجع مبنيّ على عدّاد المحاولات لا على تاريخ اليوم. الفرق ليس
|
||||
// تجميلياً: `attempts` لا يزيد إلا بعد تحديث الدفتر بنجاح، فنداء
|
||||
// نجح في المحفظة وضاع ردّه يُعاد لاحقاً — ولو بعد أيام — بالمرجع
|
||||
// نفسه، فتُرجع المحفظة خصمه الأول بدل تنفيذ خصم ثانٍ. مرجعٌ
|
||||
// مبنيّ على التاريخ كان سيخصم المبلغ مرتين في هذه الحالة بالضبط.
|
||||
$ref = "obl_{$ledgerId}_{$row['attempts']}";
|
||||
|
||||
$deducted = obligationDeduct(
|
||||
$ref, $row['driver_id'], $row['product_code'],
|
||||
$remaining, (float) $row['daily_cap_percent']
|
||||
);
|
||||
|
||||
if ($deducted === null) {
|
||||
// لا نلمس القيد إطلاقاً — ولا حتى `attempts`. قد يكون الخصم
|
||||
// تمّ في المحفظة وضاع الرد، وإبقاء العدّاد كما هو يضمن أن
|
||||
// المحاولة القادمة تحمل المرجع نفسه فتستردّ نتيجة الأولى.
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$collected = round((float) $row['amount_collected'] + $deducted, 3);
|
||||
$newRemaining = round((float) $row['amount'] - $collected, 3);
|
||||
if ($newRemaining < 0) {
|
||||
$newRemaining = 0.0;
|
||||
}
|
||||
|
||||
if ($newRemaining <= 0) {
|
||||
$status = 'settled';
|
||||
} elseif ($collected > 0) {
|
||||
$status = 'partial';
|
||||
} else {
|
||||
$status = 'pending';
|
||||
}
|
||||
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
$con->prepare("
|
||||
UPDATE obligation_ledger
|
||||
SET amount_collected = ?, amount_remaining = ?, status = ?,
|
||||
attempts = attempts + 1, last_attempt_on = ?,
|
||||
settled_at = CASE WHEN ? = 'settled' THEN NOW() ELSE settled_at END
|
||||
WHERE id = ?
|
||||
")->execute([$collected, $newRemaining, $status, $today, $status, $ledgerId]);
|
||||
|
||||
// المقسّط وحده يراكم أصلاً مسدَّداً. الدوري بلا نهاية فلا
|
||||
// معنى لأصلٍ ينفد عنده.
|
||||
if ($deducted > 0 && $row['kind'] === 'installment') {
|
||||
$con->prepare("
|
||||
UPDATE driver_obligations
|
||||
SET principal_paid = principal_paid + ?
|
||||
WHERE id = ?
|
||||
")->execute([$deducted, (int) $row['obligation_id']]);
|
||||
|
||||
// سداد الأصل كاملاً يُنهي الالتزام. بدون هذا يبقى قرض
|
||||
// مسدَّد «نشطاً» فيظهر للسائق ويزاحم غيره على السقف.
|
||||
$con->prepare("
|
||||
UPDATE driver_obligations
|
||||
SET status = 'completed', ended_at = CURDATE()
|
||||
WHERE id = ? AND status = 'active'
|
||||
AND principal_amount > 0
|
||||
AND principal_paid >= principal_amount
|
||||
")->execute([(int) $row['obligation_id']]);
|
||||
}
|
||||
|
||||
$con->commit();
|
||||
} catch (PDOException $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
// الخصم تمّ فعلاً والقيد لم يُحدَّث — أخطر حالة هنا. لا إعادة
|
||||
// محاولة تلقائية: المرجع نفسه سيُرجع الخصم الأول لا خصماً
|
||||
// جديداً، لكن التباين بين الدفترين يحتاج عيناً بشرية.
|
||||
error_log("[obligation] خُصم {$deducted} للقيد #{$ledgerId} ولم يُحدَّث الدفتر: " . $e->getMessage());
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$total += $deducted;
|
||||
if ($status === 'settled') $settled++;
|
||||
elseif ($deducted > 0) $partial++;
|
||||
else $idle++;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[obligation] فشل المرور: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
printf(
|
||||
"[obligation] سُوِّي %d، جزئي %d، بلا خصم %d، فشل %d — إجمالي %.3f\n",
|
||||
$settled, $partial, $idle, $failed, $total
|
||||
);
|
||||
@@ -28,6 +28,14 @@ try {
|
||||
die("[ReEngagement] Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// مفتاح مكافحة التكرار يعيش في Redis؛ بدونه يفشل الفحص "مفتوحاً"
|
||||
// فيتلقّى كل راكب نفس رسالة الاستعادة في كل تشغيل.
|
||||
if (!$redis) {
|
||||
error_log('[ReEngagement] ABORT: Redis unavailable — anti-spam guard would fail open.');
|
||||
die("[ReEngagement] Redis unavailable — aborting to avoid duplicate pushes.\n");
|
||||
}
|
||||
|
||||
echo "[ReEngagement] Starting Passenger Re-engagement Engine...\n";
|
||||
|
||||
$REDIS_TTL = 72 * 3600; // anti-spam: 72 ساعة
|
||||
|
||||
@@ -34,6 +34,14 @@ try {
|
||||
die("[PredictiveDemand] Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// الإيقاف هنا مقصود: مفتاح مكافحة التكرار يعيش في Redis، وبدونه سيُعاد
|
||||
// إرسال نفس الإشعارات للركاب في كل تشغيل.
|
||||
if (!$redis) {
|
||||
error_log('[PredictiveDemand] ABORT: Redis unavailable — anti-spam guard would fail open.');
|
||||
die("[PredictiveDemand] Redis unavailable — aborting to avoid duplicate pushes.\n");
|
||||
}
|
||||
|
||||
echo "[PredictiveDemand] Starting...\n";
|
||||
|
||||
$GRID_SIZE = 0.01; // حجم الخلية (~1.1km)
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_scheduled_rides.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* يحوّل الحجوزات المستحقة إلى رحلات فعلية، ويغذّي توقّع الطلب.
|
||||
*
|
||||
* ── لماذا لا تُنشأ الرحلة وقت الحجز ──
|
||||
* رحلة تُنشأ الآن لموعد بعد ست ساعات ستدور في السوق ست ساعات: تُربك
|
||||
* الإسناد، وتشوّه الخريطة الحرارية، وتُحتسب في كثافة الطلب اللحظية.
|
||||
* الحجز يبقى نيّة حتى يقترب موعده.
|
||||
*
|
||||
* ── وظيفتان ──
|
||||
* ١. تذكير الراكب قبل موعده (ساعة، ثم ربع ساعة)
|
||||
* ٢. إطلاق المستحق: الحجوزات التي بلغ موعدها ناقص هامش البحث
|
||||
* ٣. نشر التوقّع: عدد الحجوزات القادمة لكل خلية جغرافية في Redis،
|
||||
* ليقرأها cron_predictive_demand والخريطة الحرارية
|
||||
*
|
||||
* الثانية هي القيمة الحقيقية: حجز مؤكد لبعد ست ساعات معلومة يقينية،
|
||||
* أدق من أي تنبؤ إحصائي، وتسمح بتوجيه السائقين **قبل** الذروة.
|
||||
*
|
||||
* جدولة مقترحة (crontab): **كل دقيقتين**، لا كل دقيقة.
|
||||
*
|
||||
* الاستعلامات كلها مفهرسة على (status, scheduled_at) وتكلفتها مهملة،
|
||||
* لكن كل تشغيل يعني إنشاء عملية PHP جديدة داخل الحاوية — وهذا هو الحمل
|
||||
* الحقيقي لا الاستعلام. وهوامش الإطلاق ١٥-٤٠ دقيقة، فدقيقة تأخير في
|
||||
* الاستحقاق لا أثر لها إطلاقاً.
|
||||
*
|
||||
* *\/2 * * * * (كل دقيقتين)
|
||||
* docker compose exec -T php php /var/www/backend/bot/cron_scheduled_rides.php
|
||||
*
|
||||
* ⚠️ يجب أن يعمل داخل حاوية php — Redis لا ينشر منافذ.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
// حجز فات موعده بهذا القدر بلا إطلاق يُعتبر منتهياً: الراكب لم يعد
|
||||
// ينتظره، وإطلاقه الآن يرسل له سائقاً لموعد مضى.
|
||||
const SCHEDULE_EXPIRE_GRACE_MINUTES = 30;
|
||||
|
||||
// دقة خلية التوقّع — نفس شبكة cron_predictive_demand (0.01° ≈ 1.1 كم).
|
||||
const SCHEDULE_GRID = 0.01;
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, '[scheduled] DB unavailable: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ١) انتهاء صلاحية الحجوزات المهملة
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$expired = 0;
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
UPDATE scheduled_rides
|
||||
SET status = 'expired'
|
||||
WHERE status = 'scheduled'
|
||||
AND scheduled_at < NOW() - INTERVAL " . SCHEDULE_EXPIRE_GRACE_MINUTES . " MINUTE
|
||||
");
|
||||
$st->execute();
|
||||
$expired = $st->rowCount();
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[scheduled] تعذّر تعليم المنتهية: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ١.٥) تذكير الراكب
|
||||
//
|
||||
// تذكيران: قبل ساعة ليرتّب أمره، وقبل ربع ساعة ليستعد للنزول.
|
||||
// الختم في العمود يمنع التكرار — بدونه يصل التذكير على كل مرور للكرون.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$reminded = 0;
|
||||
foreach ([[60, 'reminded_60_at', 'رحلتك المحجوزة بعد ساعة'],
|
||||
[15, 'reminded_15_at', 'رحلتك المحجوزة بعد ربع ساعة']] as [$mins, $col, $title]) {
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
SELECT id, passenger_id, scheduled_at, start_name, end_name
|
||||
FROM scheduled_rides
|
||||
WHERE status = 'scheduled'
|
||||
AND `$col` IS NULL
|
||||
AND scheduled_at BETWEEN NOW() AND NOW() + INTERVAL $mins MINUTE
|
||||
LIMIT 100
|
||||
");
|
||||
$st->execute();
|
||||
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
// الختم أولاً ثم الإرسال: لو انقطع التنفيذ بينهما، أسوأ نتيجة
|
||||
// تذكير لم يصل — لا تذكير يصل مئة مرة.
|
||||
$con->prepare("UPDATE scheduled_rides SET `$col` = NOW() WHERE id = ?")
|
||||
->execute([$row['id']]);
|
||||
|
||||
scheduleNotifyPassenger(
|
||||
$con,
|
||||
$row['passenger_id'],
|
||||
$title,
|
||||
'من ' . ($row['start_name'] ?: 'موقعك') . ' في '
|
||||
. date('H:i', strtotime($row['scheduled_at'])),
|
||||
['category' => 'scheduled_reminder', 'scheduled_id' => (string) $row['id']]
|
||||
);
|
||||
$reminded++;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "[scheduled] تعذّر تذكير $mins د: " . $e->getMessage() . "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٢) الحجوزات المستحقة للإطلاق
|
||||
//
|
||||
// المستحق: بلغ الوقت موعدَه ناقص هامشه. الهامش مشتقّ من المسافة وقت
|
||||
// الحجز (رحلة مطار تحتاج وقتاً أطول لإيجاد سائق وللوصول).
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$dispatched = 0;
|
||||
$failed = 0;
|
||||
|
||||
try {
|
||||
$due = $con->prepare("
|
||||
SELECT * FROM scheduled_rides
|
||||
WHERE status = 'scheduled'
|
||||
AND NOW() >= scheduled_at - INTERVAL lead_minutes MINUTE
|
||||
AND scheduled_at >= NOW() - INTERVAL " . SCHEDULE_EXPIRE_GRACE_MINUTES . " MINUTE
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT 100
|
||||
");
|
||||
$due->execute();
|
||||
$bookings = $due->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($bookings as $b) {
|
||||
// قفل تفاؤلي: مرور ثانٍ للكرون (أو نسخة ثانية منه) لا يطلق نفس
|
||||
// الحجز مرتين. الرحلة المزدوجة تعني سائقين وراكباً واحداً.
|
||||
$lock = $con->prepare("
|
||||
UPDATE scheduled_rides SET status = 'dispatching'
|
||||
WHERE id = ? AND status = 'scheduled'
|
||||
");
|
||||
$lock->execute([$b['id']]);
|
||||
if ($lock->rowCount() === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rideId = scheduleCreateRide($b);
|
||||
|
||||
if ($rideId) {
|
||||
$con->prepare("
|
||||
UPDATE scheduled_rides SET status = 'dispatched', ride_id = ?
|
||||
WHERE id = ?
|
||||
")->execute([$rideId, $b['id']]);
|
||||
$dispatched++;
|
||||
error_log("[scheduled] حجز #{$b['id']} → رحلة #$rideId");
|
||||
} else {
|
||||
// نعيده 'scheduled' لا 'failed': المرور القادم يحاول ثانيةً،
|
||||
// وفشل شبكي عابر لا يجوز أن يحرم الراكب رحلته المحجوزة.
|
||||
$con->prepare("
|
||||
UPDATE scheduled_rides SET status = 'scheduled' WHERE id = ?
|
||||
")->execute([$b['id']]);
|
||||
$failed++;
|
||||
error_log("[scheduled] تعذّر إنشاء رحلة للحجز #{$b['id']} — ستُعاد المحاولة");
|
||||
}
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[scheduled] فشل الإطلاق: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٣) نشر التوقّع المؤكد
|
||||
//
|
||||
// هذه هي القيمة التي تتجاوز راحة الراكب: طلب معروف مسبقاً بمكانه
|
||||
// ووقته. يُنشر في Redis ليقرأه التنبؤ والخريطة الحرارية.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$cells = 0;
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
// نافذة ٦ ساعات: أبعد من ذلك لا يفيد توجيه السائقين اليوم.
|
||||
$st = $con->query("
|
||||
SELECT start_location, scheduled_at
|
||||
FROM scheduled_rides
|
||||
WHERE status = 'scheduled'
|
||||
AND scheduled_at BETWEEN NOW() AND NOW() + INTERVAL 6 HOUR
|
||||
");
|
||||
|
||||
$buckets = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
[$lat, $lng] = array_pad(
|
||||
array_map('trim', explode(',', (string) $row['start_location'], 2)), 2, null
|
||||
);
|
||||
if (!is_numeric($lat) || !is_numeric($lng)) continue;
|
||||
|
||||
// المفتاح: خلية جغرافية + الساعة المتوقعة. التجميع بالساعة
|
||||
// يكفي لقرار "أين يجب أن يكون السائقون الساعة السابعة".
|
||||
$cell = round((float) $lat / SCHEDULE_GRID) * SCHEDULE_GRID
|
||||
. ',' . round((float) $lng / SCHEDULE_GRID) * SCHEDULE_GRID;
|
||||
$hour = date('Y-m-d H:00', strtotime($row['scheduled_at']));
|
||||
|
||||
$buckets["$cell|$hour"] = ($buckets["$cell|$hour"] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$key = 'demand:scheduled';
|
||||
$redisLocation->del($key);
|
||||
if (!empty($buckets)) {
|
||||
$redisLocation->hMSet($key, $buckets);
|
||||
// عمر ساعتين: الكرون يعيد بناءه كل دقيقة، والانتهاء يمنع
|
||||
// بقاء توقّع قديم لو توقّف الكرون.
|
||||
$redisLocation->expire($key, 7200);
|
||||
}
|
||||
$cells = count($buckets);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, '[scheduled] تعذّر نشر التوقّع: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// الأقواس ضرورية: الفاصلة العربية `،` بايتاتها ≥ 0x80، وPHP يعدّها جزءاً
|
||||
// صالحاً من اسم المتغيّر — فبدونها يُقرأ "$dispatched،" متغيّراً آخر فارغاً.
|
||||
echo "[scheduled] أُطلق {$dispatched}، تعذّر {$failed}، انتهى {$expired}،"
|
||||
. " ذُكِّر {$reminded}، وخلايا التوقّع {$cells}\n";
|
||||
|
||||
|
||||
/**
|
||||
* ينشئ الرحلة الفعلية من الحجز عبر add_ride.php.
|
||||
*
|
||||
* نستدعي النقطة ذاتها لا نكرّر منطقها: add_ride يسعّر، ويبني الحمولة،
|
||||
* ويشغّل الإسناد (بالدفعات أو البثّ الحر)، ويكتب للقاعدتين. تكرار ذلك
|
||||
* هنا كان سينتج نسخة تتباعد عن الأصل مع أول تعديل.
|
||||
*
|
||||
* السعر يُحسب لحظة الإنشاء لا وقت الحجز: سعر ما قبل ست ساعات لا يعرف
|
||||
* الذروة ولا الطقس ولا كثافة السائقين.
|
||||
*
|
||||
* @return string|null رقم الرحلة، أو null عند الفشل.
|
||||
*/
|
||||
function scheduleCreateRide(array $b): ?string
|
||||
{
|
||||
$url = getenv('INTERNAL_API_BASE') ?: 'http://nginx';
|
||||
$url .= '/ride/rides/add_ride.php';
|
||||
|
||||
[$startLat, $startLng] = array_pad(
|
||||
array_map('trim', explode(',', (string) $b['start_location'], 2)), 2, ''
|
||||
);
|
||||
[$endLat, $endLng] = array_pad(
|
||||
array_map('trim', explode(',', (string) $b['end_location'], 2)), 2, ''
|
||||
);
|
||||
|
||||
$fields = [
|
||||
'passenger_id' => $b['passenger_id'],
|
||||
'start_location' => $b['start_location'],
|
||||
'end_location' => $b['end_location'],
|
||||
'start_lat' => $startLat,
|
||||
'start_lng' => $startLng,
|
||||
'end_lat' => $endLat,
|
||||
'end_lng' => $endLng,
|
||||
'start_name' => $b['start_name'] ?? '',
|
||||
'end_name' => $b['end_name'] ?? '',
|
||||
'car_type' => $b['car_type'],
|
||||
'distance' => $b['distance'],
|
||||
'duration' => $b['duration'],
|
||||
// السعر يُتجاهَل: add_ride يسعّر داخلياً لحظة الإنشاء. نمرّره
|
||||
// للتوثيق فقط — تقدير وقت الحجز لا يعرف الذروة ولا الطقس.
|
||||
'price' => $b['estimated_price'],
|
||||
'scheduled_at' => $b['scheduled_at'],
|
||||
// وسم المصدر: add_ride قد يعامل الرحلة المحجوزة بأولوية أو
|
||||
// هامش مختلف لاحقاً، والوسم يجعل ذلك ممكناً بلا تخمين.
|
||||
'source' => 'scheduled',
|
||||
'scheduled_id' => $b['id'],
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($fields),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'),
|
||||
],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code !== 200 || !$body) {
|
||||
error_log("[scheduled] add_ride رمز=$code رد=" . substr((string) $body, 0, 200));
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode($body, true);
|
||||
|
||||
// add_ride يرد بـ printSuccess($insertedId) — الرقم في message.
|
||||
$rideId = $json['message'] ?? $json['data'] ?? null;
|
||||
|
||||
return ($rideId && is_scalar($rideId)) ? (string) $rideId : null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* يرسل إشعاراً للراكب. توكنه مشفَّر في جدول tokens — نفكّه كما يفعل
|
||||
* cancel_ride_by_driver.php بالضبط.
|
||||
*
|
||||
* لا يرمي: فشل التذكير لا يُسقط الكرون ولا يمنع إطلاق الحجوزات.
|
||||
*/
|
||||
function scheduleNotifyPassenger(PDO $con, string $passengerId,
|
||||
string $title, string $body, array $data): void
|
||||
{
|
||||
global $encryptionHelper;
|
||||
|
||||
try {
|
||||
$st = $con->prepare("SELECT token FROM tokens WHERE passengerID = ? ORDER BY id DESC LIMIT 1");
|
||||
$st->execute([$passengerId]);
|
||||
$raw = $st->fetchColumn();
|
||||
if (!$raw) return;
|
||||
|
||||
$token = $raw;
|
||||
if (!empty($encryptionHelper)) {
|
||||
try {
|
||||
$dec = $encryptionHelper->decryptData($raw);
|
||||
if ($dec !== false && $dec !== '') $token = trim($dec);
|
||||
} catch (Throwable $e) { /* نستخدم الخام */ }
|
||||
}
|
||||
|
||||
if (function_exists('sendFCM_Internal')) {
|
||||
sendFCM_Internal($token, $title, $body, $data, 'scheduled_reminder', false);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('[scheduled] تعذّر إرسال التذكير: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@ try {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// المُعاملات الموسمية تُنشر عبر Redis وحده ليقرأها محرّك التسعير.
|
||||
if (!$redis) {
|
||||
error_log('[SeasonalPricing] ABORT: Redis unavailable — seasonal multipliers not published.');
|
||||
die("Redis unavailable — aborting seasonal pricing run.\n");
|
||||
}
|
||||
|
||||
$countries = ['SY' => 'Asia/Damascus', 'JO' => 'Asia/Amman', 'EG' => 'Africa/Cairo', 'IQ' => 'Asia/Baghdad'];
|
||||
|
||||
// Simulated Calendar of Events
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_sms_requests.php — شبكة الأمان لمسار الطلب بالرسائل
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* المعالجة الأصلية تتم فورياً داخل webhook_sms/webhook.php، لأن الراكب
|
||||
* واقف ينتظر رداً ودقيقة تأخير تعني أنه ظنّ الرقم معطّلاً. هذا الكرون
|
||||
* لا يعالج المسار السعيد إذن — وظيفته ما يسقط منه:
|
||||
*
|
||||
* ١. طلبات بقيت 'pending' (فشل شبكي عابر وقت الإنشاء)
|
||||
* ٢. طلبات عَلِقت في 'dispatching' (مات التنفيذ بين القفل والنتيجة)
|
||||
* ٣. رحلات لم يقبلها أحد — نخبر الراكب بدل تركه ينتظر بلا خبر
|
||||
*
|
||||
* الثالثة هي الأهم: راكب التطبيق يرى الخريطة فيعرف أن البحث جارٍ.
|
||||
* راكب الرسائل لا يرى شيئاً — صمتنا عنده يساوي عطلاً.
|
||||
*
|
||||
* جدولة: كل دقيقة.
|
||||
* *\/1 * * * * docker compose exec -T php php /var/www/backend/bot/cron_sms_requests.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../sms/ride_request.php';
|
||||
|
||||
// طلب عَلِق في 'dispatching' أطول من هذا يُعتبر ميتاً ويُعاد للطابور.
|
||||
const SMS_STUCK_MINUTES = 3;
|
||||
|
||||
// بعد هذه المدة بلا سائق نخبر الراكب. أقصر من مهلة التطبيق عمداً:
|
||||
// من ينتظر بلا شاشة يفقد صبره أسرع.
|
||||
const SMS_NO_DRIVER_MINUTES = 6;
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, '[sms-cron] DB unavailable: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ١) تحرير العالق
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$freed = 0;
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
UPDATE sms_ride_requests
|
||||
SET status = 'pending'
|
||||
WHERE status = 'dispatching'
|
||||
AND updated_at < NOW() - INTERVAL " . SMS_STUCK_MINUTES . " MINUTE
|
||||
");
|
||||
$st->execute();
|
||||
$freed = $st->rowCount();
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[sms-cron] تعذّر تحرير العالق: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٢) إعادة محاولة المعلّق
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$retried = 0;
|
||||
$created = 0;
|
||||
try {
|
||||
$st = $con->query("
|
||||
SELECT id, sender_norm, passenger_id, start_location, end_location,
|
||||
start_name, end_name, distance, duration, attempts
|
||||
FROM sms_ride_requests
|
||||
WHERE status = 'pending' AND passenger_id IS NOT NULL
|
||||
ORDER BY id ASC
|
||||
LIMIT 50
|
||||
");
|
||||
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $req) {
|
||||
$retried++;
|
||||
if (smsProcessRequest($con, $req)) $created++;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[sms-cron] فشل إعادة المحاولة: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٣) إبلاغ من لم يجد سائقاً
|
||||
//
|
||||
// نعتمد على حالة الرحلة نفسها لا على مؤقّت منفصل: ما دامت 'waiting'
|
||||
// فلا سائق قَبِل. والوسم في fail_reason يمنع تكرار الإبلاغ كل دقيقة.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$notified = 0;
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
SELECT s.id, s.sender_norm, s.ride_id
|
||||
FROM sms_ride_requests s
|
||||
JOIN ride r ON r.id = s.ride_id
|
||||
WHERE s.status = 'dispatched'
|
||||
AND (s.fail_reason IS NULL OR s.fail_reason <> 'no_driver_notified')
|
||||
AND r.status IN ('waiting', 'wait')
|
||||
AND s.updated_at < NOW() - INTERVAL " . SMS_NO_DRIVER_MINUTES . " MINUTE
|
||||
LIMIT 50
|
||||
");
|
||||
$st->execute();
|
||||
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
// الوسم أولاً ثم الإرسال: لو انقطع التنفيذ بينهما، أسوأ نتيجة
|
||||
// رسالة لم تصل — لا رسالة تصل كل دقيقة.
|
||||
$con->prepare("UPDATE sms_ride_requests SET fail_reason='no_driver_notified' WHERE id=?")
|
||||
->execute([$row['id']]);
|
||||
|
||||
smsEnqueue($con, $row['sender_norm'],
|
||||
'سيرو: ما زلنا نبحث عن سائق قريب منك. سنرسل لك رسالة فور قبول أحدهم،'
|
||||
. ' أو أعد إرسال طلبك بعد قليل.',
|
||||
'no_driver_yet', (string) $row['ride_id']);
|
||||
$notified++;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[sms-cron] تعذّر إبلاغ من بلا سائق: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "[sms-cron] حُرِّر {$freed}، أُعيدت محاولة {$retried}، أُنشئ {$created}،"
|
||||
. " أُبلغ {$notified}\n";
|
||||
+42
-13
@@ -23,7 +23,15 @@ if (!$SECRET_KEY) {
|
||||
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'];
|
||||
// أجهزة البوت المسموح بها — تُضبط عبر BOT_ALLOWED_DEVICES (مفصولة بفواصل)
|
||||
// حتى لا يتطلب ضم جهاز جديد تعديل الكود ونشره.
|
||||
$ALLOWED_DEVICES = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string)(getenv('BOT_ALLOWED_DEVICES') ?: ''))
|
||||
)));
|
||||
if (!$ALLOWED_DEVICES) {
|
||||
$ALLOWED_DEVICES = ['SHAM_CASH_BOT_01', 'PRICE_SCRAPER_BOT_01', '77a9528112ef7c2a'];
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
@@ -43,9 +51,12 @@ if ($method === 'GET') {
|
||||
// ---------------------------------------------------------
|
||||
// GET: Fetch Pending Task for the Bot
|
||||
// ---------------------------------------------------------
|
||||
$device_id = filterRequest('device_id');
|
||||
$ts = filterRequest('ts');
|
||||
$sig = filterRequest('sig');
|
||||
// ملاحظة: filterRequest() تقرأ من POST أو JSON body فقط ولا تلمس $_GET
|
||||
// (backend/core/helpers.php) — والبوت يرسل بارامترات التوقيع في الـ query
|
||||
// string، لذا تُقرأ هنا من $_GET مباشرة.
|
||||
$device_id = isset($_GET['device_id']) ? trim((string)$_GET['device_id']) : null;
|
||||
$ts = isset($_GET['ts']) ? (int)$_GET['ts'] : null;
|
||||
$sig = isset($_GET['sig']) ? trim((string)$_GET['sig']) : null;
|
||||
|
||||
if (!$device_id || !$ts || !$sig) {
|
||||
jsonError("Missing security parameters");
|
||||
@@ -64,6 +75,14 @@ if ($method === 'GET') {
|
||||
|
||||
// Check Redis Queue for tasks
|
||||
// Queue Name: queue:bot:tasks (Using Main Redis)
|
||||
// bootstrap.php يترك $redis = null إذا فشل الاتصال — بدون هذا الفحص
|
||||
// كان الطلب ينهار بخطأ 500 غامض بدل رسالة واضحة.
|
||||
if (!$redis) {
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Task queue unavailable']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$taskJson = $redis->rpop('queue:bot:tasks');
|
||||
|
||||
if ($taskJson) {
|
||||
@@ -144,18 +163,28 @@ if ($method === 'GET') {
|
||||
}
|
||||
// ------------------------------------------------------
|
||||
|
||||
// العملة تتبع الدولة بدل تثبيتها على JOD — البوت يعمل على أكثر من سوق
|
||||
$currencyByCountry = ['JO' => 'JOD', 'SY' => 'SYP', 'EG' => 'EGP'];
|
||||
$currency = $currencyByCountry[strtoupper($country_code)] ?? 'JOD';
|
||||
|
||||
// 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]);
|
||||
try {
|
||||
$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, $currency, $country_code]);
|
||||
} catch (PDOException $e) {
|
||||
// لا نُسقط الطلب: نسجّل الخطأ ونكمل إلى Redis حتى لا تضيع العيّنة
|
||||
// من محرّك التسعير بسبب عطل في الكتابة إلى MySQL.
|
||||
error_log("[Bot Worker] MySQL insert failed for task $task_id: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// 2. Save to Redis (Calculate Price Per KM)
|
||||
if ($distance_km > 0 && $price > 0) {
|
||||
if ($redis && $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);
|
||||
|
||||
Reference in New Issue
Block a user