Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-09 16:56:13 +03:00
parent 95e2e4f35d
commit b64debaa88
1058 changed files with 164327 additions and 113928 deletions
@@ -0,0 +1,19 @@
-- ============================================================
-- scraped_competitor_prices: add distance_km / duration_min
-- ============================================================
-- backend/bot/worker.php computes both values via OSRM
-- (core/osrm_routing.php) and has always listed them in its
-- INSERT, but the columns were never created — so every
-- production bot submission failed with a PDOException.
--
-- Consumers currently derive distance as
-- (price_amount / price_per_km)
-- e.g. Admin/marketing/what_if_simulator.php and
-- Admin/marketing/trigger_campaign.php
-- Those keep working unchanged; the new columns are additive
-- and nullable, so historical rows stay valid.
-- ============================================================
ALTER TABLE `scraped_competitor_prices`
ADD COLUMN `distance_km` DECIMAL(8,3) DEFAULT NULL AFTER `price_per_km`,
ADD COLUMN `duration_min` DECIMAL(8,2) DEFAULT NULL AFTER `distance_km`;
@@ -0,0 +1,36 @@
-- ============================================================
-- admin_audit_log — جدول سجل تدقيق عمليات الأدمن
-- ============================================================
-- الجدول كان مستخدماً في الكود ولا وجود له في schema_primary.sql، أي أنه
-- غير موجود على أي نشر نظيف. والنتيجة:
--
-- • logAudit() في backend/functions.php يبتلع الاستثناء ويكتفي بـ
-- error_log، فكل عملية تدقيق كانت تفشل بصمت (تحديث كازان، تعديل سائق،
-- إرسال إشعار جماعي، تشغيل حملة، تحديث إصدار التطبيق).
-- • شاشة "سجلات الأمان" تظهر فارغة دائماً — لا لأن العرض معطّل بل لأن
-- الاستعلام لا يجد جدولاً.
--
-- الأعمدة هنا هي اتحاد ما يكتبه المصدران المختلفان:
-- - functions.php::logAudit → admin_id, action, table_name, record_id, details
-- - Admin/v2/quality/blacklist_manager.php → admin_id, admin_phone, action, table_name, entity_type, details
-- ولذلك فالأعمدة غير المشتركة تقبل NULL.
--
-- ملاحظة الترميز: audit_logs.php يربط admin_id مع employee.id و adminUser.id
-- عبر COLLATE utf8mb4_general_ci صراحةً، فنُثبّت الترميز نفسه هنا لتفادي
-- خطأ "Illegal mix of collations" عند الربط.
-- ============================================================
CREATE TABLE IF NOT EXISTS `admin_audit_log` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`admin_id` VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
`admin_phone` VARCHAR(32) DEFAULT NULL,
`action` VARCHAR(255) NOT NULL,
`table_name` VARCHAR(100) DEFAULT NULL,
`entity_type` VARCHAR(100) DEFAULT NULL,
`record_id` VARCHAR(100) DEFAULT NULL,
`details` TEXT DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_admin_id` (`admin_id`),
KEY `idx_created_at` (`created_at`),
KEY `idx_action` (`action`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
@@ -0,0 +1,50 @@
-- ============================================================
-- marketing_tasks + marketing_reports — جدولا محرّك التسويق والبوت الاجتماعي
-- ============================================================
-- كلا الجدولين مستعمل في الكود ولا وجود لهما في schema_primary.sql — نفس
-- حالة admin_audit_log بالضبط. الأثر:
--
-- • marketing_tasks هو طابور مهام البوت الاجتماعي:
-- يكتب فيه cron_insert_task.php (كل 4 ساعات) و insert_autonomous_task.php
-- ويقرأ منه marketing_engine/index.php ليخدم socialBot.
-- بغيابه لا يستلم البوت مهمة واحدة أبداً، ويفشل الكرون بصمت.
--
-- • marketing_reports هو مصدر شاشة "الذكاء الاجتماعي":
-- يكتب فيه telegram_scraper.php (كل 3 ساعات) و cron_weekly_report.php
-- و index.php (evaluate_posts).
-- بغيابه تظهر الشاشة فارغة دائماً — وهو ما لوحظ.
--
-- الأعمدة مستخرجة من مواضع الاستعمال الفعلية في:
-- marketing_engine/index.php, cron_insert_task.php, cron_weekly_report.php,
-- insert_autonomous_task.php, telegram_scraper.php, insert_test_task.php
-- ============================================================
CREATE TABLE IF NOT EXISTS `marketing_tasks` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`platform` VARCHAR(50) NOT NULL DEFAULT 'facebook',
`type` VARCHAR(100) NOT NULL,
-- 'pending' هي الحالة التي يبحث عنها get_task؛ أي قيمة أخرى تعني
-- أن المهمة قيد التنفيذ أو انتهت.
`status` VARCHAR(30) NOT NULL DEFAULT 'pending',
`target_url` TEXT DEFAULT NULL,
`content_text` TEXT DEFAULT NULL,
`media_url` TEXT DEFAULT NULL,
`error_message` TEXT DEFAULT NULL,
`completed_at` TIMESTAMP NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- get_task يرشّح بـ status + platform ويرتّب بـ id
KEY `idx_status_platform` (`status`, `platform`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `marketing_reports` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`platform` VARCHAR(50) NOT NULL DEFAULT 'all',
`report_html` LONGTEXT NOT NULL,
-- cron_weekly_report.php يكتب 1؛ التقارير اللحظية تترك 0
`is_weekly` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- get_reports يرتّب بـ created_at تنازلياً
KEY `idx_created_at` (`created_at`),
KEY `idx_is_weekly` (`is_weekly`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,75 @@
-- 2026_08_07_complaint_resolution.sql
--
-- ‏دورة الشكوى وأداة التعويض.
--
-- ‏الوضع اليوم: serviceapp/update_complaint.php يقلب الحالة ويكتب نصّ
-- ‏قرار. لا تعويض، لا تصنيف سبب، ولا قياس زمن. والموظف الذي يريد
-- ‏تعويض راكب متضرر لا يملك أي أداة — فيَعِد شفهياً أو لا يفعل شيئاً.
--
-- ‏كلمة "refund" لا ترد إطلاقاً في مسار الرحلات، فقط في وحدة الطعام.
--
-- ‏هذا الترحيل يحوّل الدعم من "استقبال شكاوى" إلى "إغلاق قضايا".
--
-- ⚠️ ‏يُنفَّذ على قاعدة primary (jorSiroDB) وحدها.
-- ══════════════════════════════════════════════════════════════
-- ١) قياس الزمن (SLA)
--
-- ‏date_resolved موجود أصلاً، لكن زمن **أول رد** هو المؤشر الذي يشعر به
-- ‏العميل فعلاً — شكوى تُغلق بعد يومين بردٍّ خلال دقائق أفضل بكثير من
-- ‏شكوى تُغلق بعد ساعة بلا أن يعلم صاحبها شيئاً.
-- ══════════════════════════════════════════════════════════════
ALTER TABLE `complaint`
ADD COLUMN `first_response_at` DATETIME NULL DEFAULT NULL
COMMENT 'لحظة أول رد من الدعم — يُختم مرة واحدة ولا يتغيّر',
ADD COLUMN `assigned_to` VARCHAR(100) NULL DEFAULT NULL
COMMENT 'معرّف الموظف المسؤول عن القضية',
ADD COLUMN `reason_code` VARCHAR(40) NULL DEFAULT NULL
COMMENT 'تصنيف موحّد للسبب — قائمة مغلقة في resolve_complaint.php',
ADD INDEX `idx_status_filed` (`statusComplaint`, `date_filed`),
ADD INDEX `idx_ride` (`ride_id`);
-- ══════════════════════════════════════════════════════════════
-- ٢) سجل التعويضات
--
-- ‏جدول مستقل لا أعمدة في complaint: الشكوى الواحدة قد تُعوَّض أكثر من
-- ‏مرة (استرجاع جزئي ثم رصيد اعتذار)، وكل حركة مالية تحتاج أثراً مستقلاً
-- ‏يُراجَع ويُدقَّق.
-- ══════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS `complaint_compensations` (
`id` INT NOT NULL AUTO_INCREMENT,
`complaint_id` INT NOT NULL,
`ride_id` VARCHAR(20) NULL DEFAULT NULL,
`beneficiary_type` VARCHAR(20) NOT NULL
COMMENT 'passenger | driver — لمن ذهب التعويض',
`beneficiary_id` VARCHAR(100) NOT NULL,
`kind` VARCHAR(20) NOT NULL
COMMENT 'refund (استرجاع من قيمة الرحلة) | goodwill (رصيد اعتذار)',
`amount` DECIMAL(12,3) NOT NULL,
`currency` VARCHAR(5) NOT NULL DEFAULT 'SYP',
`issued_by` VARCHAR(100) NOT NULL COMMENT 'الموظف الذي صرفه',
`note` VARCHAR(500) NULL DEFAULT NULL,
-- ‏نتيجة نداء المحفظة. الفشل يبقى مسجَّلاً للتسوية اليدوية بدل أن
-- ‏يختفي في اللوجز — العميل وُعد بمبلغ ويجب أن يصله.
`transfer_status` VARCHAR(20) NOT NULL DEFAULT 'pending'
COMMENT 'pending | success | failed',
`transfer_code` SMALLINT NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_complaint` (`complaint_id`),
KEY `idx_beneficiary` (`beneficiary_type`, `beneficiary_id`),
KEY `idx_failed` (`transfer_status`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='تعويضات الشكاوى — كل حركة مالية بأثر مستقل قابل للتدقيق';
-- ‏للتراجع:
-- ALTER TABLE `complaint` DROP COLUMN `first_response_at`,
-- DROP COLUMN `assigned_to`, DROP COLUMN `reason_code`,
-- DROP INDEX `idx_status_filed`, DROP INDEX `idx_ride`;
-- DROP TABLE `complaint_compensations`;
@@ -0,0 +1,59 @@
-- 2026_08_07_driver_cancel_compensation.sql
--
-- ‏تعويض السائق عند عدم حضور الراكب (إلغاء السائق بعد الوصول والانتظار).
-- ‏النموذج: التعويض = مسافة الاقتراب إلى الراكب × سعر الكيلومتر لنوع السيارة
-- ‏ + دقائق الانتظار الفعلية × سعر الدقيقة حسب وقت اليوم.
-- ‏يُخصم من محفظة الراكب ويُضاف لمحفظة السائق. الأسعار من kazan نفسه الذي
-- ‏تُسعَّر به الرحلة، عبر ride/pricing/pricing_helper.php.
--
-- ‏يستبدل هذا الملف مقاربة "الرسم الثابت" السابقة: المبلغ لم يعد يُقرأ من
-- ‏طلب العميل (كان filterRequest("penalty_fee") بلا سقف ولا تحقق)، ولم يعد
-- ‏رقماً ثابتاً — صار محسوباً من بيانات مسجَّلة على الخادم.
--
-- ⚠️ ‏هذا الملف لقاعدة primary (jorSiroDB) حصراً. الملف التوأم _ride.sql يُنفَّذ على قاعدة الرحلات (intaleq-ridesDB).
-- ══════════════════════════════════════════════════════════════
-- ١) سياسة التعويض لكل دولة (قاعدة primary فقط — kazan يُقرأ عبر $con)
-- ══════════════════════════════════════════════════════════════
ALTER TABLE `kazan`
ADD COLUMN `cancelCompensationCap` VARCHAR(10)
CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci
NOT NULL DEFAULT '0'
COMMENT 'سقف تعويض عدم حضور الراكب بعملة الدولة. 0 = التعويض معطّل بالكامل'
AFTER `startPrice`,
ADD COLUMN `cancelMinWaitMinutes` TINYINT UNSIGNED
NOT NULL DEFAULT 5
COMMENT 'أقل دقائق انتظار بعد الوصول قبل أن يستحق السائق تعويضاً'
AFTER `cancelCompensationCap`;
-- ══════════════════════════════════════════════════════════════
-- ٢) بيانات الوصول والاقتراب (قاعدتا primary و ride)
--
-- ‏هذان العمودان للتوثيق النهائي فقط ولا يُكتبان أثناء الرحلة: القيمتان
-- ‏تعيشان في هاش Redis نفسه الذي يستخدمه السوكِت لحالة الرحلة
-- ‏(ride:$id:state، الحقول pickup_approach_km و driver_arrived_at)،
-- ‏وتُثبَّتان هنا كتابةً واحدة مدمجة في جملة تحديث الحالة عند الإلغاء.
-- ‏أي: صفر كتابات إضافية على المسار الحار (القبول والوصول).
-- ══════════════════════════════════════════════════════════════
ALTER TABLE `ride`
ADD COLUMN `driverArrivedAt` DATETIME NULL DEFAULT NULL
COMMENT 'لحظة تأكيد وصول السائق لنقطة الالتقاط — يختمها الخادم بعد تحقق الموقع',
ADD COLUMN `pickupApproachKm` FLOAT NOT NULL DEFAULT 0
COMMENT 'المسافة من موقع السائق لحظة القبول إلى نقطة الالتقاط (كم)';
-- ══════════════════════════════════════════════════════════════
-- ٣) تطبيق الإيقاف على الخادم (قاعدتا primary و ride)
-- ‏كان الإيقاف يُكتب في تخزين تطبيق السائق وحده، فيُفكّ بإعادة التثبيت.
--
-- ‏مسار الفحص في acceptRide.php هو مفتاح Redis بـ TTL (driver:$id:blocked)
-- ‏فينتهي الإيقاف تلقائياً بلا كرون تنظيف وبلا استعلام على كل قبول.
-- ‏هذا العمود سجل دائم للمراجعة الإدارية، يُكتب في الإلغاء الثالث فقط.
-- ══════════════════════════════════════════════════════════════
ALTER TABLE `driver`
ADD COLUMN `blockedUntil` DATETIME NULL DEFAULT NULL
COMMENT 'إيقاف استقبال الرحلات حتى هذا الوقت (إلغاءات متكررة). NULL = غير موقوف';
-- ‏للتراجع:
-- ALTER TABLE `kazan` DROP COLUMN `cancelCompensationCap`, DROP COLUMN `cancelMinWaitMinutes`;
-- ALTER TABLE `ride` DROP COLUMN `driverArrivedAt`, DROP COLUMN `pickupApproachKm`;
-- ALTER TABLE `driver` DROP COLUMN `blockedUntil`;
@@ -0,0 +1,44 @@
-- 2026_08_07_driver_cancel_compensation.sql
--
-- ‏تعويض السائق عند عدم حضور الراكب (إلغاء السائق بعد الوصول والانتظار).
-- ‏النموذج: التعويض = مسافة الاقتراب إلى الراكب × سعر الكيلومتر لنوع السيارة
-- ‏ + دقائق الانتظار الفعلية × سعر الدقيقة حسب وقت اليوم.
-- ‏يُخصم من محفظة الراكب ويُضاف لمحفظة السائق. الأسعار من kazan نفسه الذي
-- ‏تُسعَّر به الرحلة، عبر ride/pricing/pricing_helper.php.
--
-- ‏يستبدل هذا الملف مقاربة "الرسم الثابت" السابقة: المبلغ لم يعد يُقرأ من
-- ‏طلب العميل (كان filterRequest("penalty_fee") بلا سقف ولا تحقق)، ولم يعد
-- ‏رقماً ثابتاً — صار محسوباً من بيانات مسجَّلة على الخادم.
--
-- ⚠️ ‏هذا الملف لقاعدة ride (intaleq-ridesDB) حصراً. جدول kazan لا يُمسّ هنا: بنيته في قاعدة الرحلات أقدم ومختلفة الأعمدة، ولا يُقرأ في هذا المسار.
-- ══════════════════════════════════════════════════════════════
-- ٢) بيانات الوصول والاقتراب (قاعدتا primary و ride)
--
-- ‏هذان العمودان للتوثيق النهائي فقط ولا يُكتبان أثناء الرحلة: القيمتان
-- ‏تعيشان في هاش Redis نفسه الذي يستخدمه السوكِت لحالة الرحلة
-- ‏(ride:$id:state، الحقول pickup_approach_km و driver_arrived_at)،
-- ‏وتُثبَّتان هنا كتابةً واحدة مدمجة في جملة تحديث الحالة عند الإلغاء.
-- ‏أي: صفر كتابات إضافية على المسار الحار (القبول والوصول).
-- ══════════════════════════════════════════════════════════════
ALTER TABLE `ride`
ADD COLUMN `driverArrivedAt` DATETIME NULL DEFAULT NULL
COMMENT 'لحظة تأكيد وصول السائق لنقطة الالتقاط — يختمها الخادم بعد تحقق الموقع',
ADD COLUMN `pickupApproachKm` FLOAT NOT NULL DEFAULT 0
COMMENT 'المسافة من موقع السائق لحظة القبول إلى نقطة الالتقاط (كم)';
-- ══════════════════════════════════════════════════════════════
-- ٣) تطبيق الإيقاف على الخادم (قاعدتا primary و ride)
-- ‏كان الإيقاف يُكتب في تخزين تطبيق السائق وحده، فيُفكّ بإعادة التثبيت.
--
-- ‏مسار الفحص في acceptRide.php هو مفتاح Redis بـ TTL (driver:$id:blocked)
-- ‏فينتهي الإيقاف تلقائياً بلا كرون تنظيف وبلا استعلام على كل قبول.
-- ‏هذا العمود سجل دائم للمراجعة الإدارية، يُكتب في الإلغاء الثالث فقط.
-- ══════════════════════════════════════════════════════════════
ALTER TABLE `driver`
ADD COLUMN `blockedUntil` DATETIME NULL DEFAULT NULL
COMMENT 'إيقاف استقبال الرحلات حتى هذا الوقت (إلغاءات متكررة). NULL = غير موقوف';
-- ‏للتراجع:
-- ALTER TABLE `ride` DROP COLUMN `driverArrivedAt`, DROP COLUMN `pickupApproachKm`;
-- ALTER TABLE `driver` DROP COLUMN `blockedUntil`;
@@ -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;
@@ -0,0 +1,50 @@
-- 2026_08_07_leak_detection.sql
--
-- ‏كشف التسرب خارج المنصة — سجل خادمي مستقل عن بلاغ التطبيق.
--
-- ‏الموجود اليوم: جدول driver_ride_scam يملؤه **تطبيق السائق عن نفسه**
-- ‏(map_driver_controller.dart:823). نسخة معدّلة من التطبيق ببساطة لا
-- ‏ترسل البلاغ، فالكشف يعتمد على تعاون من نريد كشفه. ويبقى مفيداً كإشارة
-- ‏إضافية، لذلك لا نلمسه.
--
-- ‏الجديد: الخادم يستنتج النمط بنفسه من بيانات لا يملك السائق تزويرها —
-- ‏قَبِل، ثم أُلغيت، ولم تبدأ الرحلة قط. وتكرار نفس زوج (سائق، راكب) هو
-- ‏الإشارة عالية الثقة: صدفةً مرة، ونمطاً مرتين.
--
-- ⚠️ ‏يُنفَّذ على قاعدة primary (jorSiroDB) وحدها.
CREATE TABLE IF NOT EXISTS `driver_leak_events` (
`id` INT NOT NULL AUTO_INCREMENT,
`driver_id` VARCHAR(100) NOT NULL,
`passenger_id` VARCHAR(100) NOT NULL,
`ride_id` VARCHAR(20) NOT NULL,
`cancel_status` VARCHAR(40) NOT NULL
COMMENT 'الحالة التي انتهت بها الرحلة (CancelFromDriverAfterApply / cancelled_by_passenger)',
`pair_repeat` SMALLINT UNSIGNED NOT NULL DEFAULT 1
COMMENT 'كم مرة تكرر نفس زوج (سائق، راكب) بهذا النمط خلال النافذة',
`app_reported` TINYINT(1) NOT NULL DEFAULT 0
COMMENT 'هل بلّغ تطبيق السائق أيضاً (driver_ride_scam)؟ تطابقهما يرفع الثقة',
`confidence` DECIMAL(4,3) NOT NULL DEFAULT 0
COMMENT 'درجة الثقة 0..1 — يحسبها bot/cron_leak_detection.php',
`reviewed_by` VARCHAR(100) NULL DEFAULT NULL
COMMENT 'معرّف الإداري الذي راجع الحالة. NULL = لم تُراجع بعد',
`review_note` VARCHAR(255) NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
-- ‏يمنع الكرون من تسجيل نفس الرحلة مرتين عند كل مرور
UNIQUE KEY `uniq_ride` (`ride_id`),
KEY `idx_driver` (`driver_id`, `created_at`),
KEY `idx_pair` (`driver_id`, `passenger_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='أحداث تسرب مشتبهة يستنتجها الخادم — لا يبلّغ عنها التطبيق';
-- ‏driver_orders يُقرأ في هذا الكشف بالانضمام على order_id و driver_id،
-- ‏وهو اليوم بلا أي فهرس غير المفتاح الأساسي (id). الفحص كان سيمسح
-- ‏الجدول كاملاً عند كل مرور.
ALTER TABLE `driver_orders`
ADD INDEX `idx_order` (`order_id`),
ADD INDEX `idx_driver_created` (`driver_id`, `created_at`);
-- ‏للتراجع:
-- DROP TABLE `driver_leak_events`;
-- ALTER TABLE `driver_orders` DROP INDEX `idx_order`, DROP INDEX `idx_driver_created`;
@@ -0,0 +1,41 @@
-- 2026_08_07_passenger_cancel_policy.sql
--
-- ‏رسوم إلغاء الراكب بعد قبول السائق.
--
-- ‏متماثلة مع تعويض عدم الحضور (ترحيل 2026_08_07_driver_cancel_compensation):
-- ‏نفس المبدأ — من تسبب بضياع وقت الآخر يعوّضه، والمال يذهب **للسائق** لا
-- ‏للشركة. الفرق أن الزمن المحتسب هنا يبدأ من لحظة القبول لا من لحظة الوصول،
-- ‏لأن السائق يكون في طريقه لا واقفاً ينتظر.
--
-- ‏ثلاثة إعفاءات مبنية عمداً، لأن رسماً بلا إعفاء يعاقب الراكب على أخطاء
-- ‏ليست خطأه ويطرده من التطبيق:
-- ١. لا سائق قُبِل بعد → مجاني دائماً
-- ٢. خلال أول دقائق من القبول → مجاني (passengerCancelFreeMinutes)
-- ٣. السائق تأخّر ولم يصل → مجاني (driverLateExemptMinutes)
--
-- ⚠️ ‏يُنفَّذ على قاعدة primary (jorSiroDB) وحدها — kazan يُقرأ عبر $con.
ALTER TABLE `kazan`
ADD COLUMN `passengerCancelCap` VARCHAR(10)
CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci
NOT NULL DEFAULT '0'
COMMENT 'سقف رسم إلغاء الراكب بعملة الدولة. 0 = الرسم معطّل بالكامل'
AFTER `cancelMinWaitMinutes`,
ADD COLUMN `passengerCancelFreeMinutes` TINYINT UNSIGNED
NOT NULL DEFAULT 2
COMMENT 'دقائق الإلغاء المجاني بعد قبول السائق'
AFTER `passengerCancelCap`,
ADD COLUMN `driverLateExemptMinutes` TINYINT UNSIGNED
NOT NULL DEFAULT 10
COMMENT 'إن مرّت هذه الدقائق على القبول ولم يصل السائق، الإلغاء مجاني';
-- ‏عدّاد عدم الحضور للراكب — سجل دائم للمراجعة الإدارية.
-- ‏(الفحص السريع يتم من Redis، وهذا العمود للتقارير وقرارات الحظر اليدوية.)
ALTER TABLE `passengers`
ADD COLUMN `noShowCount` SMALLINT UNSIGNED NOT NULL DEFAULT 0
COMMENT 'مرات إلغاء الراكب بعد قبول السائق ودفعه رسماً';
-- ‏للتراجع:
-- ALTER TABLE `kazan` DROP COLUMN `passengerCancelCap`,
-- DROP COLUMN `passengerCancelFreeMinutes`, DROP COLUMN `driverLateExemptMinutes`;
-- ALTER TABLE `passengers` DROP COLUMN `noShowCount`;
@@ -0,0 +1,63 @@
-- 2026_08_07_safety_incidents.sql
--
-- ‏غرفة عمليات السلامة — أثر يمكن مراجعته لواقعة حقيقية.
--
-- ‏الوضع اليوم: زر SOS في تطبيق الراكب ينتهي عند رسالة واتساب لجهة اتصال
-- ‏شخصية خزّنها الراكب بنفسه (ui_interactions_controller.dart:145). لا نداء
-- ‏API، لا سجل، لا تنبيه لأحد داخل الشركة. لو وقعت حادثة فعلية، الشركة لا
-- ‏تعرف بها ولا تملك دليلاً عنها ولا تستطيع التدخل.
--
-- ‏هذا الجدول هو الأثر المفقود. رسالة الواتساب تبقى كما هي — تُرسل بالتوازي
-- ‏لا بدلاً، لأنها الأسرع وصولاً لمن يهمّه الأمر فعلاً.
--
-- ⚠️ ‏يُنفَّذ على قاعدة primary (jorSiroDB) وحدها.
CREATE TABLE IF NOT EXISTS `safety_incidents` (
`id` INT NOT NULL AUTO_INCREMENT,
-- ‏من أبلغ ولماذا
`reporter_type` VARCHAR(20) NOT NULL
COMMENT 'passenger | driver — من ضغط الزر',
`reporter_id` VARCHAR(100) NOT NULL,
`trigger_type` VARCHAR(30) NOT NULL DEFAULT 'sos_button'
COMMENT 'sos_button | shake | route_deviation | long_stop | overtime',
-- ‏سياق الواقعة
`ride_id` VARCHAR(20) NULL DEFAULT NULL,
`driver_id` VARCHAR(100) NULL DEFAULT NULL,
`passenger_id` VARCHAR(100) NULL DEFAULT NULL,
`lat` DECIMAL(10,7) NULL DEFAULT NULL,
`lng` DECIMAL(10,7) NULL DEFAULT NULL,
`note` VARCHAR(500) NULL DEFAULT NULL,
-- ‏لقطة الحالة وقت الواقعة، مجمّدة كما كانت.
-- ‏الرحلة تتغيّر بعد الحادثة (تُلغى، تُنهى، يُصفَّر السائق) فقراءتها لاحقاً
-- ‏لا تصف اللحظة. هذه اللقطة هي الدليل.
`snapshot` JSON NULL DEFAULT NULL,
-- ‏دورة المعالجة
`status` VARCHAR(20) NOT NULL DEFAULT 'open'
COMMENT 'open | acknowledged | resolved | false_alarm',
`acknowledged_by` VARCHAR(100) NULL DEFAULT NULL,
`acknowledged_at` DATETIME NULL DEFAULT NULL,
`resolution_note` VARCHAR(500) NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_status_created` (`status`, `created_at`),
KEY `idx_ride` (`ride_id`),
KEY `idx_driver` (`driver_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='بلاغات السلامة — السجل الوحيد القابل للمراجعة لواقعة';
-- ‏تجميد إسناد الرحلات للسائق حتى المراجعة.
-- ‏منفصل عن blockedUntil (عقوبة الإلغاءات المتكررة): ذاك مؤقت بساعات
-- ‏وينتهي وحده، وهذا يبقى حتى يرفعه إنسان.
ALTER TABLE `driver`
ADD COLUMN `safetyHoldIncidentId` INT NULL DEFAULT NULL
COMMENT 'رقم بلاغ السلامة الذي جمّد هذا السائق. NULL = غير مجمّد';
-- ‏للتراجع:
-- DROP TABLE `safety_incidents`;
-- ALTER TABLE `driver` DROP COLUMN `safetyHoldIncidentId`;
@@ -0,0 +1,65 @@
-- 2026_08_07_scheduled_rides.sql — الحجز المسبق
--
-- ‏شريحة مفقودة بالكامل: رحلات المطار، المواعيد الطبية، الدوام اليومي،
-- ‏رحلات الفجر. أعلى الشرائح استعداداً للدفع وأقلها حساسية للسعر.
--
-- ‏والزاوية الأهم: الحجز ليس ميزة راكب فقط — هو **مصدر توقّع طلب مؤكد**.
-- ‏رحلة محجوزة لبعد ست ساعات معلومة يقينية تُغذّي cron_predictive_demand
-- ‏والخريطة الحرارية بشيء أدق من أي تنبؤ إحصائي، وتسمح بتوجيه السائقين
-- ‏قبل الذروة لا بعدها.
--
-- ⚠️ ‏يُنفَّذ على قاعدة primary (jorSiroDB) وحدها: الكرون والتنبؤ يقرآن
-- ‏من primary، والرحلة الفعلية تُنشأ عبر add_ride.php الذي يكتب للاثنتين.
CREATE TABLE IF NOT EXISTS `scheduled_rides` (
`id` INT NOT NULL AUTO_INCREMENT,
`passenger_id` VARCHAR(100) NOT NULL,
-- ‏نقاط الرحلة — نفس صيغة ride.start_location ("lat,lng")
`start_location` VARCHAR(255) NOT NULL,
`end_location` VARCHAR(255) NOT NULL,
`start_name` VARCHAR(255) NULL DEFAULT NULL,
`end_name` VARCHAR(255) NULL DEFAULT NULL,
`car_type` VARCHAR(20) NOT NULL DEFAULT 'Speed',
`distance` FLOAT NOT NULL DEFAULT 0,
`duration` INT NOT NULL DEFAULT 0 COMMENT 'ثوانٍ',
-- ‏السعر التقديري وقت الحجز. ليس ملزماً: التسعير النهائي يحدث لحظة
-- ‏إنشاء الرحلة الفعلية، فسعر ما قبل ست ساعات لا يعرف الذروة ولا الطقس.
`estimated_price` DECIMAL(10,2) NOT NULL DEFAULT 0,
`scheduled_at` DATETIME NOT NULL COMMENT 'موعد انطلاق الرحلة كما طلبه الراكب',
-- ‏كم دقيقة قبل الموعد نبدأ البحث عن سائق. رحلة مطار تحتاج هامشاً أطول
-- ‏من رحلة داخل الحي، والراكب لا يُطلب منه ضبطها — نشتقها من المسافة.
`lead_minutes` SMALLINT UNSIGNED NOT NULL DEFAULT 15,
`status` VARCHAR(20) NOT NULL DEFAULT 'scheduled'
COMMENT 'scheduled = بانتظار موعده | dispatching = قفل مؤقت أثناء الإنشاء (يمنع الإطلاق المزدوج) | dispatched = أُنشئت رحلته | cancelled | expired = فات موعده بلا إسناد',
-- ‏الرحلة الفعلية التي وُلدت من هذا الحجز.
`ride_id` VARCHAR(20) NULL DEFAULT NULL,
-- ‏أختام التذكيرات: يُكتب الختم مرة واحدة فيمنع تكرار الإشعار على كل
-- ‏مرور للكرون. راكب يصله «رحلتك بعد ساعة» ستين مرة يُلغي الحجز ويحذف
-- ‏التطبيق.
`reminded_60_at` DATETIME NULL DEFAULT NULL,
`reminded_15_at` DATETIME NULL DEFAULT NULL,
`note` VARCHAR(255) NULL DEFAULT NULL,
`cancelled_reason` VARCHAR(255) NULL 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`),
-- ‏المفتاح الذي يعتمد عليه الكرون: يمسح المستحق دون قراءة الجدول كله.
KEY `idx_due` (`status`, `scheduled_at`),
KEY `idx_passenger` (`passenger_id`, `status`),
KEY `idx_ride` (`ride_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='الحجوزات المسبقة — ومصدر توقّع الطلب المؤكد';
-- ‏للتراجع:
-- DROP TABLE `scheduled_rides`;
@@ -0,0 +1,80 @@
-- ============================================================
-- طلب رحلة بلا إنترنت — عبر SMS (بند 3.1 من دراسة الفرص)
--
-- ‏المسار: الراكب يرسل رسالة نصية لرقمنا الثابت ← تطبيق أندرويد على
-- ‏ذلك الهاتف يوجّهها إلى webhook_sms/webhook.php ← نحلّلها وننشئ رحلة
-- ‏← نرد عليه برسالة، وحين يقبل سائق نرد برسالة ثانية فيها اسمه ولوحته.
--
-- ‏لا مشغّل اتصالات ولا ترخيص ولا رقم قصير — شريحة عادية في هاتف عادي.
-- ============================================================
-- ── ١) الوارد الخام ─────────────────────────────────────────
-- ‏نحفظ كل رسالة تصل كما وصلت، قبل أي تحليل. السبب: المحلّل سيخطئ في
-- ‏البداية حتماً (اللهجات، الأخطاء الإملائية، صيغ لم نتوقّعها)، وبلا
-- ‏الخام لن نعرف *لماذا* أخطأ ولا كيف نحسّنه.
CREATE TABLE IF NOT EXISTS `sms_inbox` (
`id` INT NOT NULL AUTO_INCREMENT,
`sender` VARCHAR(32) NOT NULL COMMENT 'رقم المرسل كما وصل من الهاتف',
`sender_norm` VARCHAR(32) NOT NULL COMMENT 'الرقم بعد التوحيد (بلا رموز، بمقدمة دولية)',
`body` TEXT NOT NULL,
`intent` ENUM('ride','payment','unknown') NOT NULL DEFAULT 'unknown',
`received_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sender_norm` (`sender_norm`),
KEY `idx_received` (`received_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── ٢) طلبات الرحلة المستخرجة ───────────────────────────────
-- ‏جدول وسيط بين الرسالة والرحلة، لا اختصار له. وظيفته الأمنية أساساً:
-- ‏add_ride.php يفتح مساراً داخلياً بمفتاح S2S لهذه الطلبات، وشرط المرور
-- ‏وجود صف هنا بحالة 'dispatching' يطابق الراكب ونقطة الانطلاق — تماماً
-- ‏كما فعلنا مع الحجز المسبق. مفتاح مسرّب وحده لا يخلق رحلة.
CREATE TABLE IF NOT EXISTS `sms_ride_requests` (
`id` INT NOT NULL AUTO_INCREMENT,
`inbox_id` INT DEFAULT NULL,
`sender_norm` VARCHAR(32) NOT NULL,
`passenger_id` VARCHAR(100) DEFAULT NULL COMMENT 'يُملأ بعد إيجاد/إنشاء الحساب',
`raw_start` VARCHAR(255) DEFAULT NULL COMMENT 'النص كما كتبه الراكب',
`raw_end` VARCHAR(255) DEFAULT NULL,
`start_location` VARCHAR(100) DEFAULT NULL COMMENT '"lat,lng"',
`end_location` VARCHAR(100) DEFAULT NULL,
`start_name` VARCHAR(255) DEFAULT NULL COMMENT 'اسم المعلم المطابَق',
`end_name` VARCHAR(255) DEFAULT NULL,
`distance` DOUBLE DEFAULT 0,
`duration` INT DEFAULT 0,
`status` ENUM('pending','dispatching','dispatched','failed','unparsed')
NOT NULL DEFAULT 'pending',
`ride_id` VARCHAR(100) DEFAULT NULL,
`attempts` TINYINT NOT NULL DEFAULT 0,
`fail_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`),
KEY `idx_status_created` (`status`, `created_at`),
KEY `idx_ride` (`ride_id`),
KEY `idx_sender` (`sender_norm`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── ٣) الصادر ───────────────────────────────────────────────
-- ‏طابور لا إرسال مباشر. مزوّدونا الحاليون (Kazumi / Nabeh / Intaleq)
-- ‏مسارات OTP لا رسائل حرة، فالمُرسِل اليوم هو نفس الهاتف المستقبِل:
-- ‏يسحب من هنا كل بضع ثوانٍ ويرسل عبر شريحته. الطابور يجعل استبدال
-- ‏المُرسِل لاحقاً (مشغّل، أو بوابة تجارية) تغييراً في طرف واحد.
CREATE TABLE IF NOT EXISTS `sms_outbox` (
`id` INT NOT NULL AUTO_INCREMENT,
`recipient` VARCHAR(32) NOT NULL,
`body` TEXT NOT NULL,
`context` VARCHAR(64) DEFAULT NULL COMMENT 'ride_created / driver_found / error ...',
`ref_id` VARCHAR(100) DEFAULT NULL COMMENT 'رقم الرحلة أو الطلب المرتبط',
`status` ENUM('pending','sending','sent','failed') NOT NULL DEFAULT 'pending',
`attempts` TINYINT NOT NULL DEFAULT 0,
`claimed_by` VARCHAR(64) DEFAULT NULL COMMENT 'معرّف الهاتف الذي سحب الرسالة',
`claimed_at` DATETIME DEFAULT NULL,
`sent_at` DATETIME DEFAULT NULL,
`last_error` VARCHAR(255) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
-- ‏الفهرس على (status, id) هو ما يجعل السحب رخيصاً مهما كبر الجدول.
KEY `idx_status_id` (`status`, `id`),
KEY `idx_claimed` (`status`, `claimed_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,49 @@
-- 2026_08_07_verify_all.sql — فحص حالة كل ترحيلات اليوم
--
-- ‏قراءة خالصة لا تُعدّل شيئاً. شغّله على أي قاعدة لتعرف ما نُفِّذ وما لا.
-- ‏العمود ok: 1 = موجود، 0 = ناقص.
--
-- sirodb jorSiroDB < backend/migrations/2026_08_07_verify_all.sql
-- sirodb 'intaleq-ridesDB' < backend/migrations/2026_08_07_verify_all.sql
SELECT 'kazan.cancelCompensationCap' AS item,
COUNT(*) AS ok FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kazan' AND COLUMN_NAME='cancelCompensationCap'
UNION ALL SELECT 'kazan.cancelMinWaitMinutes', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kazan' AND COLUMN_NAME='cancelMinWaitMinutes'
UNION ALL SELECT 'kazan.passengerCancelCap', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kazan' AND COLUMN_NAME='passengerCancelCap'
UNION ALL SELECT 'kazan.passengerCancelFreeMinutes', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kazan' AND COLUMN_NAME='passengerCancelFreeMinutes'
UNION ALL SELECT 'kazan.driverLateExemptMinutes', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kazan' AND COLUMN_NAME='driverLateExemptMinutes'
UNION ALL SELECT 'ride.driverArrivedAt', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ride' AND COLUMN_NAME='driverArrivedAt'
UNION ALL SELECT 'ride.pickupApproachKm', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ride' AND COLUMN_NAME='pickupApproachKm'
UNION ALL SELECT 'driver.blockedUntil', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='driver' AND COLUMN_NAME='blockedUntil'
UNION ALL SELECT 'driver.safetyHoldIncidentId', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='driver' AND COLUMN_NAME='safetyHoldIncidentId'
UNION ALL SELECT 'passengers.noShowCount', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='passengers' AND COLUMN_NAME='noShowCount'
UNION ALL SELECT 'complaint.first_response_at', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='complaint' AND COLUMN_NAME='first_response_at'
UNION ALL SELECT 'complaint.assigned_to', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='complaint' AND COLUMN_NAME='assigned_to'
UNION ALL SELECT 'complaint.reason_code', COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='complaint' AND COLUMN_NAME='reason_code'
UNION ALL SELECT 'driver_orders.idx_order', COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='driver_orders' AND INDEX_NAME='idx_order'
UNION ALL SELECT 'TABLE driver_leak_events', COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='driver_leak_events'
UNION ALL SELECT 'TABLE safety_incidents', COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='safety_incidents'
UNION ALL SELECT 'TABLE complaint_compensations', COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='complaint_compensations';
@@ -0,0 +1,239 @@
-- ============================================================
-- محرك الالتزامات العام — بند 3.4 من دراسة الفرص (اقتصاد السائق)
--
-- ‏سبب وجود هذا الملف ليس التوسّع المستقبلي، بل ثغرة قائمة في الإنتاج:
-- ‏`cron_insurance_premiums.php` يقيّد الأقساط في `insurance_premium_ledger`
-- ‏منذ إطلاقه، وتعليقه يقول «تقرأه التسوية» — والتسوية غير موجودة. لا
-- ‏مرجع واحد لذلك الجدول في المشروع كله خارج الكرون والـmigration. أي أن
-- ‏كل قسط تأمين قُيّد حتى اليوم ما زال `pending` ولم يُحصَّل قرشٌ منه.
--
-- ‏فالخيار كان: كتابة تسوية خاصة بالتأمين، أو تعميم النموذج مرة واحدة.
-- ‏والوقود والصيانة والتمويل — البنود الثلاثة التالية في اقتصاد السائق —
-- ‏كلها نفس الشكل: التزام دوري أو مقسّط يُخصم من أرباح السائق. كتابة
-- ‏تسوية لكل واحد منها تعني أربع نسخ من أخطر منطق في المنصة: المنطق
-- ‏الذي يلمس مال السائق.
--
-- ‏الفصل المعتمد:
-- • مُصدِر استحقاق لكل منتج → يقيّد «على السائق كذا»
-- • دفتر موحّد → سجل دائم واحد مهما كان المصدر
-- • محرك تسوية واحد → الجهة الوحيدة التي تلمس الرصيد
--
-- ‏جداول التأمين تبقى كما هي — لا تُحذف ولا تُعدَّل. تُنقل بياناتها هنا
-- ‏في نهاية هذا الملف، وتبقى الأصلية شاهداً تاريخياً.
-- ============================================================
-- ── ١) المنتجات ─────────────────────────────────────────────
-- ‏يعمّم `insurance_plans`. الفرق الجوهري عن الخطة: `kind` يحدّد سلوك
-- ‏الاستحقاق نفسه — الدوري يتكرّر بلا نهاية (تأمين)، والمقسّط له أصل
-- ‏محدود ينتهي بسداده (صيانة، تمويل)، والمسحوب يُقيَّد عند السحب لا
-- ‏على جدول (وقود).
CREATE TABLE IF NOT EXISTS `obligation_products` (
`id` INT NOT NULL AUTO_INCREMENT,
`code` VARCHAR(60) NOT NULL COMMENT 'معرّف ثابت يُستعمل في الكود',
`kind` ENUM('recurring','installment','drawdown') NOT NULL,
`name_ar` VARCHAR(160) NOT NULL,
`description_ar` TEXT DEFAULT NULL,
-- ‏الشريك الخارجي: شركة تأمين، سلسلة محطات، ورشة، أو بنك. عمود نصّي
-- ‏لا جدول: لا نعرف بعد ما إذا كانت لهذه الجهات دورة حياة تستحق
-- ‏جدولاً، وجدول فارغ الغرض أسوأ من عمود.
`partner_name` VARCHAR(160) DEFAULT NULL,
`billing_cycle` ENUM('daily','monthly','none') NOT NULL DEFAULT 'daily'
COMMENT 'none للمنتجات المسحوبة — لا دورة لها',
`currency` VARCHAR(10) NOT NULL DEFAULT 'JOD',
-- ── شروط الأهلية ──
-- ‏تُخزَّن مع المنتج لا في الكود: الشريك سيغيّرها، والسوق المصري يختلف
-- ‏عن الأردني، وتغيير رقم في صف أرخص من نشر إصدار. منقولة كما هي من
-- ‏`insurance_plans` لأن المنطق ذاته ينطبق على الوقود والصيانة: كلاهما
-- ‏ائتمان يُمنح لسائق قد يختفي.
`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,
-- ‏سقف الخصم اليومي كنسبة من أرباح اليوم. على مستوى المنتج لا النظام:
-- ‏قسط التأمين الصغير يحتمل نسبة أعلى من قرض صيانة كبير، والسقف الموحّد
-- ‏يعني إمّا خنق السائق أو إبطاء التحصيل.
`daily_cap_percent` DECIMAL(5,2) NOT NULL DEFAULT 25.00,
-- ‏ترتيب المزاحمة حين تستحق التزامات عدة في يوم واحد. الأصغر أولاً:
-- ‏التأمين (١٠) قبل الوقود (٢٠) قبل الصيانة (٣٠) — انقطاع التأمين
-- ‏يُلغي وثيقةً ويفقد السائق تغطيته، بينما تأخّر قسط صيانة يوماً لا
-- ‏يكلّف أحداً شيئاً.
`priority` SMALLINT NOT NULL DEFAULT 50,
`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_kind_active` (`kind`, `is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── ٢) التزامات السائقين ────────────────────────────────────
-- ‏يعمّم `driver_insurance_policies`: الاشتراك/العقد النشط بين سائق ومنتج.
CREATE TABLE IF NOT EXISTS `driver_obligations` (
`id` INT NOT NULL AUTO_INCREMENT,
`driver_id` VARCHAR(100) NOT NULL,
`product_id` INT NOT NULL,
`external_ref` VARCHAR(120) DEFAULT NULL COMMENT 'رقم الوثيقة/العقد لدى الشريك',
`status` ENUM('active','suspended','completed','cancelled')
NOT NULL DEFAULT 'active',
`started_at` DATE NOT NULL,
`ended_at` DATE DEFAULT NULL,
-- ‏مبلغ الدورة الواحدة. منسوخ من المنتج لحظة الاشتراك لا مقروءاً منه:
-- ‏رفع سعر الخطة غداً يجب ألّا يغيّر قسط من اشترك أمس بأثر رجعي.
`cycle_amount` DECIMAL(12,3) NOT NULL DEFAULT 0,
-- ‏للمقسّط فقط: الأصل وما سُدِّد منه. المنتج الدوري يتركهما صفراً —
-- ‏لا نهاية له فلا معنى لأصلٍ ينفد.
`principal_amount` DECIMAL(12,3) NOT NULL DEFAULT 0,
`principal_paid` DECIMAL(12,3) NOT NULL DEFAULT 0,
-- ‏لقطة الأهلية لحظة الاشتراك. بدونها لا يمكن الإجابة لاحقاً على
-- ‏«لماذا مُنح هذا السائق ائتماناً؟» حين ينخفض تقييمه أو تتغيّر الشروط.
`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`),
KEY `idx_driver_status` (`driver_id`, `status`),
KEY `idx_charge_sweep` (`status`, `last_charged_on`),
CONSTRAINT `fk_obligation_product` FOREIGN KEY (`product_id`)
REFERENCES `obligation_products` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── ٣) الدفتر الموحّد ───────────────────────────────────────
-- ‏يعمّم `insurance_premium_ledger` بفارق واحد جوهري: `amount_collected`
-- ‏و`amount_remaining`. القسط يُدفع كاملاً أو لا يُدفع، أما الوقود
-- ‏والصيانة فتحصيلهما جزئي بطبعه — سقف الخصم اليومي يعني أن قيداً بقيمة
-- ‏عشرة قد يُحصَّل على ثلاثة أيام. بلا هذين العمودين لا يمكن تمثيل ذلك
-- ‏إلا بتفتيت القيد، فيضيع أثر الاستحقاق الأصلي.
CREATE TABLE IF NOT EXISTS `obligation_ledger` (
`id` INT NOT NULL AUTO_INCREMENT,
`obligation_id` INT NOT NULL,
`driver_id` VARCHAR(100) NOT NULL,
`product_code` VARCHAR(60) NOT NULL COMMENT 'منسوخ للاستعلام بلا JOIN',
`charge_date` DATE NOT NULL COMMENT 'اليوم أو أول الشهر المحتسَب',
`amount` DECIMAL(12,3) NOT NULL COMMENT 'أصل الاستحقاق',
`amount_collected` DECIMAL(12,3) NOT NULL DEFAULT 0,
`amount_remaining` DECIMAL(12,3) NOT NULL COMMENT 'amount - amount_collected',
`currency` VARCHAR(10) NOT NULL DEFAULT 'JOD',
-- ‏partial ليست حالة عابرة بل مستقرّة: قيد حُصِّل بعضه ينتظر يوماً
-- ‏أفضل. waived للإعفاء الإداري — يُغلق القيد بلا مال، ويبقى أثره.
`status` ENUM('pending','partial','settled','waived','failed')
NOT NULL DEFAULT 'pending',
`attempts` SMALLINT NOT NULL DEFAULT 0,
`last_attempt_on` DATE DEFAULT NULL,
`settled_at` DATETIME DEFAULT NULL,
`note` 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`),
-- ‏الحارس الحقيقي ضد الاحتساب المزدوج: استحقاق واحد لكل التزام في
-- ‏اليوم الواحد، مهما تكرّر تشغيل الكرون أو تزامنت نسختان منه.
UNIQUE KEY `uq_obligation_date` (`obligation_id`, `charge_date`),
-- ‏فهرس مسح التسوية: تمرّ على المعلّق والجزئي مرتّباً بأولوية المنتج.
KEY `idx_settlement_sweep` (`status`, `driver_id`),
KEY `idx_driver_date` (`driver_id`, `charge_date`),
CONSTRAINT `fk_ledger_obligation` FOREIGN KEY (`obligation_id`)
REFERENCES `driver_obligations` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- ترحيل التأمين إلى النموذج العام
--
-- ‏يعمل هذا القسم أكثر من مرة بلا ضرر: كل إدراج مشروط بعدم وجود ما
-- ‏يقابله. سبب الحرص أن الترحيل يلمس التزامات مالية قائمة، وتشغيلاً
-- ‏ثانياً بلا حماية يعني ازدواج كل وثيقة وكل قسط.
-- ============================================================
-- ── خطط التأمين تصير منتجات ──
-- ‏`code` يُنسخ كما هو ليبقى المعرّف الثابت واحداً بين النموذجين.
-- ‏الأولوية ١٠: التأمين أوّل من يُحصَّل عند المزاحمة.
INSERT INTO `obligation_products`
(`code`, `kind`, `name_ar`, `description_ar`, `partner_name`, `billing_cycle`,
`currency`, `min_completed_rides`, `min_rating`, `min_account_days`,
`daily_cap_percent`, `priority`, `is_active`)
SELECT
pl.`code`, 'recurring', pl.`name_ar`, pl.`description_ar`,
pr.`name`, pl.`billing_cycle`, pl.`currency`,
pl.`min_completed_rides`, pl.`min_rating`, pl.`min_account_days`,
25.00, 10, pl.`is_active`
FROM `insurance_plans` pl
JOIN `insurance_providers` pr ON pr.`id` = pl.`provider_id`
WHERE NOT EXISTS (
SELECT 1 FROM `obligation_products` op WHERE op.`code` = pl.`code`
);
-- ── الوثائق تصير التزامات ──
-- ‏`cycle_amount` يُنسخ من قسط الخطة لحظة الترحيل — نفس مبدأ تثبيت السعر
-- ‏الذي يحكم الاشتراكات الجديدة.
INSERT INTO `driver_obligations`
(`driver_id`, `product_id`, `external_ref`, `status`, `started_at`, `ended_at`,
`cycle_amount`, `rides_at_signup`, `rating_at_signup`, `last_charged_on`,
`cancel_reason`, `created_at`)
SELECT
p.`driver_id`, op.`id`, p.`policy_number`,
-- ‏'expired' في التأمين تقابل 'completed' هنا: النموذج العام لا يعرف
-- ‏انتهاء صلاحية، يعرف التزاماً بلغ نهايته.
CASE p.`status` WHEN 'expired' THEN 'completed' ELSE p.`status` END,
p.`started_at`, p.`ended_at`,
pl.`premium`, p.`rides_at_signup`, p.`rating_at_signup`, p.`last_charged_on`,
p.`cancel_reason`, p.`created_at`
FROM `driver_insurance_policies` p
JOIN `insurance_plans` pl ON pl.`id` = p.`plan_id`
JOIN `obligation_products` op ON op.`code` = pl.`code`
WHERE NOT EXISTS (
SELECT 1 FROM `driver_obligations` o
WHERE o.`driver_id` = p.`driver_id`
AND o.`product_id` = op.`id`
AND o.`started_at` = p.`started_at`
);
-- ── الأقساط المعلّقة تصير قيوداً ──
-- ‏هذه هي الغاية العملية من الترحيل كله: هذه الصفوف — أقساط حقيقية
-- ‏تراكمت في الإنتاج بلا تحصيل — تصير مرئية لمحرك التسوية.
--
-- ‏`settled` القديمة تُنقل أيضاً رغم أنها لن تُحصَّل: دفتر ناقص التاريخ
-- ‏لا يُسوّى مع شريك.
INSERT INTO `obligation_ledger`
(`obligation_id`, `driver_id`, `product_code`, `charge_date`,
`amount`, `amount_collected`, `amount_remaining`, `currency`,
`status`, `settled_at`, `note`, `created_at`)
SELECT
o.`id`, l.`driver_id`, op.`code`, l.`charge_date`,
l.`amount`,
CASE WHEN l.`status` = 'settled' THEN l.`amount` ELSE 0 END,
CASE WHEN l.`status` = 'settled' THEN 0 ELSE l.`amount` END,
l.`currency`,
-- ‏'failed' القديمة تعود 'pending': الفشل السابق لم يكن قراراً بل
-- ‏غياب محرك. حجبها عن التسوية الآن يعني إسقاط مال مستحق فعلاً.
CASE l.`status` WHEN 'failed' THEN 'pending' ELSE l.`status` END,
l.`settled_at`, l.`note`, l.`created_at`
FROM `insurance_premium_ledger` l
JOIN `driver_insurance_policies` p ON p.`id` = l.`policy_id`
JOIN `insurance_plans` pl ON pl.`id` = p.`plan_id`
JOIN `obligation_products` op ON op.`code` = pl.`code`
JOIN `driver_obligations` o ON o.`driver_id` = p.`driver_id`
AND o.`product_id` = op.`id`
AND o.`started_at` = p.`started_at`
WHERE NOT EXISTS (
SELECT 1 FROM `obligation_ledger` ol
WHERE ol.`obligation_id` = o.`id` AND ol.`charge_date` = l.`charge_date`
);
@@ -0,0 +1,55 @@
-- ============================================================
-- بوابة الائتمان: لا التزام إلا لمن تمرّ أرباحه بالمحفظة
--
-- ‏المشكلة التي يعالجها هذا الملف (قرار المالك 2026-08-09):
--
-- ‏سقف الخصم اليومي في محرك الالتزامات نسبة من أرباح اليوم، وأرباح اليوم
-- ‏تُقاس من الصفوف الموجبة في `driverWallet`. سائق الكاش الذي يحصّل نقداً
-- ‏ولا تمرّ أرباحه بالمحفظة يظهر بأرباح صفر كل يوم — فسقفه صفر، فلا
-- ‏يُحصَّل منه شيء أبداً.
--
-- ‏هذا محتمَل في التأمين (قسط صغير متكرر، والخسارة محدودة ومرئية). وهو
-- ‏غير محتمَل إطلاقاً في الوقود والصيانة والتمويل: هناك تسلّم السائق
-- ‏قيمةً فعلية — وقوداً في خزّان سيارته أو إصلاحاً في ورشة — مقابل وعد
-- ‏بالسداد من قناة لا يمرّ بها ماله. هذا ليس ائتماناً بل هبة.
--
-- ‏الحل: شرط أهلية إضافي مخزَّن مع المنتج لا في الكود، على غرار شروط
-- ‏الرحلات والتقييم القائمة. العتبة تختلف بطبيعة المنتج: قسط تأمين يومي
-- ‏صغير لا يحتاج ما يحتاجه قرض صيانة.
-- ============================================================
ALTER TABLE `obligation_products`
-- ‏المفتاح العام. تركه قابلاً للإطفاء ليس تساهلاً: منتج مستقبلي قد
-- ‏يُدفع مقدَّماً أو يموّله طرف ثالث، فلا معنى لبوابة ائتمان عليه.
ADD COLUMN `requires_wallet_income` TINYINT(1) NOT NULL DEFAULT 1
COMMENT 'يشترط أن تمرّ أرباح السائق بالمحفظة' AFTER `min_account_days`,
-- ‏عدد الأيام المتمايزة التي دخل فيها مالٌ محفظةَ السائق خلال آخر
-- ‏ثلاثين يوماً. المقياس أيام لا مبلغ لأن السؤال هنا ليس «كم يكسب؟»
-- ‏بل «هل قناة التحصيل تعمل؟» — سائق دخله مرة واحدة كبيرة قد يكون
-- ‏استرداداً أو حادثة، وسائق دخله اثنا عشر يوماً متفرّقاً قناته حيّة.
ADD COLUMN `min_wallet_days_30d` SMALLINT NOT NULL DEFAULT 10
COMMENT 'أيام دخل في المحفظة خلال آخر 30 يوماً' AFTER `requires_wallet_income`,
-- ‏الحد الأدنى للدخل المار بالمحفظة في آخر ثلاثين يوماً. يُقاس بعملة
-- ‏المنتج. صفر يعني «لا شرط مبلغ» ويُكتفى بشرط الأيام.
ADD COLUMN `min_wallet_income_30d` DECIMAL(12,3) NOT NULL DEFAULT 0
COMMENT 'إجمالي الدخل المار بالمحفظة خلال آخر 30 يوماً' AFTER `min_wallet_days_30d`;
-- ── عتبة التأمين ────────────────────────────────────────────
-- ‏منتجات التأمين المرحَّلة تخضع للبوابة أيضاً — القسط الذي تتحمّله
-- ‏الشركة ثم تسترده من الأرباح ائتمانٌ بالمعنى نفسه.
--
-- ‏لكن العتبة متساهلة عمداً (ثمانية أيام، بلا شرط مبلغ): التأمين مكافأة
-- ‏استمرار مشروطة أصلاً بمئتي رحلة وتقييم جيد، وتشديد بوابة ثانية فوقها
-- ‏يفرغ المنتج من غرضه.
--
-- ‏هذا يمسّ الاشتراكات **الجديدة** وحدها. الالتزامات القائمة لا تُفحص
-- ‏ثانيةً: سحب تغطية من سائق مؤمَّن اليوم بسبب شرط سُنّ اليوم عقوبة
-- ‏بأثر رجعي.
UPDATE `obligation_products` op
JOIN `insurance_plans` pl ON pl.`code` = op.`code`
SET op.`requires_wallet_income` = 1,
op.`min_wallet_days_30d` = 8,
op.`min_wallet_income_30d` = 0;
@@ -0,0 +1,145 @@
-- ============================================================
-- محفظة الوقود — بند 3.4 من دراسة الفرص، الخطوة الثالثة
--
-- ‏الفكرة: اتفاق مع سلسلة محطات، السائق يتزوّد وقوداً بلا نقد، والقيمة
-- ‏تُقيَّد ديناً عليه يُخصم من أرباحه بالسقف اليومي نفسه. قيمة فورية
-- ‏ملموسة للسائق — الوقود مشكلته اليومية الأولى — وأقوى أثراً من أي
-- ‏زيادة عمولة، لأنها تحلّ نقص السيولة لا تزيد الرقم.
--
-- ‏هذا أول منتج من نوع `drawdown`: لا دورة فوترة له. الاستحقاق يُقيَّد
-- ‏لحظة السحب من المحطة لا على جدول زمني.
-- ============================================================
-- ── ١) تعدّد الاستحقاقات في اليوم الواحد ────────────────────
--
-- ‏المفتاح الفريد الحالي (obligation_id, charge_date) يمنع قيدين لالتزام
-- ‏واحد في يوم واحد — وهو بالضبط ما نريده للتأمين: قسط واحد في اليوم.
--
-- ‏لكنه يكسر الوقود: سائق يتزوّد مرتين في يوم واحد سيصطدم قيده الثاني
-- ‏بالمفتاح فيُرفض، فيحصل على وقود بلا دَين يقابله. اكتُشف هذا عند
-- ‏تصميم المُصدِر لا بعد تشغيله.
--
-- ‏الحل: `source_ref` يميّز السحبة. فارغاً للمنتجات الدورية — فيبقى
-- ‏ضمانها كما هو حرفياً — ومملوءاً برقم السحبة للوقود.
--
-- ‏سلسلة فارغة لا NULL: MySQL يعتبر كل NULL مختلفاً عن غيره في المفاتيح
-- ‏الفريدة، فعمود NULL كان سيلغي الحماية عن التأمين بصمت.
ALTER TABLE `obligation_ledger`
ADD COLUMN `source_ref` VARCHAR(80) NOT NULL DEFAULT ''
COMMENT 'مرجع الحدث المُنشئ — فارغ للدوري، رقم السحبة للوقود'
AFTER `charge_date`;
ALTER TABLE `obligation_ledger`
DROP INDEX `uq_obligation_date`,
ADD UNIQUE KEY `uq_obligation_event` (`obligation_id`, `charge_date`, `source_ref`);
-- ── ٢) سقف الائتمان ─────────────────────────────────────────
-- ‏`principal_amount` لا يصلح هنا: هو أصلٌ ينفد بالسداد (قرض صيانة)،
-- ‏بينما سقف الوقود متجدّد — يُسدَّد فيعود متاحاً، كبطاقة ائتمان.
ALTER TABLE `driver_obligations`
ADD COLUMN `credit_limit` DECIMAL(12,3) NOT NULL DEFAULT 0
COMMENT 'سقف متجدّد لمنتجات السحب (الوقود)' AFTER `principal_amount`;
-- ── ٣) المحطات الشريكة ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS `fuel_stations` (
`id` INT NOT NULL AUTO_INCREMENT,
`name_ar` VARCHAR(160) NOT NULL,
`chain_name` VARCHAR(120) DEFAULT NULL COMMENT 'اسم السلسلة إن كانت فرعاً',
`city` VARCHAR(80) DEFAULT NULL,
`address` VARCHAR(255) DEFAULT NULL,
`lat` DECIMAL(10,7) DEFAULT NULL,
`lng` DECIMAL(10,7) DEFAULT NULL,
`phone` VARCHAR(40) DEFAULT NULL,
-- ‏مفتاح المحطة مخزَّن مُهشَّراً لا نصّاً. المحطات أطراف خارجية وأجهزتها
-- ‏خارج سيطرتنا، وتسريب قاعدة البيانات يجب ألّا يسلّم مفاتيح تشغيلية
-- ‏لأحد. المفتاح الأصلي يُعطى للمحطة مرة واحدة عند الإنشاء.
`api_key_hash` CHAR(64) NOT NULL COMMENT 'sha256 لمفتاح المحطة',
-- ‏خصم الشريك عن سعر المضخة. يُسجَّل مع كل سحبة لأنه يتغيّر بالتفاوض،
-- ‏وحسابه لاحقاً بالنسبة الحالية يزوّر تسويات الماضي.
`discount_percent` DECIMAL(5,2) NOT NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_active_city` (`is_active`, `city`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── ٤) قسائم السحب ──────────────────────────────────────────
-- ‏دورة الحياة: issued → redeemed | expired | cancelled
--
-- ‏لماذا قسيمة مسبقة لا خصم مباشر عند المضخة؟ لأن عامل المحطة لا يملك
-- ‏وسيلة للتحقق من هوية السائق ولا من سقفه. القسيمة تنقل القرار إلى
-- ‏تطبيق السائق (حيث الهوية مؤكَّدة والسقف معلوم)، ولا يبقى على المحطة
-- ‏إلا تأكيد رقم.
CREATE TABLE IF NOT EXISTS `fuel_vouchers` (
`id` INT NOT NULL AUTO_INCREMENT,
`obligation_id` INT NOT NULL,
`driver_id` VARCHAR(100) NOT NULL,
`station_id` INT DEFAULT NULL COMMENT 'يُملأ عند الصرف',
`code` VARCHAR(12) NOT NULL COMMENT 'الرقم الذي يعطيه السائق للمحطة',
-- ‏المبلغ المحجوز عند الإصدار، والمصروف فعلاً عند التأكيد. الفرق
-- ‏طبيعي: السائق يطلب عشرين ويتزوّد بخمسة عشر. الدَّين على المصروف.
`amount_authorized` DECIMAL(12,3) NOT NULL,
`amount_redeemed` DECIMAL(12,3) NOT NULL DEFAULT 0,
`discount_percent` DECIMAL(5,2) NOT NULL DEFAULT 0,
`currency` VARCHAR(10) NOT NULL DEFAULT 'JOD',
`status` ENUM('issued','redeemed','expired','cancelled')
NOT NULL DEFAULT 'issued',
-- ‏صلاحية قصيرة. قسيمة مفتوحة إلى الأبد تحجز من سقف السائق مبلغاً
-- ‏لن يُستعمل، فيجد نفسه ممنوعاً من التزوّد بسبب قسيمة نسيها.
`expires_at` DATETIME NOT NULL,
`redeemed_at` DATETIME DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
-- ‏الرمز فريد مطلقاً: عامل المحطة يُدخل رقماً بلا سياق، فتكراره ولو
-- ‏بين سائقين مختلفين يعني صرفاً على حساب الشخص الخطأ.
UNIQUE KEY `uq_code` (`code`),
KEY `idx_driver_status` (`driver_id`, `status`),
KEY `idx_open_vouchers` (`status`, `expires_at`),
CONSTRAINT `fk_voucher_obligation` FOREIGN KEY (`obligation_id`)
REFERENCES `driver_obligations` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_voucher_station` FOREIGN KEY (`station_id`)
REFERENCES `fuel_stations` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── ٥) منتج الوقود ──────────────────────────────────────────
-- ‏سقف الخصم اليومي ١٥٪ لا ٢٥٪ كالتأمين: دَين الوقود أكبر من قسط يومي،
-- ‏واقتطاع ربع الدخل لسداده يترك السائق بلا ما يكفي ليعمل غداً — فيعود
-- ‏محتاجاً وقوداً بالدَّين، وتلك حلقة لا تُغلق.
--
-- ‏بوابة الائتمان أشدّ من التأمين (١٢ يوماً مقابل ٨) لأن هنا قيمةً فعلية
-- ‏تُسلَّم في خزّان السيارة، لا تغطيةً احتمالية.
--
-- ‏**يُنشأ مُطفأً (`is_active = 0`) عمداً.** قرار المالك: تُبنى الشاشات
-- ‏وتُجهَّز الأمور، ولا يُشغَّل المنتج إلا بعد إتمام الاتفاق مع سلسلة
-- ‏المحطات. المنتج المُطفأ يمنع السائقين من التفعيل والصرف، بينما تعمل
-- ‏الشاشة الإدارية بالكامل للتجهيز والعرض.
--
-- ‏التشغيل بصفّ واحد من الشاشة الإدارية (تبديل حالة المنتج) — لا بنشر
-- ‏إصدار جديد.
INSERT INTO `obligation_products`
(`code`, `kind`, `name_ar`, `description_ar`, `partner_name`, `billing_cycle`,
`currency`, `min_completed_rides`, `min_rating`, `min_account_days`,
`requires_wallet_income`, `min_wallet_days_30d`, `min_wallet_income_30d`,
`daily_cap_percent`, `priority`, `is_active`)
SELECT
'fuel_wallet', 'drawdown', 'محفظة الوقود',
'تزوّد بالوقود من المحطات الشريكة بلا نقد، والقيمة تُخصم من أرباحك تدريجياً بسقف يومي.',
NULL, 'none', 'JOD',
200, 4.50, 30,
1, 12, 0,
15.00, 20, 0
WHERE NOT EXISTS (
SELECT 1 FROM `obligation_products` WHERE `code` = 'fuel_wallet'
);
+75
View File
@@ -0,0 +1,75 @@
-- _helpers.sql — إجراءات ترحيل قابلة لإعادة التشغيل
--
-- ‏المشكلة: ALTER TABLE ... ADD COLUMN في MySQL يُجهض **كل** الجملة عند
-- ‏أول عمود مكرّر. فإعادة تشغيل ترحيل نُفِّذ جزئياً تفشل، وإعادة تشغيل
-- ‏ترحيل نُفِّذ كاملاً تفشل أيضاً — بلا أن تعرف أيّهما.
--
-- ‏هذا لدغ نشرَ سيرو ثلاث مرات في يوم واحد، ويزداد سوءاً مع تعدد النسخ
-- ‏(intaleq / tripz / siro) حيث لا أحد يتذكّر ما نُفِّذ أين.
--
-- ‏الحل: إجراءات تفحص information_schema أولاً فتتخطّى الموجود بصمت.
--
-- ‏شغّل هذا الملف مرة واحدة على كل قاعدة قبل أي ترحيل يستخدمه.
DELIMITER //
DROP PROCEDURE IF EXISTS `siro_add_column` //
/**
* ‏يضيف عموداً إن لم يكن موجوداً. آمن لإعادة التشغيل.
*
* ‏مثال:
* CALL siro_add_column('ride', 'driverArrivedAt',
* 'DATETIME NULL DEFAULT NULL COMMENT ''لحظة الوصول''');
*/
CREATE PROCEDURE `siro_add_column`(
IN p_table VARCHAR(64),
IN p_column VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = p_table
AND COLUMN_NAME = p_column
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `',
p_column, '` ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT CONCAT('أُضيف: ', p_table, '.', p_column) AS result;
ELSE
SELECT CONCAT('موجود مسبقاً: ', p_table, '.', p_column) AS result;
END IF;
END //
DROP PROCEDURE IF EXISTS `siro_add_index` //
/**
* ‏يضيف فهرساً إن لم يكن موجوداً.
* CALL siro_add_index('driver_orders', 'idx_order', '(`order_id`)');
*/
CREATE PROCEDURE `siro_add_index`(
IN p_table VARCHAR(64),
IN p_index VARCHAR(64),
IN p_columns TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = p_table
AND INDEX_NAME = p_index
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD INDEX `',
p_index, '` ', p_columns);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT CONCAT('أُضيف فهرس: ', p_table, '.', p_index) AS result;
ELSE
SELECT CONCAT('فهرس موجود مسبقاً: ', p_table, '.', p_index) AS result;
END IF;
END //
DELIMITER ;