Update: 2026-08-08 12:58:16
This commit is contained in:
@@ -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'
|
||||
);
|
||||
Reference in New Issue
Block a user