Update: 2026-08-08 12:58:16

This commit is contained in:
Hamza-Ayed
2026-08-08 12:58:17 +03:00
parent 611b5e616d
commit fae0e4a38a
55 changed files with 5771 additions and 1820 deletions
@@ -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'
);