Update: 2026-08-07 16:25:34

This commit is contained in:
Hamza-Ayed
2026-08-07 16:25:34 +03:00
parent 9338e76894
commit cdd36d4777
16 changed files with 744 additions and 13 deletions
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* cron_insurance_premiums.php — قيد أقساط التأمين
* ─────────────────────────────────────────────────────────────
* ‏يقيّد قسط كل وثيقة نشطة بلغت دورتها. لا يخصم من رصيد أحد:
*
* ‏رصيد السائق يعيش في payment_server لا في هذه القاعدة، والخصم
* ‏المباشر من هنا كان سيعني كتابةً عابرة للحدود بلا معاملة موحّدة —
* ‏أي خطر رصيد ناقص بلا قيد يقابله، أو قيد بلا خصم. فنقيّد استحقاقاً
* ‏هنا، وتقرأه التسوية.
*
* ‏الحماية من الاحتساب المزدوج طبقتان:
* ١. `last_charged_on` في الوثيقة — نفلتر به قبل العمل
* ٢. مفتاح فريد (policy_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 {
$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";
+18 -9
View File
@@ -2,16 +2,22 @@
require_once __DIR__ . '/../connect.php'; require_once __DIR__ . '/../connect.php';
// Get the values from the request // ‏المسار القديم للتأمين الصحي. المسار الجديد (خطط، أهلية، أقساط) في
$driver_id = filterRequest("driver_id"); // ‏plans.php و subscribe.php و get.php — هذا يبقى لمن سُجّل هنا سابقاً.
$assured = filterRequest("assured"); // إذا كانت قيمة حساسة يجب تشفيرها
$health_insurance_provider = filterRequest("health_insurance_provider"); // إذا كانت حساسة، شفرها
// إذا تحتاج تشفير، فعّل التالي: // ‏الهوية من الـJWT لا من الطلب. كان الملف يقرأ driver_id من المدخلات
// $assured = $encryptionHelper->encryptData($assured); // ‏بلا أي فحص، فأي حامل رمز صالح يكتب سجل تأمين باسم أي سائق.
// $health_insurance_provider = $encryptionHelper->encryptData($health_insurance_provider); $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` ( $sql = "INSERT INTO `driver_health_assurance` (
`driver_id`, `driver_id`,
`assured`, `assured`,
@@ -20,7 +26,10 @@ $sql = "INSERT INTO `driver_health_assurance` (
:driver_id, :driver_id,
:assured, :assured,
:health_insurance_provider :health_insurance_provider
)"; )
ON DUPLICATE KEY UPDATE
`assured` = VALUES(`assured`),
`health_insurance_provider` = VALUES(`health_insurance_provider`)";
$stmt = $con->prepare($sql); $stmt = $con->prepare($sql);
$stmt->bindParam(':driver_id', $driver_id); $stmt->bindParam(':driver_id', $driver_id);
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* driver_assurance/cancel.php — إلغاء وثيقة التأمين
*
* ‏الإلغاء يوقف الأقساط القادمة ولا يمسّ الماضي: القيود المستحقّة تبقى
* ‏مستحقّة. تصفيرها عند الإلغاء كانت ستجعل الإلغاء وسيلة للتهرّب من
* ‏أيام تغطية استفاد منها السائق فعلاً.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/eligibility.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$policy = assuranceActivePolicy($con, $driverId);
if (!$policy) {
jsonError('لا توجد وثيقة نشطة');
}
$reason = filterRequest('reason') ?: 'بطلب السائق';
try {
// ‏الشرط على driver_id إلى جانب المعرّف: الوثيقة أتت من استعلام
// ‏مقيَّد بالسائق أصلاً، لكن الشرط هنا يجعل الاستعلام آمناً بذاته
// ‏لو أعيد استعماله يوماً في سياق آخر.
$st = $con->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']
: 'أُلغيت الوثيقة');
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* driver_assurance/eligibility.php — حساب أهلية السائق للتأمين
* ─────────────────────────────────────────────────────────────
* ‏قرار المالك: التأمين ليس خدمة تُمنح لكل من سجّل، بل مكافأة استمرار.
* ‏لا يستحقّه السائق إلا بعد عدد رحلات مكتملة وتقييم جيد.
*
* ‏المنطق التجاري خلفه: القسط كلفة متكررة تتحمّلها الشركة عن السائق أو
* ‏تخصمها من أرباحه. منحه لمن قد يختفي بعد أسبوع يحوّل الأداة من
* ‏ولاء إلى نزيف — والسائق الذي أتمّ مئتي رحلة بتقييم جيد أثبت أنه
* ‏باقٍ، وهو بالضبط من نريد قفله معنا.
*
* ‏الأرقام في جدول الخطط لا في هذا الملف: الشريك سيغيّرها، ومصر تختلف
* ‏عن الأردن، وتعديل صف أرخص من نشر إصدار.
*/
/**
* ‏يقيس السائق مقابل شروط خطة.
*
* @return array{
* eligible: bool,
* rides: int, rating: float, account_days: int,
* reasons: string[]
* }
*/
function assuranceCheckEligibility(PDO $con, string $driverId, array $plan): array
{
$rides = 0;
$rating = 0.0;
$days = 0;
try {
// ‏الرحلات المكتملة فقط. الحالة مكتوبة بحرفين مختلفين في
// ‏الجدول تاريخياً ('Finished' و'finished')، وإغفال أحدهما
// ‏يُنقص عدّاد السائق إلى النصف تقريباً.
$st = $con->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;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
/**
* driver_assurance/get.php — حالة تأمين السائق
*
* ‏كان هذا الملف فارغاً (صفر بايت) — يُستدعى فيرد بلا شيء.
*
* ‏يرد بالوثيقة النشطة، وملخّص دفتر أقساطها، وآخر القيود. الدفتر جزء
* ‏من الرد لا نقطة منفصلة: السائق الذي يُخصم من أرباحه يجب أن يرى
* ‏مقابل ماذا في الشاشة نفسها، وإلا صار الخصم مفاجأة تُفقد الثقة.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/eligibility.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$policy = assuranceActivePolicy($con, $driverId);
if (!$policy) {
jsonSuccess([
'has_policy' => 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');
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* driver_assurance/plans.php — الخطط المتاحة وأهلية السائق لكلٍّ منها
*
* ‏نرد بكل الخطط لا المؤهَّل لها فقط، ومع كل واحدة سبب عدم الأهلية إن
* ‏وُجد. إخفاء ما لا يستحقه السائق يجعل التأمين مجهولاً عنده؛ إظهاره
* ‏مع «ينقصك ٤٠ رحلة» يحوّله إلى هدف يسعى إليه — وهذا هو مقصد الشرط
* ‏أصلاً: ربط السائق بالمنصّة.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/eligibility.php';
// ‏الهوية من الـJWT لا من الطلب — الأهلية معلومة عن السائق نفسه،
// ‏وقراءتها بمعرّف مُرسَل تكشف أرقام غيره.
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$country = getenv('APP_COUNTRY') ?: 'Jordan';
try {
$st = $con->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');
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* driver_assurance/subscribe.php — اشتراك السائق في خطة تأمين
*
* ‏الأهلية تُحسب هنا من جديد لا يُوثق بما أرسله التطبيق: الشاشة قد تكون
* ‏مفتوحة منذ ساعة، وقد يُعدَّل الطلب يدوياً. النقطة التي تمنح تغطية
* ‏مالية لا تصدّق عميلها.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/eligibility.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$planId = (int) (filterRequest('plan_id', 'int') ?: 0);
if ($planId <= 0) {
jsonError('plan_id is required');
}
try {
$st = $con->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'),
], 'تم تفعيل وثيقة التأمين');
@@ -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;
+3
View File
@@ -49,6 +49,9 @@ function buildMarketPayload($rideId, $lat, $lng, $payloadData, $extraMarketData
// ‏ونافذة الأوفرلي. غيابها = رحلة لحظية عادية. // ‏ونافذة الأوفرلي. غيابها = رحلة لحظية عادية.
'is_scheduled' => $payloadData[39] ?? '', 'is_scheduled' => $payloadData[39] ?? '',
'scheduled_at' => $payloadData[40] ?? '', 'scheduled_at' => $payloadData[40] ?? '',
// ‏راكب بلا إنترنت: طلب عبر رسالة نصية. لن يصله شات ولا إشعار،
// ‏والاتصال الهاتفي هو وسيلة التواصل الوحيدة معه.
'is_sms' => $payloadData[41] ?? '',
], $extraMarketData); ], $extraMarketData);
} }
+8
View File
@@ -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 */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
# ============================================================ # ============================================================
# الإشعارات والتسويق # الإشعارات والتسويق
# ============================================================ # ============================================================
@@ -41,11 +41,19 @@ class OrderData {
/// موعد الانطلاق كما حجزه الراكب. فارغ في الرحلات العادية. /// موعد الانطلاق كما حجزه الراكب. فارغ في الرحلات العادية.
final String scheduledAt; final String scheduledAt;
/// طلب وصل عبر رسالة نصية — الراكب بلا إنترنت.
///
/// ليست تفصيلاً تجميلياً: هذا الراكب لن يصله شات ولا إشعار ولن يرى
/// السائق على الخريطة. الاتصال الهاتفي هو القناة الوحيدة، والسائق
/// الذي لا يعرف ذلك سينتظر رداً لن يأتي ثم يُلغي.
final bool isSmsRide;
OrderData({ OrderData({
this.offerType, this.offerType,
this.offerExpiresIn = 0, this.offerExpiresIn = 0,
this.isScheduled = false, this.isScheduled = false,
this.scheduledAt = '', this.scheduledAt = '',
this.isSmsRide = false,
required this.customerName, required this.customerName,
required this.customerToken, required this.customerToken,
required this.tripDistanceKm, required this.tripDistanceKm,
@@ -164,6 +172,10 @@ class OrderData {
isScheduled: list.length > 39 && isScheduled: list.length > 39 &&
(list[39].toString() == '1' || list[39].toString() == 'true'), (list[39].toString() == '1' || list[39].toString() == 'true'),
scheduledAt: list.length > 40 ? list[40].toString() : '', 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, offerExpiresIn: int.tryParse(str('offer_expires_in', '0')) ?? 0,
isScheduled: str('is_scheduled') == '1' || str('is_scheduled') == 'true', isScheduled: str('is_scheduled') == '1' || str('is_scheduled') == 'true',
scheduledAt: str('scheduled_at'), scheduledAt: str('scheduled_at'),
isSmsRide: str('is_sms') == '1' || str('is_sms') == 'true',
); );
} }
+3 -1
View File
@@ -947,5 +947,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": "تعذّر جلب الرحلات المجدولة",
"Passenger offline — call them": "راكب بلا إنترنت — اتصل به",
"No internet — call": "بلا إنترنت — اتصل"
} }
+3 -1
View File
@@ -905,5 +905,7 @@
"You have no upcoming scheduled rides": "You have no upcoming scheduled rides", "You have no upcoming scheduled rides": "You have no upcoming scheduled rides",
"Starting Point": "Starting Point", "Starting Point": "Starting Point",
"Destination": "Destination", "Destination": "Destination",
"Failed to fetch scheduled rides": "Failed to fetch scheduled rides" "Failed to fetch scheduled rides": "Failed to fetch scheduled rides",
"Passenger offline — call them": "Passenger offline — call them",
"No internet — call": "No internet — call"
} }
@@ -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/controller/home/captin/order_request_controller.dart';
import 'package:siro_driver/views/widgets/offer_type_badge.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/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/constant/currency.dart';
import 'package:siro_driver/views/widgets/driver_earnings_badge.dart'; import 'package:siro_driver/views/widgets/driver_earnings_badge.dart';
@@ -123,6 +124,12 @@ class OrderRequestPage extends StatelessWidget {
rideInfo: controller.scheduleInfo!), rideInfo: controller.scheduleInfo!),
const SizedBox(width: 10), const SizedBox(width: 10),
], ],
// راكب بلا إنترنت — الاتصال هو القناة الوحيدة
if (SmsRideBadge.isSmsRide(
controller.scheduleInfo)) ...[
SmsRideBadge(rideInfo: controller.scheduleInfo!),
const SizedBox(width: 10),
],
const Icon(Icons.near_me, const Icon(Icons.near_me,
color: Colors.amber, size: 16), color: Colors.amber, size: 16),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -6,6 +6,7 @@ import 'package:get/get.dart';
import 'package:intaleq_maps/intaleq_maps.dart'; // لتحديد الأنواع إذا لزم import 'package:intaleq_maps/intaleq_maps.dart'; // لتحديد الأنواع إذا لزم
import '../widgets/scheduled_ride_badge.dart'; import '../widgets/scheduled_ride_badge.dart';
import '../widgets/sms_ride_badge.dart';
import '../../constant/box_name.dart'; import '../../constant/box_name.dart';
import '../../constant/colors.dart'; import '../../constant/colors.dart';
import '../../constant/links.dart'; import '../../constant/links.dart';
@@ -195,6 +196,12 @@ class RideAvailableCard extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
ScheduledRideBadge(rideInfo: rideInfo, compact: true), ScheduledRideBadge(rideInfo: rideInfo, compact: true),
], ],
// راكب طلب برسالة نصية — لا يملك إنترنت، والاتصال هو
// الوسيلة الوحيدة للتواصل معه.
if (SmsRideBadge.isSmsRide(rideInfo)) ...[
const SizedBox(height: 8),
SmsRideBadge(rideInfo: rideInfo, compact: true),
],
if (rideInfo['has_steps']?.toString() == 'true') ...[ if (rideInfo['has_steps']?.toString() == 'true') ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
@@ -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,
),
),
],
),
);
}
}