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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
# ============================================================
|
||||
# الإشعارات والتسويق
|
||||
# ============================================================
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -947,5 +947,7 @@
|
||||
"You have no upcoming scheduled rides": "ليس لديك رحلات مجدولة قادمة",
|
||||
"Starting Point": "نقطة الانطلاق",
|
||||
"Destination": "وجهة الوصول",
|
||||
"Failed to fetch scheduled rides": "تعذّر جلب الرحلات المجدولة"
|
||||
}
|
||||
"Failed to fetch scheduled rides": "تعذّر جلب الرحلات المجدولة",
|
||||
"Passenger offline — call them": "راكب بلا إنترنت — اتصل به",
|
||||
"No internet — call": "بلا إنترنت — اتصل"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
"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/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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user