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';
// 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);
+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] ?? '',
'scheduled_at' => $payloadData[40] ?? '',
// ‏راكب بلا إنترنت: طلب عبر رسالة نصية. لن يصله شات ولا إشعار،
// ‏والاتصال الهاتفي هو وسيلة التواصل الوحيدة معه.
'is_sms' => $payloadData[41] ?? '',
], $extraMarketData);
}