Update: 2026-08-07 16:25:34
This commit is contained in:
@@ -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";
|
||||
@@ -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);
|
||||
|
||||
@@ -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']
|
||||
: 'أُلغيت الوثيقة');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -49,6 +49,9 @@ function buildMarketPayload($rideId, $lat, $lng, $payloadData, $extraMarketData
|
||||
// ونافذة الأوفرلي. غيابها = رحلة لحظية عادية.
|
||||
'is_scheduled' => $payloadData[39] ?? '',
|
||||
'scheduled_at' => $payloadData[40] ?? '',
|
||||
// راكب بلا إنترنت: طلب عبر رسالة نصية. لن يصله شات ولا إشعار،
|
||||
// والاتصال الهاتفي هو وسيلة التواصل الوحيدة معه.
|
||||
'is_sms' => $payloadData[41] ?? '',
|
||||
], $extraMarketData);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user