From cdd36d4777a3a7555fa22f2a1b39207554a3c8d9 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 7 Aug 2026 16:25:34 +0300 Subject: [PATCH] Update: 2026-08-07 16:25:34 --- backend/bot/cron_insurance_premiums.php | 98 +++++++++++++++ backend/driver_assurance/add.php | 27 ++-- backend/driver_assurance/cancel.php | 64 ++++++++++ backend/driver_assurance/eligibility.php | 110 +++++++++++++++++ backend/driver_assurance/get.php | 78 ++++++++++++ backend/driver_assurance/plans.php | 67 ++++++++++ backend/driver_assurance/subscribe.php | 76 ++++++++++++ .../2026_08_07_driver_insurance.sql | 116 ++++++++++++++++++ backend/ride/rides/add_ride.php | 3 + docker/crontab.production | 8 ++ siro_driver/lib/models/model/order_data.dart | 13 ++ siro_driver/lib/translations_ar.json | 6 +- siro_driver/lib/translations_en.json | 6 +- .../orderCaptin/order_request_page.dart | 7 ++ .../notification/available_rides_page.dart | 7 ++ .../lib/views/widgets/sms_ride_badge.dart | 71 +++++++++++ 16 files changed, 744 insertions(+), 13 deletions(-) create mode 100644 backend/bot/cron_insurance_premiums.php create mode 100644 backend/driver_assurance/cancel.php create mode 100644 backend/driver_assurance/eligibility.php create mode 100644 backend/driver_assurance/plans.php create mode 100644 backend/driver_assurance/subscribe.php create mode 100644 backend/migrations/2026_08_07_driver_insurance.sql create mode 100644 siro_driver/lib/views/widgets/sms_ride_badge.dart diff --git a/backend/bot/cron_insurance_premiums.php b/backend/bot/cron_insurance_premiums.php new file mode 100644 index 00000000..1db1fd07 --- /dev/null +++ b/backend/bot/cron_insurance_premiums.php @@ -0,0 +1,98 @@ +getMessage() . "\n"); + exit(1); +} + +$today = date('Y-m-d'); +// ‏الشهري يُقيَّد على أول الشهر لا على يوم الاشتراك: دفتر تتوزّع فيه +// ‏تواريخ القيد على ثلاثين يوماً يستحيل تسويته مع الشريك. +$monthStart = date('Y-m-01'); + +$charged = 0; +$skipped = 0; + +try { + $st = $con->query(" + SELECT p.id, p.driver_id, p.last_charged_on, + pl.premium, pl.currency, pl.billing_cycle + FROM driver_insurance_policies p + JOIN insurance_plans pl ON pl.id = p.plan_id + WHERE p.status = 'active' + ORDER BY p.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 insurance_premium_ledger + (policy_id, driver_id, charge_date, amount, currency, status) + VALUES (?, ?, ?, ?, ?, 'pending') + ")->execute([ + $policy['id'], $policy['driver_id'], $chargeDate, + $policy['premium'], $policy['currency'], + ]); + + $con->prepare(" + UPDATE driver_insurance_policies 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_insurance_policies 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"; diff --git a/backend/driver_assurance/add.php b/backend/driver_assurance/add.php index 9ea8363c..e015826b 100644 --- a/backend/driver_assurance/add.php +++ b/backend/driver_assurance/add.php @@ -2,16 +2,22 @@ require_once __DIR__ . '/../connect.php'; -// Get the values from the request -$driver_id = filterRequest("driver_id"); -$assured = filterRequest("assured"); // إذا كانت قيمة حساسة يجب تشفيرها -$health_insurance_provider = filterRequest("health_insurance_provider"); // إذا كانت حساسة، شفرها +// ‏المسار القديم للتأمين الصحي. المسار الجديد (خطط، أهلية، أقساط) في +// ‏plans.php و subscribe.php و get.php — هذا يبقى لمن سُجّل هنا سابقاً. -// إذا تحتاج تشفير، فعّل التالي: -// $assured = $encryptionHelper->encryptData($assured); -// $health_insurance_provider = $encryptionHelper->encryptData($health_insurance_provider); +// ‏الهوية من الـJWT لا من الطلب. كان الملف يقرأ driver_id من المدخلات +// ‏بلا أي فحص، فأي حامل رمز صالح يكتب سجل تأمين باسم أي سائق. +$driver_id = $user_id ?? ''; +if (empty($driver_id) || ($role ?? '') !== 'driver') { + jsonError('Unauthorized', 401); +} -// SQL using bind parameters +$assured = filterRequest("assured"); +$health_insurance_provider = filterRequest("health_insurance_provider"); + +// ‏upsert لا insert: العمود driver_id عليه مفتاح فريد، فأي حفظ ثانٍ +// ‏لنفس السائق كان يفشل بخرق المفتاح — والحفظ الثاني هو الحالة +// ‏الطبيعية (تعديل مزوّد التأمين) لا الاستثناء. $sql = "INSERT INTO `driver_health_assurance` ( `driver_id`, `assured`, @@ -20,7 +26,10 @@ $sql = "INSERT INTO `driver_health_assurance` ( :driver_id, :assured, :health_insurance_provider -)"; +) +ON DUPLICATE KEY UPDATE + `assured` = VALUES(`assured`), + `health_insurance_provider` = VALUES(`health_insurance_provider`)"; $stmt = $con->prepare($sql); $stmt->bindParam(':driver_id', $driver_id); diff --git a/backend/driver_assurance/cancel.php b/backend/driver_assurance/cancel.php new file mode 100644 index 00000000..e389fa6c --- /dev/null +++ b/backend/driver_assurance/cancel.php @@ -0,0 +1,64 @@ +prepare(" + UPDATE driver_insurance_policies + SET status = 'cancelled', ended_at = CURDATE(), cancel_reason = ? + WHERE id = ? AND driver_id = ? AND status = 'active' + "); + $st->execute([mb_substr($reason, 0, 255), $policy['id'], $driverId]); + + if ($st->rowCount() === 0) { + jsonError('تعذّر إلغاء الوثيقة'); + } +} catch (PDOException $e) { + error_log('[assurance/cancel] ' . $e->getMessage()); + jsonError('Server error'); +} + +$pending = 0.0; +try { + $st = $con->prepare(" + SELECT COALESCE(SUM(amount), 0) FROM insurance_premium_ledger + WHERE policy_id = ? AND status = 'pending' + "); + $st->execute([$policy['id']]); + $pending = (float) $st->fetchColumn(); +} catch (PDOException $e) { + error_log('[assurance/cancel] تعذّرت قراءة المستحق: ' . $e->getMessage()); +} + +error_log("[assurance] أُلغيت وثيقة #{$policy['id']} للسائق $driverId"); + +jsonSuccess([ + 'cancelled' => true, + 'pending_balance' => $pending, + 'currency' => $policy['currency'], +], $pending > 0 + ? 'أُلغيت الوثيقة. يبقى عليك مستحق أقساط سابقة قدره ' . $pending . ' ' . $policy['currency'] + : 'أُلغيت الوثيقة'); diff --git a/backend/driver_assurance/eligibility.php b/backend/driver_assurance/eligibility.php new file mode 100644 index 00000000..e3007149 --- /dev/null +++ b/backend/driver_assurance/eligibility.php @@ -0,0 +1,110 @@ +prepare(" + SELECT COUNT(*) FROM ride + WHERE driver_id = ? AND status IN ('Finished', 'finished') + "); + $st->execute([$driverId]); + $rides = (int) $st->fetchColumn(); + + $st = $con->prepare("SELECT AVG(rating) FROM ratingDriver WHERE driver_id = ?"); + $st->execute([$driverId]); + $avg = $st->fetchColumn(); + // ‏سائق بلا تقييمات ليس سيئاً — لكنه أيضاً غير مثبَت. عدد + // ‏الرحلات المطلوب يضمن أن تقييماته وصلت على أي حال. + $rating = $avg === null || $avg === false ? 0.0 : round((float) $avg, 2); + + $st = $con->prepare("SELECT DATEDIFF(NOW(), created_at) FROM driver WHERE id = ? LIMIT 1"); + $st->execute([$driverId]); + $d = $st->fetchColumn(); + $days = ($d === false || $d === null) ? 0 : (int) $d; + } catch (PDOException $e) { + error_log('[assurance] تعذّر حساب الأهلية: ' . $e->getMessage()); + return [ + 'eligible' => false, 'rides' => 0, 'rating' => 0.0, 'account_days' => 0, + 'reasons' => ['تعذّر التحقق حالياً'], + ]; + } + + $minRides = (int) ($plan['min_completed_rides'] ?? 200); + $minRating = (float) ($plan['min_rating'] ?? 4.50); + $minDays = (int) ($plan['min_account_days'] ?? 30); + + // ‏نجمع كل الأسباب لا أوّلها: السائق يستحق أن يعرف كل ما ينقصه + // ‏دفعةً واحدة، لا أن يعالج شرطاً فيُفاجأ بالتالي. + $reasons = []; + if ($rides < $minRides) { + $reasons[] = "أكمل $minRides رحلة (لديك $rides)"; + } + if ($rating < $minRating) { + $reasons[] = "تقييمك يجب ألّا يقل عن $minRating (تقييمك " . ($rating ?: '—') . ')'; + } + if ($days < $minDays) { + $reasons[] = "مضيّ $minDays يوماً على حسابك (مضى $days)"; + } + + return [ + 'eligible' => empty($reasons), + 'rides' => $rides, + 'rating' => $rating, + 'account_days' => $days, + 'reasons' => $reasons, + ]; +} + +/** + * ‏الوثيقة النشطة للسائق مع بيانات خطتها، أو null. + */ +function assuranceActivePolicy(PDO $con, string $driverId): ?array +{ + try { + $st = $con->prepare(" + SELECT p.*, pl.code, pl.name_ar AS plan_name, pl.premium, pl.currency, + pl.billing_cycle, pl.coverage_summary, + pr.name AS provider_name, pr.name_ar AS provider_name_ar + FROM driver_insurance_policies p + JOIN insurance_plans pl ON pl.id = p.plan_id + JOIN insurance_providers pr ON pr.id = pl.provider_id + WHERE p.driver_id = ? AND p.status = 'active' + ORDER BY p.id DESC LIMIT 1 + "); + $st->execute([$driverId]); + return $st->fetch(PDO::FETCH_ASSOC) ?: null; + } catch (PDOException $e) { + error_log('[assurance] تعذّر جلب الوثيقة: ' . $e->getMessage()); + return null; + } +} diff --git a/backend/driver_assurance/get.php b/backend/driver_assurance/get.php index e69de29b..fc3f5f55 100644 --- a/backend/driver_assurance/get.php +++ b/backend/driver_assurance/get.php @@ -0,0 +1,78 @@ + false, + 'policy' => null, + 'ledger' => ['pending_total' => 0, 'settled_total' => 0, 'currency' => null], + 'entries' => [], + ], 'لا توجد وثيقة نشطة'); +} + +$summary = ['pending_total' => 0.0, 'settled_total' => 0.0, + 'currency' => $policy['currency']]; +$entries = []; + +try { + $st = $con->prepare(" + SELECT status, SUM(amount) AS total + FROM insurance_premium_ledger + WHERE policy_id = ? + GROUP BY status + "); + $st->execute([$policy['id']]); + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { + if ($row['status'] === 'pending') $summary['pending_total'] = (float) $row['total']; + if ($row['status'] === 'settled') $summary['settled_total'] = (float) $row['total']; + } + + // ‏آخر ثلاثين قيداً: كافية لشهر يومي، وتمنع رداً ينمو بلا حد. + $st = $con->prepare(" + SELECT charge_date, amount, currency, status, note + FROM insurance_premium_ledger + WHERE policy_id = ? + ORDER BY charge_date DESC + LIMIT 30 + "); + $st->execute([$policy['id']]); + $entries = $st->fetchAll(PDO::FETCH_ASSOC); +} catch (PDOException $e) { + error_log('[assurance/get] تعذّرت قراءة الدفتر: ' . $e->getMessage()); +} + +jsonSuccess([ + 'has_policy' => true, + 'policy' => [ + 'id' => (int) $policy['id'], + 'plan' => $policy['plan_name'], + 'provider' => $policy['provider_name_ar'] ?: $policy['provider_name'], + 'policy_number' => $policy['policy_number'], + 'coverage' => $policy['coverage_summary'], + 'billing_cycle' => $policy['billing_cycle'], + 'premium' => (float) $policy['premium'], + 'currency' => $policy['currency'], + 'started_at' => $policy['started_at'], + 'last_charged_on' => $policy['last_charged_on'], + ], + 'ledger' => $summary, + 'entries' => $entries, +], 'success'); diff --git a/backend/driver_assurance/plans.php b/backend/driver_assurance/plans.php new file mode 100644 index 00000000..e23a7212 --- /dev/null +++ b/backend/driver_assurance/plans.php @@ -0,0 +1,67 @@ +prepare(" + SELECT pl.*, pr.name AS provider_name, pr.name_ar AS provider_name_ar + FROM insurance_plans pl + JOIN insurance_providers pr ON pr.id = pl.provider_id + WHERE pl.is_active = 1 AND pr.is_active = 1 AND pr.country = ? + ORDER BY pl.premium ASC + "); + $st->execute([$country]); + $plans = $st->fetchAll(PDO::FETCH_ASSOC); +} catch (PDOException $e) { + error_log('[assurance/plans] ' . $e->getMessage()); + jsonError('Server error'); +} + +$current = assuranceActivePolicy($con, $driverId); + +$out = []; +foreach ($plans as $plan) { + $check = assuranceCheckEligibility($con, $driverId, $plan); + + $out[] = [ + 'id' => (int) $plan['id'], + 'code' => $plan['code'], + 'name' => $plan['name_ar'], + 'description' => $plan['description_ar'], + 'provider' => $plan['provider_name_ar'] ?: $plan['provider_name'], + 'billing_cycle' => $plan['billing_cycle'], + 'premium' => (float) $plan['premium'], + 'currency' => $plan['currency'], + 'coverage' => $plan['coverage_summary'], + 'eligible' => $check['eligible'], + 'missing' => $check['reasons'], + 'is_current' => $current && (int) $current['plan_id'] === (int) $plan['id'], + // ‏تقدّم السائق نحو الشرط — رقم يفهمه بلا شرح. + 'progress_rides' => $check['rides'], + 'required_rides' => (int) $plan['min_completed_rides'], + ]; +} + +jsonSuccess([ + 'plans' => $out, + 'has_policy' => (bool) $current, + 'driver_rating' => $current ? (float) $current['rating_at_signup'] : null, +], 'success'); diff --git a/backend/driver_assurance/subscribe.php b/backend/driver_assurance/subscribe.php new file mode 100644 index 00000000..f31934f2 --- /dev/null +++ b/backend/driver_assurance/subscribe.php @@ -0,0 +1,76 @@ +prepare(" + SELECT pl.*, pr.is_active AS provider_active + FROM insurance_plans pl + JOIN insurance_providers pr ON pr.id = pl.provider_id + WHERE pl.id = ? AND pl.is_active = 1 LIMIT 1 + "); + $st->execute([$planId]); + $plan = $st->fetch(PDO::FETCH_ASSOC); +} catch (PDOException $e) { + error_log('[assurance/subscribe] ' . $e->getMessage()); + jsonError('Server error'); +} + +if (!$plan || (int) $plan['provider_active'] !== 1) { + jsonError('Plan not available'); +} + +// ‏وثيقة نشطة قائمة: لا نشترك مرتين ولا نستبدل بصمت. تبديل الخطة +// ‏قرار مالي — يمرّ بإلغاء صريح ثم اشتراك جديد، ليبقى في السجل أثر. +if (assuranceActivePolicy($con, $driverId)) { + jsonError('لديك وثيقة تأمين نشطة بالفعل. ألغِها أولاً لتغيير الخطة.'); +} + +$check = assuranceCheckEligibility($con, $driverId, $plan); +if (!$check['eligible']) { + jsonError('لم تستوفِ شروط هذه الخطة بعد: ' . implode('، ', $check['reasons'])); +} + +try { + $con->prepare(" + INSERT INTO driver_insurance_policies + (driver_id, plan_id, status, started_at, rides_at_signup, rating_at_signup) + VALUES (?, ?, 'active', CURDATE(), ?, ?) + ")->execute([$driverId, $planId, $check['rides'], $check['rating']]); + + $policyId = (int) $con->lastInsertId(); +} catch (PDOException $e) { + error_log('[assurance/subscribe] تعذّر إنشاء الوثيقة: ' . $e->getMessage()); + jsonError('تعذّر تفعيل الوثيقة حالياً'); +} + +error_log("[assurance] السائق $driverId اشترك في الخطة {$plan['code']} — وثيقة #$policyId"); + +jsonSuccess([ + 'policy_id' => $policyId, + 'plan' => $plan['name_ar'], + 'premium' => (float) $plan['premium'], + 'currency' => $plan['currency'], + 'billing_cycle' => $plan['billing_cycle'], + // ‏أول قسط يُقيَّد في تشغيل الكرون القادم لا الآن: الاشتراك ليس + // ‏دفعاً، والقيد يجب أن يكون له تاريخ واضح يوافق دورة الفوترة. + 'starts_on' => date('Y-m-d'), +], 'تم تفعيل وثيقة التأمين'); diff --git a/backend/migrations/2026_08_07_driver_insurance.sql b/backend/migrations/2026_08_07_driver_insurance.sql new file mode 100644 index 00000000..c8febc73 --- /dev/null +++ b/backend/migrations/2026_08_07_driver_insurance.sql @@ -0,0 +1,116 @@ +-- ============================================================ +-- التأمين للسائق — بند 3.5 من دراسة الفرص +-- +-- ‏ما كان موجوداً قبل هذا: جدول `driver_health_assurance` فيه بوليان +-- ‏`assured` واسم شركة نصّاً، وملف add.php وحيد بلا حارس مصادقة — +-- ‏و get.php و update.php **فارغان تماماً** (صفر بايت). أي أن الفكرة +-- ‏لم "تبدأ وتتوقف" كما تقول الدراسة، بل لم تبدأ. +-- +-- ‏قرار المالك: شريك مرخّص (ميتلايف وآخر في مصر)، قسط يومي أو شهري +-- ‏يُخصم من أرباح السائق، وأهلية مشروطة بعدد رحلات وتقييم — التأمين +-- ‏مكافأة استمرار لا خدمة تُمنح لكل من سجّل. +-- +-- ‏لا نلمس driver_health_assurance: يبقى كما هو لمن سُجّل فيه سابقاً، +-- ‏والجداول هنا هي المسار الجديد. +-- ============================================================ + +-- ── ١) شركات التأمين ──────────────────────────────────────── +CREATE TABLE IF NOT EXISTS `insurance_providers` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(120) NOT NULL COMMENT 'MetLife وغيرها', + `name_ar` VARCHAR(120) DEFAULT NULL, + `country` VARCHAR(60) NOT NULL DEFAULT 'Egypt', + `contact` VARCHAR(255) DEFAULT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_country_active` (`country`, `is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── ٢) الخطط ──────────────────────────────────────────────── +-- ‏شروط الأهلية تُخزَّن مع الخطة لا في الكود: الشريك سيغيّرها، والسوق +-- ‏المصري يختلف عن الأردني، وتغيير رقم في صف أرخص من نشر إصدار. +CREATE TABLE IF NOT EXISTS `insurance_plans` ( + `id` INT NOT NULL AUTO_INCREMENT, + `provider_id` INT NOT NULL, + `code` VARCHAR(60) NOT NULL COMMENT 'معرّف ثابت يُستعمل في الكود', + `name_ar` VARCHAR(160) NOT NULL, + `description_ar` TEXT DEFAULT NULL, + `billing_cycle` ENUM('daily','monthly') NOT NULL DEFAULT 'daily', + `premium` DECIMAL(10,3) NOT NULL COMMENT 'قسط الدورة الواحدة', + `currency` VARCHAR(10) NOT NULL DEFAULT 'EGP', + `coverage_summary` TEXT DEFAULT NULL, + + -- ── شروط الأهلية ── + -- ‏قرار المالك: لا تأمين قبل ~٢٠٠ رحلة بتقييم جيد. المنطق سليم — + -- ‏التأمين كلفة متكررة على الشركة، ومنحه لمن قد يختفي بعد أسبوع + -- ‏يحوّله من أداة ولاء إلى نزيف. + `min_completed_rides` INT NOT NULL DEFAULT 200, + `min_rating` DECIMAL(3,2) NOT NULL DEFAULT 4.50, + `min_account_days` INT NOT NULL DEFAULT 30, + + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_code` (`code`), + KEY `idx_provider_active` (`provider_id`, `is_active`), + CONSTRAINT `fk_plan_provider` FOREIGN KEY (`provider_id`) + REFERENCES `insurance_providers` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── ٣) وثائق السائقين ─────────────────────────────────────── +CREATE TABLE IF NOT EXISTS `driver_insurance_policies` ( + `id` INT NOT NULL AUTO_INCREMENT, + `driver_id` VARCHAR(100) NOT NULL, + `plan_id` INT NOT NULL, + `policy_number` VARCHAR(120) DEFAULT NULL COMMENT 'رقم الوثيقة من الشريك', + `status` ENUM('active','suspended','cancelled','expired') + NOT NULL DEFAULT 'active', + `started_at` DATE NOT NULL, + `ended_at` DATE DEFAULT NULL, + + -- ‏لقطة الأهلية لحظة الاشتراك. بدونها لا يمكن الإجابة لاحقاً على + -- ‏«لماذا قُبل هذا السائق؟» حين تتغيّر شروط الخطة أو ينخفض تقييمه. + `rides_at_signup` INT NOT NULL DEFAULT 0, + `rating_at_signup` DECIMAL(3,2) NOT NULL DEFAULT 0, + + -- ‏آخر يوم حُسب عنه قسط. هذا العمود — لا التاريخ الحالي — هو ما + -- ‏يمنع الاحتساب المزدوج لو عمل الكرون مرتين في اليوم نفسه. + `last_charged_on` DATE DEFAULT NULL, + `cancel_reason` VARCHAR(255) DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + -- ‏وثيقة واحدة نشطة لكل سائق. المفتاح الفريد على (driver_id, status) + -- ‏لا يصلح — يمنع وثيقتين ملغيتين. نمنعه في الكود ونكشفه بفهرس. + KEY `idx_driver_status` (`driver_id`, `status`), + KEY `idx_charge_sweep` (`status`, `last_charged_on`), + CONSTRAINT `fk_policy_plan` FOREIGN KEY (`plan_id`) + REFERENCES `insurance_plans` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── ٤) دفتر الأقساط ───────────────────────────────────────── +-- ‏دفتر لا خصم مباشر. رصيد السائق يعيش في payment_server لا هنا، +-- ‏فالكرون يقيّد استحقاقاً والتسوية تقرأه. الفصل مقصود أيضاً لسبب +-- ‏محاسبي: القيد سجلّ دائم قابل للمراجعة، بينما الخصم المباشر يترك +-- ‏رقماً متغيّراً بلا أثر يشرح كيف صار كذلك. +CREATE TABLE IF NOT EXISTS `insurance_premium_ledger` ( + `id` INT NOT NULL AUTO_INCREMENT, + `policy_id` INT NOT NULL, + `driver_id` VARCHAR(100) NOT NULL, + `charge_date` DATE NOT NULL COMMENT 'اليوم أو أول الشهر المحتسَب', + `amount` DECIMAL(10,3) NOT NULL, + `currency` VARCHAR(10) NOT NULL DEFAULT 'EGP', + `status` ENUM('pending','settled','waived','failed') + NOT NULL DEFAULT 'pending', + `settled_at` DATETIME DEFAULT NULL, + `note` VARCHAR(255) DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + -- ‏الحارس الحقيقي ضد الاحتساب المزدوج: قسط واحد لكل وثيقة في اليوم + -- ‏الواحد مهما تكرّر تشغيل الكرون أو تزامنت نسختان منه. + UNIQUE KEY `uq_policy_date` (`policy_id`, `charge_date`), + KEY `idx_driver_status` (`driver_id`, `status`), + CONSTRAINT `fk_ledger_policy` FOREIGN KEY (`policy_id`) + REFERENCES `driver_insurance_policies` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/ride/rides/add_ride.php b/backend/ride/rides/add_ride.php index 5c7144e6..c8589dc3 100644 --- a/backend/ride/rides/add_ride.php +++ b/backend/ride/rides/add_ride.php @@ -49,6 +49,9 @@ function buildMarketPayload($rideId, $lat, $lng, $payloadData, $extraMarketData // ‏ونافذة الأوفرلي. غيابها = رحلة لحظية عادية. 'is_scheduled' => $payloadData[39] ?? '', 'scheduled_at' => $payloadData[40] ?? '', + // ‏راكب بلا إنترنت: طلب عبر رسالة نصية. لن يصله شات ولا إشعار، + // ‏والاتصال الهاتفي هو وسيلة التواصل الوحيدة معه. + 'is_sms' => $payloadData[41] ?? '', ], $extraMarketData); } diff --git a/docker/crontab.production b/docker/crontab.production index f9e1995b..5cf33337 100644 --- a/docker/crontab.production +++ b/docker/crontab.production @@ -81,6 +81,14 @@ DEX=/usr/bin/docker compose -f $SIRO/docker-compose.yml exec -T php php # فصمتنا عنده يساوي عطلاً. */1 * * * * $DEX /var/www/backend/bot/cron_sms_requests.php >> /var/log/sms_requests.log 2>&1 +# ============================================================ +# التأمين للسائق +# ============================================================ +# يقيّد قسط كل وثيقة نشطة بلغت دورتها. لا يخصم من رصيد أحد — الرصيد +# في payment_server، وهذا دفتر استحقاق تقرأه التسوية. +# مرة يومياً بعد منتصف الليل: القيد اليومي مرتبط بالتاريخ لا بالساعة. +15 0 * * * $DEX /var/www/backend/bot/cron_insurance_premiums.php >> /var/log/insurance.log 2>&1 + # ============================================================ # الإشعارات والتسويق # ============================================================ diff --git a/siro_driver/lib/models/model/order_data.dart b/siro_driver/lib/models/model/order_data.dart index a0836852..17b04e88 100755 --- a/siro_driver/lib/models/model/order_data.dart +++ b/siro_driver/lib/models/model/order_data.dart @@ -41,11 +41,19 @@ class OrderData { /// موعد الانطلاق كما حجزه الراكب. فارغ في الرحلات العادية. final String scheduledAt; + /// طلب وصل عبر رسالة نصية — الراكب بلا إنترنت. + /// + /// ليست تفصيلاً تجميلياً: هذا الراكب لن يصله شات ولا إشعار ولن يرى + /// السائق على الخريطة. الاتصال الهاتفي هو القناة الوحيدة، والسائق + /// الذي لا يعرف ذلك سينتظر رداً لن يأتي ثم يُلغي. + final bool isSmsRide; + OrderData({ this.offerType, this.offerExpiresIn = 0, this.isScheduled = false, this.scheduledAt = '', + this.isSmsRide = false, required this.customerName, required this.customerToken, required this.tripDistanceKm, @@ -164,6 +172,10 @@ class OrderData { isScheduled: list.length > 39 && (list[39].toString() == '1' || list[39].toString() == 'true'), scheduledAt: list.length > 40 ? list[40].toString() : '', + + // ‏الفهرس 41 — طلب بالرسائل النصية. + isSmsRide: list.length > 41 && + (list[41].toString() == '1' || list[41].toString() == 'true'), ); } @@ -212,6 +224,7 @@ class OrderData { offerExpiresIn: int.tryParse(str('offer_expires_in', '0')) ?? 0, isScheduled: str('is_scheduled') == '1' || str('is_scheduled') == 'true', scheduledAt: str('scheduled_at'), + isSmsRide: str('is_sms') == '1' || str('is_sms') == 'true', ); } diff --git a/siro_driver/lib/translations_ar.json b/siro_driver/lib/translations_ar.json index 2ca0883b..07a91a03 100644 --- a/siro_driver/lib/translations_ar.json +++ b/siro_driver/lib/translations_ar.json @@ -947,5 +947,7 @@ "You have no upcoming scheduled rides": "ليس لديك رحلات مجدولة قادمة", "Starting Point": "نقطة الانطلاق", "Destination": "وجهة الوصول", - "Failed to fetch scheduled rides": "تعذّر جلب الرحلات المجدولة" -} \ No newline at end of file + "Failed to fetch scheduled rides": "تعذّر جلب الرحلات المجدولة", + "Passenger offline — call them": "راكب بلا إنترنت — اتصل به", + "No internet — call": "بلا إنترنت — اتصل" +} diff --git a/siro_driver/lib/translations_en.json b/siro_driver/lib/translations_en.json index ff3865cc..a42601d7 100644 --- a/siro_driver/lib/translations_en.json +++ b/siro_driver/lib/translations_en.json @@ -905,5 +905,7 @@ "You have no upcoming scheduled rides": "You have no upcoming scheduled rides", "Starting Point": "Starting Point", "Destination": "Destination", - "Failed to fetch scheduled rides": "Failed to fetch scheduled rides" -} \ No newline at end of file + "Failed to fetch scheduled rides": "Failed to fetch scheduled rides", + "Passenger offline — call them": "Passenger offline — call them", + "No internet — call": "No internet — call" +} diff --git a/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart b/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart index ba24d980..4f93ce54 100755 --- a/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart +++ b/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart @@ -6,6 +6,7 @@ import 'package:siro_driver/constant/colors.dart'; import 'package:siro_driver/controller/home/captin/order_request_controller.dart'; import 'package:siro_driver/views/widgets/offer_type_badge.dart'; import 'package:siro_driver/views/widgets/scheduled_ride_badge.dart'; +import 'package:siro_driver/views/widgets/sms_ride_badge.dart'; import 'package:siro_driver/constant/currency.dart'; import 'package:siro_driver/views/widgets/driver_earnings_badge.dart'; @@ -123,6 +124,12 @@ class OrderRequestPage extends StatelessWidget { rideInfo: controller.scheduleInfo!), const SizedBox(width: 10), ], + // راكب بلا إنترنت — الاتصال هو القناة الوحيدة + if (SmsRideBadge.isSmsRide( + controller.scheduleInfo)) ...[ + SmsRideBadge(rideInfo: controller.scheduleInfo!), + const SizedBox(width: 10), + ], const Icon(Icons.near_me, color: Colors.amber, size: 16), const SizedBox(width: 8), diff --git a/siro_driver/lib/views/notification/available_rides_page.dart b/siro_driver/lib/views/notification/available_rides_page.dart index 34412440..e6091cb9 100755 --- a/siro_driver/lib/views/notification/available_rides_page.dart +++ b/siro_driver/lib/views/notification/available_rides_page.dart @@ -6,6 +6,7 @@ import 'package:get/get.dart'; import 'package:intaleq_maps/intaleq_maps.dart'; // لتحديد الأنواع إذا لزم import '../widgets/scheduled_ride_badge.dart'; +import '../widgets/sms_ride_badge.dart'; import '../../constant/box_name.dart'; import '../../constant/colors.dart'; import '../../constant/links.dart'; @@ -195,6 +196,12 @@ class RideAvailableCard extends StatelessWidget { const SizedBox(height: 8), ScheduledRideBadge(rideInfo: rideInfo, compact: true), ], + // راكب طلب برسالة نصية — لا يملك إنترنت، والاتصال هو + // الوسيلة الوحيدة للتواصل معه. + if (SmsRideBadge.isSmsRide(rideInfo)) ...[ + const SizedBox(height: 8), + SmsRideBadge(rideInfo: rideInfo, compact: true), + ], if (rideInfo['has_steps']?.toString() == 'true') ...[ const SizedBox(height: 8), Container( diff --git a/siro_driver/lib/views/widgets/sms_ride_badge.dart b/siro_driver/lib/views/widgets/sms_ride_badge.dart new file mode 100644 index 00000000..6201a06a --- /dev/null +++ b/siro_driver/lib/views/widgets/sms_ride_badge.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +/// شارة «راكب بلا إنترنت». +/// +/// الراكب طلب رحلته برسالة نصية إلى رقم الشركة، لا من التطبيق. وهذا +/// يقلب طريقة التعامل معه رأساً على عقب: +/// +/// • لن يصله شيء في التطبيق — لا محادثة ولا إشعار «وصل السائق» +/// • لن يرى السائق يقترب على الخريطة، فلا يعرف أين صار +/// • لن يستطيع تعديل وجهته ولا إلغاءها من التطبيق +/// +/// السائق الذي لا يعرف هذا سيرسل رسالة داخل التطبيق، ينتظر رداً لن +/// يأتي، ثم يُلغي ظناً أن الراكب غير جاد. لذلك الشارة تصرّح بالمطلوب +/// صراحةً — «اتصل به» — لا تكتفي بوصف الحالة. +/// +/// المصدر: حقل `is_sms` في حمولة الرحلة، يضيفه buildMarketPayload في +/// backend/ride/rides/add_ride.php. غيابه = رحلة عادية بلا شارة. +class SmsRideBadge extends StatelessWidget { + final Map rideInfo; + + /// حجم مصغّر لبطاقات القائمة. + final bool compact; + + const SmsRideBadge({ + super.key, + required this.rideInfo, + this.compact = false, + }); + + static bool isSmsRide(Map? info) { + final v = info?['is_sms']?.toString(); + return v == '1' || v == 'true'; + } + + @override + Widget build(BuildContext context) { + if (!isSmsRide(rideInfo)) return const SizedBox.shrink(); + + // برتقالي محذّر — يفترق عن بنفسجي «المجدولة» بوضوح، فالشارتان قد + // تظهران متجاورتين ولا يجوز أن تلتبسا. + const color = Color(0xFFE07B39); + + return Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 8 : 12, + vertical: compact ? 4 : 6, + ), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(compact ? 12 : 20), + border: Border.all(color: color.withOpacity(0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.phone_in_talk, color: color, size: compact ? 14 : 16), + const SizedBox(width: 4), + Text( + compact ? 'No internet — call'.tr : 'Passenger offline — call them'.tr, + style: TextStyle( + color: color, + fontSize: compact ? 11 : 13, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +}