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
Vendored
BIN
View File
Binary file not shown.
-2
View File
@@ -102,6 +102,4 @@ GoogleService-Info.plist
semgrep_*.json semgrep_*.json
nuclei_results.txt nuclei_results.txt
# سجل لوحة المفاتيح — محلي للسيرفر، لا يُدفع
backend/tools/panel/panel_audit.log
*.env.bak.* *.env.bak.*
+89
View File
@@ -0,0 +1,89 @@
<?php
// Admin/fuel/drawdowns.php — حركة القسائم وملخّص الائتمان
// POST (اختياري): station_id, status, driver_id, limit
//
// ‏الشاشة التي تُجيب: كم أقرضنا؟ كم استُرد؟ وأي محطة تعمل؟
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
$status = filterRequest('status');
$driverId = filterRequest('driver_id');
$limit = (int) (filterRequest('limit', 'int') ?: 100);
$limit = max(1, min($limit, 500));
$where = [];
$params = [];
if ($stationId > 0) {
$where[] = 'v.station_id = ?';
$params[] = $stationId;
}
if (!empty($status)) {
$where[] = 'v.status = ?';
$params[] = $status;
}
if (!empty($driverId)) {
$where[] = 'v.driver_id = ?';
$params[] = $driverId;
}
$clause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
try {
$st = $con->prepare("
SELECT v.id, v.driver_id, v.code, v.amount_authorized, v.amount_redeemed,
v.currency, v.status, v.expires_at, v.redeemed_at, v.created_at,
s.name_ar AS station_name
FROM fuel_vouchers v
LEFT JOIN fuel_stations s ON s.id = v.station_id
$clause
ORDER BY v.id DESC
LIMIT $limit
");
$st->execute($params);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
// ‏الملخّص على كامل المنتج لا على الصفحة المعروضة: رقمٌ يتغيّر بتغيّر
// ‏الفلتر يُقرأ كأنه حقيقة عن المنصة وهو ليس كذلك.
$summary = $con->query("
SELECT
COALESCE(SUM(amount), 0) AS lent_total,
COALESCE(SUM(amount_collected), 0) AS collected_total,
COALESCE(SUM(amount_remaining), 0) AS outstanding_total,
COUNT(DISTINCT driver_id) AS drivers_count
FROM obligation_ledger
WHERE product_code = 'fuel_wallet'
")->fetch(PDO::FETCH_ASSOC) ?: [];
} catch (PDOException $e) {
error_log('[admin/fuel/drawdowns] ' . $e->getMessage());
jsonError('Server error', 500);
}
$vouchers = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'driver_id' => $r['driver_id'],
'code' => $r['code'],
'amount_authorized' => (float) $r['amount_authorized'],
'amount_redeemed' => (float) $r['amount_redeemed'],
'currency' => $r['currency'],
'status' => $r['status'],
'station' => $r['station_name'],
'expires_at' => $r['expires_at'],
'redeemed_at' => $r['redeemed_at'],
'created_at' => $r['created_at'],
], $rows);
jsonSuccess([
'vouchers' => $vouchers,
'summary' => [
'lent_total' => (float) ($summary['lent_total'] ?? 0),
'collected_total' => (float) ($summary['collected_total'] ?? 0),
'outstanding_total' => (float) ($summary['outstanding_total'] ?? 0),
'drivers_count' => (int) ($summary['drivers_count'] ?? 0),
],
], 'success');
+90
View File
@@ -0,0 +1,90 @@
<?php
// Admin/fuel/list.php — قائمة محطات الوقود الشريكة
// POST (اختياري): search, city, only_active
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$search = filterRequest('search');
$city = filterRequest('city');
$onlyActive = filterRequest('only_active') === '1';
$where = [];
$params = [];
if (!empty($search)) {
$where[] = '(s.name_ar LIKE ? OR s.chain_name LIKE ? OR s.address LIKE ?)';
$like = '%' . $search . '%';
array_push($params, $like, $like, $like);
}
if (!empty($city)) {
$where[] = 's.city = ?';
$params[] = $city;
}
if ($onlyActive) {
$where[] = 's.is_active = 1';
}
$clause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
try {
// ‏إحصاء الصرف مع كل محطة: لوحة بلا أرقام لا تخبر أحداً أي محطة
// ‏تعمل فعلاً وأيها اسمٌ في جدول. `redeemed` وحدها — القسائم الملغاة
// ‏والمنتهية لا تقول شيئاً عن أداء المحطة.
$st = $con->prepare("
SELECT s.*,
COUNT(v.id) AS redeem_count,
COALESCE(SUM(v.amount_redeemed), 0) AS redeem_total,
MAX(v.redeemed_at) AS last_redeem_at
FROM fuel_stations s
LEFT JOIN fuel_vouchers v
ON v.station_id = s.id AND v.status = 'redeemed'
$clause
GROUP BY s.id
ORDER BY s.is_active DESC, s.city ASC, s.name_ar ASC
LIMIT 200
");
$st->execute($params);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[admin/fuel/list] ' . $e->getMessage());
jsonError('Server error', 500);
}
$stations = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'name_ar' => $r['name_ar'],
'chain_name' => $r['chain_name'],
'city' => $r['city'],
'address' => $r['address'],
'lat' => $r['lat'] !== null ? (float) $r['lat'] : null,
'lng' => $r['lng'] !== null ? (float) $r['lng'] : null,
'phone' => $r['phone'],
'discount_percent' => (float) $r['discount_percent'],
'is_active' => (int) $r['is_active'] === 1,
'created_at' => $r['created_at'],
'redeem_count' => (int) $r['redeem_count'],
'redeem_total' => (float) $r['redeem_total'],
'last_redeem_at' => $r['last_redeem_at'],
// ‏المفتاح لا يخرج أبداً — لا هنا ولا في أي نقطة أخرى. مخزَّن مُهشَّراً
// ‏أصلاً، ويُعرض مرة واحدة فقط لحظة إنشائه أو تدويره.
], $rows);
// ‏حالة المنتج تُرسل مع القائمة لا في نقطة منفصلة: الشاشة تحتاج أن تعرض
// ‏«مُجهَّز وغير مُشغَّل» بوضوح، وطلبان لبناء لافتة واحدة إسراف.
$productActive = false;
try {
$st = $con->prepare("SELECT is_active FROM obligation_products WHERE code = 'fuel_wallet' LIMIT 1");
$st->execute();
$productActive = (int) $st->fetchColumn() === 1;
} catch (PDOException $e) {
error_log('[admin/fuel/list] تعذّرت قراءة حالة المنتج: ' . $e->getMessage());
}
jsonSuccess([
'stations' => $stations,
'product_active' => $productActive,
], 'success');
+44
View File
@@ -0,0 +1,44 @@
<?php
// Admin/fuel/rotate_key.php — توليد مفتاح جديد لمحطة
// POST: station_id
//
// ‏يُستعمل عند فقدان المحطة مفتاحها أو الشك في تسريبه. المفتاح القديم
// ‏يتوقّف فوراً — وهذا مقصود: التدوير إجراء أمني، وإبقاء القديم يعمل
// ‏«حتى تنتقل المحطة» يُفرغه من معناه.
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
if ($stationId <= 0) {
jsonError('station_id مطلوب');
}
try {
$st = $con->prepare("SELECT name_ar FROM fuel_stations WHERE id = ? LIMIT 1");
$st->execute([$stationId]);
$name = $st->fetchColumn();
if ($name === false) {
jsonError('المحطة غير موجودة', 404);
}
$plainKey = bin2hex(random_bytes(24));
$con->prepare("UPDATE fuel_stations SET api_key_hash = ? WHERE id = ?")
->execute([hash('sha256', $plainKey), $stationId]);
error_log("[admin/fuel] دُوِّر مفتاح المحطة #$stationId بواسطة $user_id");
jsonSuccess([
'station_id' => $stationId,
'station' => $name,
'api_key' => $plainKey,
], 'المفتاح القديم توقّف. انسخ الجديد الآن — لن يظهر مرة أخرى.');
} catch (PDOException $e) {
error_log('[admin/fuel/rotate_key] ' . $e->getMessage());
jsonError('Server error', 500);
}
+88
View File
@@ -0,0 +1,88 @@
<?php
// Admin/fuel/save.php — إضافة محطة أو تعديلها
// POST: station_id (اختياري للتعديل), name_ar, chain_name, city, address,
// lat, lng, phone, discount_percent, is_active
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
$nameAr = filterRequest('name_ar');
if (empty($nameAr)) {
jsonError('اسم المحطة مطلوب');
}
$discount = (float) (filterRequest('discount_percent', 'numeric') ?: 0);
if ($discount < 0 || $discount > 100) {
jsonError('نسبة الخصم يجب أن تكون بين 0 و 100');
}
$lat = filterRequest('lat', 'numeric');
$lng = filterRequest('lng', 'numeric');
$fields = [
'name_ar' => $nameAr,
'chain_name' => filterRequest('chain_name'),
'city' => filterRequest('city'),
'address' => filterRequest('address'),
'lat' => $lat !== null ? (float) $lat : null,
'lng' => $lng !== null ? (float) $lng : null,
'phone' => filterRequest('phone'),
'discount_percent' => $discount,
'is_active' => filterRequest('is_active') === '0' ? 0 : 1,
];
try {
if ($stationId > 0) {
$sets = implode(', ', array_map(static fn($k) => "`$k` = ?", array_keys($fields)));
$st = $con->prepare("UPDATE fuel_stations SET $sets WHERE id = ?");
$st->execute([...array_values($fields), $stationId]);
if ($st->rowCount() === 0) {
// ‏rowCount صفر قد يعني «غير موجودة» وقد يعني «حُفظت بلا تغيير».
// ‏نفرّق بينهما بقراءة صريحة: إخبار المستخدم أن المحطة غير
// ‏موجودة بينما هي موجودة يدفعه لإنشاء نسخة ثانية منها.
$chk = $con->prepare("SELECT 1 FROM fuel_stations WHERE id = ? LIMIT 1");
$chk->execute([$stationId]);
if (!$chk->fetchColumn()) {
jsonError('المحطة غير موجودة', 404);
}
}
error_log("[admin/fuel] عُدّلت المحطة #$stationId بواسطة $user_id");
jsonSuccess(['station_id' => $stationId, 'created' => false], 'تم حفظ التعديلات');
}
// ── إنشاء جديد ──
// ‏المفتاح يُولَّد هنا ولا يُطلب من المستخدم: مفتاح يختاره إنسان يكون
// ‏قصيراً ومتوقَّعاً، وهذا المفتاح يأذن بصرف مال.
$plainKey = bin2hex(random_bytes(24));
$cols = array_keys($fields);
$st = $con->prepare(
'INSERT INTO fuel_stations (`' . implode('`, `', $cols) . '`, `api_key_hash`) '
. 'VALUES (' . implode(', ', array_fill(0, count($cols), '?')) . ', ?)'
);
$st->execute([...array_values($fields), hash('sha256', $plainKey)]);
$stationId = (int) $con->lastInsertId();
error_log("[admin/fuel] أُنشئت المحطة #$stationId بواسطة $user_id");
jsonSuccess([
'station_id' => $stationId,
'created' => true,
// ‏المرة **الوحيدة** التي يظهر فيها المفتاح. مخزَّن مُهشَّراً، فلا
// ‏سبيل لاستعادته لاحقاً — من يفقده يدوّر مفتاحاً جديداً.
'api_key' => $plainKey,
], 'أُنشئت المحطة. انسخ المفتاح الآن — لن يظهر مرة أخرى.');
} catch (PDOException $e) {
error_log('[admin/fuel/save] ' . $e->getMessage());
jsonError('Server error', 500);
}
+53
View File
@@ -0,0 +1,53 @@
<?php
// Admin/fuel/toggle_product.php — تشغيل محفظة الوقود أو إيقافها
// POST: is_active (1 | 0)
//
// ‏قرار المالك: تُبنى الشاشات وتُجهَّز الأمور، ولا يُشغَّل المنتج إلا بعد
// ‏إتمام الاتفاق مع سلسلة المحطات. هذه هي نقطة ذلك التبديل.
//
// ‏ما الذي يتغيّر فعلاً؟ المنتج المُطفأ لا يظهر في `obligationProduct`،
// ‏فلا يستطيع سائق تفعيل محفظته ولا إصدار قسيمة. أما الالتزامات القائمة
// ‏ودفترها فلا تتأثر — الإطفاء يغلق الباب ولا يمحو ما خلفه.
require_once __DIR__ . '/../../connect.php';
// ‏super_admin وحده. تشغيل المنتج يفتح باب إقراض حقيقي بمال الشركة،
// ‏وهو قرار مالك لا قرار مناوب على اللوحة.
if ($role !== 'super_admin') {
jsonError('Unauthorized: Super admin access required', 403);
}
$isActive = filterRequest('is_active') === '1' ? 1 : 0;
try {
// ‏التشغيل بلا محطة واحدة فعّالة يعني سائقاً يفعّل محفظته، يطلب
// ‏قسيمة، ثم لا يجد أين يصرفها. الرفض هنا أرحم من اكتشافه عند مضخة.
if ($isActive === 1) {
$st = $con->query("SELECT COUNT(*) FROM fuel_stations WHERE is_active = 1");
if ((int) $st->fetchColumn() === 0) {
jsonError('لا توجد محطة فعّالة واحدة. أضف محطة قبل تشغيل المنتج.');
}
}
$st = $con->prepare("UPDATE obligation_products SET is_active = ? WHERE code = 'fuel_wallet'");
$st->execute([$isActive]);
if ($st->rowCount() === 0) {
$chk = $con->query("SELECT 1 FROM obligation_products WHERE code = 'fuel_wallet' LIMIT 1");
if (!$chk->fetchColumn()) {
jsonError('منتج محفظة الوقود غير موجود — نفّذ الترحيل أولاً', 404);
}
}
error_log('[admin/fuel] ' . ($isActive ? 'شُغّلت' : 'أُوقفت') . " محفظة الوقود بواسطة $user_id");
jsonSuccess(
['product_active' => $isActive === 1],
$isActive === 1
? 'شُغّلت محفظة الوقود — صارت متاحة للسائقين المؤهَّلين'
: 'أُوقفت محفظة الوقود — لا تفعيل جديد ولا قسائم جديدة'
);
} catch (PDOException $e) {
error_log('[admin/fuel/toggle_product] ' . $e->getMessage());
jsonError('Server error', 500);
}
+32 -18
View File
@@ -7,11 +7,19 @@
* ‏رصيد السائق يعيش في payment_server لا في هذه القاعدة، والخصم * ‏رصيد السائق يعيش في payment_server لا في هذه القاعدة، والخصم
* ‏المباشر من هنا كان سيعني كتابةً عابرة للحدود بلا معاملة موحّدة — * ‏المباشر من هنا كان سيعني كتابةً عابرة للحدود بلا معاملة موحّدة —
* ‏أي خطر رصيد ناقص بلا قيد يقابله، أو قيد بلا خصم. فنقيّد استحقاقاً * ‏أي خطر رصيد ناقص بلا قيد يقابله، أو قيد بلا خصم. فنقيّد استحقاقاً
* ‏هنا، وتقرأه التسوية. * ‏هنا، ويقرأه محرك التسوية `cron_obligation_settlement.php`.
*
* ‏تحديث (٢٠٢٦-٠٨-٠٨): صار هذا الملف **مُصدِر استحقاق** في محرك
* ‏الالتزامات العام، فيكتب في `obligation_ledger` لا في
* ‏`insurance_premium_ledger`. سبب النقل أن الدفتر القديم لم يكن يقرأه
* ‏أحد إطلاقاً — «تقرأه التسوية» كان وعداً بلا منفّذ، وكل قسط قُيّد منذ
* ‏الإطلاق بقي معلّقاً بلا تحصيل. جداول التأمين تبقى كما هي شاهداً
* ‏تاريخياً، وقد رُحّلت بياناتها في
* ‏`migrations/2026_08_08_obligations_engine.sql`.
* *
* ‏الحماية من الاحتساب المزدوج طبقتان: * ‏الحماية من الاحتساب المزدوج طبقتان:
* ١. `last_charged_on` في الوثيقة — نفلتر به قبل العمل * ١. `last_charged_on` في الالتزام — نفلتر به قبل العمل
* ٢. مفتاح فريد (policy_id, charge_date) في الدفتر — يحسم السباق * ٢. مفتاح فريد (obligation_id, charge_date) في الدفتر — يحسم السباق
* لو عملت نسختان من الكرون معاً * لو عملت نسختان من الكرون معاً
* *
* ‏الأولى وحدها لا تكفي: بين القراءة والكتابة نافذة، والمفتاح الفريد * ‏الأولى وحدها لا تكفي: بين القراءة والكتابة نافذة، والمفتاح الفريد
@@ -39,19 +47,24 @@ $charged = 0;
$skipped = 0; $skipped = 0;
try { try {
// ‏المصدر الآن الالتزامات لا الوثائق. القيد على `kind = 'recurring'`
// ‏لا على المنتج التأميني تحديداً: أي منتج دوري يُضاف لاحقاً يُقيَّد
// ‏بالمنطق نفسه بلا سطر جديد هنا.
$st = $con->query(" $st = $con->query("
SELECT p.id, p.driver_id, p.last_charged_on, SELECT o.id, o.driver_id, o.last_charged_on, o.cycle_amount,
pl.premium, pl.currency, pl.billing_cycle pr.code AS product_code, pr.currency, pr.billing_cycle
FROM driver_insurance_policies p FROM driver_obligations o
JOIN insurance_plans pl ON pl.id = p.plan_id JOIN obligation_products pr ON pr.id = o.product_id
WHERE p.status = 'active' WHERE o.status = 'active'
ORDER BY p.id ASC AND pr.kind = 'recurring'
AND pr.billing_cycle <> 'none'
ORDER BY o.id ASC
"); ");
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $policy) { foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $policy) {
$chargeDate = $policy['billing_cycle'] === 'monthly' ? $monthStart : $today; $chargeDate = $policy['billing_cycle'] === 'monthly' ? $monthStart : $today;
// ‏الفلترة الرخيصة أولاً — تجنّبنا محاولة إدراج لكل وثيقة كل يوم. // ‏الفلترة الرخيصة أولاً — تجنّبنا محاولة إدراج لكل التزام كل يوم.
if ($policy['last_charged_on'] === $chargeDate) { if ($policy['last_charged_on'] === $chargeDate) {
$skipped++; $skipped++;
continue; continue;
@@ -61,16 +74,17 @@ try {
$con->beginTransaction(); $con->beginTransaction();
$con->prepare(" $con->prepare("
INSERT INTO insurance_premium_ledger INSERT INTO obligation_ledger
(policy_id, driver_id, charge_date, amount, currency, status) (obligation_id, driver_id, product_code, charge_date,
VALUES (?, ?, ?, ?, ?, 'pending') amount, amount_collected, amount_remaining, currency, status)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, 'pending')
")->execute([ ")->execute([
$policy['id'], $policy['driver_id'], $chargeDate, $policy['id'], $policy['driver_id'], $policy['product_code'], $chargeDate,
$policy['premium'], $policy['currency'], $policy['cycle_amount'], $policy['cycle_amount'], $policy['currency'],
]); ]);
$con->prepare(" $con->prepare("
UPDATE driver_insurance_policies SET last_charged_on = ? WHERE id = ? UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
")->execute([$chargeDate, $policy['id']]); ")->execute([$chargeDate, $policy['id']]);
$con->commit(); $con->commit();
@@ -82,11 +96,11 @@ try {
// ‏هذه ليست حالة خطأ — هي بالضبط ما صُمّم المفتاح لمنعه. // ‏هذه ليست حالة خطأ — هي بالضبط ما صُمّم المفتاح لمنعه.
if ($e->getCode() === '23000') { if ($e->getCode() === '23000') {
$con->prepare(" $con->prepare("
UPDATE driver_insurance_policies SET last_charged_on = ? WHERE id = ? UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
")->execute([$chargeDate, $policy['id']]); ")->execute([$chargeDate, $policy['id']]);
$skipped++; $skipped++;
} else { } else {
error_log("[insurance] تعذّر قيد قسط الوثيقة #{$policy['id']}: " . $e->getMessage()); error_log("[insurance] تعذّر قيد استحقاق الالتزام #{$policy['id']}: " . $e->getMessage());
} }
} }
} }
+199
View File
@@ -0,0 +1,199 @@
<?php
/**
* cron_obligation_settlement.php — محرك تسوية الالتزامات
* ─────────────────────────────────────────────────────────────
* ‏الجهة **الوحيدة** التي تحوّل قيداً في `obligation_ledger` إلى خصم فعلي
* ‏من محفظة السائق. مُصدِرو الاستحقاق (التأمين، الوقود، الصيانة) يقيّدون
* ‏فقط ولا يلمسون مالاً — وهذا ما يجعل إضافة منتج جديد لا تمسّ أخطر
* ‏منطق في المنصة.
*
* ‏هذا الملف يغلق ثغرة إنتاج قائمة: `cron_insurance_premiums.php` يقيّد
* ‏الأقساط منذ إطلاقه وتعليقه يقول «تقرأه التسوية» — ولم تكن التسوية
* ‏موجودة. كل قسط قُيّد حتى اليوم بقي `pending` بلا تحصيل.
*
* ‏لماذا كرون منفصل لا خصم عند إنهاء الرحلة؟
* ‏لأن `finish_ride_updates.php` مسار حرج والسائق ينتظر رده. نداء شبكي
* ‏إلى سيرفر المحفظة داخله يعني تعليق شاشة السائق على بطء طرف ثالث،
* ‏وفشلاً في التسوية يظهر للسائق كفشل في إنهاء رحلته.
*
* ‏ضمانات عدم التكرار طبقتان:
* ١. `last_attempt_on` في القيد — محاولة واحدة لكل قيد في اليوم
* ٢. `settlement_ref` فريد في سيرفر المحفظة — يحسم السباق ويجعل
* ‏إعادة النداء بعد انقطاع شبكي آمنة بالتعريف
*
* ‏الأولى وحدها لا تكفي: نداء قد ينجح في المحفظة ثم ينقطع قبل أن يصل
* ‏الرد، فيبقى القيد بلا تحديث. الطبقة الثانية تجعل إعادة المحاولة تُرجع
* ‏نتيجة الخصم الأول بدل خصم ثانٍ.
*
* ‏جدولة: مرة يومياً في آخر اليوم لا بعد قيد الاستحقاقات مباشرة —
* ‏السقف نسبة من أرباح اليوم، وقراءتها بعد منتصف الليل تعني سقفاً صفرياً
* ‏كل ليلة وتحصيلاً لا يقع أبداً.
* 30 23 * * * docker compose exec -T php php /var/www/backend/bot/cron_obligation_settlement.php
*/
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../obligations/functions.php';
try {
$con = Database::get('main');
} catch (Exception $e) {
fwrite(STDERR, '[obligation] DB unavailable: ' . $e->getMessage() . "\n");
exit(1);
}
$sharedKey = getenv('S2S_SHARED_KEY') ?: '';
if ($sharedKey === '') {
// ‏بلا مفتاح لن ينجح أي نداء. الخروج المبكر أوضح من مئة محاولة
// ‏تعود 401 وتزيد `attempts` بلا سبب حقيقي.
fwrite(STDERR, "[obligation] S2S_SHARED_KEY غير معرّف — لا تسوية\n");
exit(1);
}
/**
* ‏ينادي نقطة الخصم ويُرجع ما خُصم فعلاً، أو null عند فشل لا يمكن تفسيره.
*
* ‏التمييز بين «خُصم صفر» و«فشل» جوهري: الأول حالة طبيعية (لا أرباح
* ‏اليوم) تُسجَّل محاولةً، والثاني لا يجوز أن يُحسب محاولة — وإلا ضاع
* ‏يوم تحصيل بسبب عطل شبكي عابر.
*/
function obligationDeduct(string $ref, string $driverId, string $productCode,
float $amount, float $capPercent): ?float
{
$payload = obligationWalletCall(
'/v2/main/ride/driverWallet/deduct_s2s_obligation.php',
[
'settlement_ref' => $ref,
'driverID' => $driverId,
'product_code' => $productCode,
'amount' => number_format($amount, 3, '.', ''),
'cap_percent' => number_format($capPercent, 2, '.', ''),
]
);
if ($payload === null || !isset($payload['deducted'])) {
error_log("[obligation] تعذّر الحصول على نتيجة خصم ($ref)");
return null;
}
return round((float) $payload['deducted'], 3);
}
$today = date('Y-m-d');
$settled = 0;
$partial = 0;
$idle = 0;
$failed = 0;
$total = 0.0;
try {
// ‏الترتيب مقصود: السائق أولاً ليكون سقفه اليومي مُستهلَكاً بترتيب
// ‏معلوم، ثم أولوية المنتج (التأمين قبل الوقود قبل الصيانة)، ثم
// ‏الأقدم فالأحدث — الدَّين الأقدم أولى بالسداد.
$st = $con->query("
SELECT l.id, l.obligation_id, l.driver_id, l.product_code,
l.amount_remaining, l.amount_collected, l.amount, l.attempts,
p.daily_cap_percent, p.kind
FROM obligation_ledger l
JOIN driver_obligations o ON o.id = l.obligation_id
JOIN obligation_products p ON p.id = o.product_id
WHERE l.status IN ('pending', 'partial')
AND l.amount_remaining > 0
AND (l.last_attempt_on IS NULL OR l.last_attempt_on <> CURDATE())
ORDER BY l.driver_id ASC, p.priority ASC, l.charge_date ASC
");
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
$ledgerId = (int) $row['id'];
$remaining = round((float) $row['amount_remaining'], 3);
// ‏المرجع مبنيّ على عدّاد المحاولات لا على تاريخ اليوم. الفرق ليس
// ‏تجميلياً: `attempts` لا يزيد إلا بعد تحديث الدفتر بنجاح، فنداء
// ‏نجح في المحفظة وضاع ردّه يُعاد لاحقاً — ولو بعد أيام — بالمرجع
// ‏نفسه، فتُرجع المحفظة خصمه الأول بدل تنفيذ خصم ثانٍ. مرجعٌ
// ‏مبنيّ على التاريخ كان سيخصم المبلغ مرتين في هذه الحالة بالضبط.
$ref = "obl_{$ledgerId}_{$row['attempts']}";
$deducted = obligationDeduct(
$ref, $row['driver_id'], $row['product_code'],
$remaining, (float) $row['daily_cap_percent']
);
if ($deducted === null) {
// ‏لا نلمس القيد إطلاقاً — ولا حتى `attempts`. قد يكون الخصم
// ‏تمّ في المحفظة وضاع الرد، وإبقاء العدّاد كما هو يضمن أن
// ‏المحاولة القادمة تحمل المرجع نفسه فتستردّ نتيجة الأولى.
$failed++;
continue;
}
$collected = round((float) $row['amount_collected'] + $deducted, 3);
$newRemaining = round((float) $row['amount'] - $collected, 3);
if ($newRemaining < 0) {
$newRemaining = 0.0;
}
if ($newRemaining <= 0) {
$status = 'settled';
} elseif ($collected > 0) {
$status = 'partial';
} else {
$status = 'pending';
}
try {
$con->beginTransaction();
$con->prepare("
UPDATE obligation_ledger
SET amount_collected = ?, amount_remaining = ?, status = ?,
attempts = attempts + 1, last_attempt_on = ?,
settled_at = CASE WHEN ? = 'settled' THEN NOW() ELSE settled_at END
WHERE id = ?
")->execute([$collected, $newRemaining, $status, $today, $status, $ledgerId]);
// ‏المقسّط وحده يراكم أصلاً مسدَّداً. الدوري بلا نهاية فلا
// ‏معنى لأصلٍ ينفد عنده.
if ($deducted > 0 && $row['kind'] === 'installment') {
$con->prepare("
UPDATE driver_obligations
SET principal_paid = principal_paid + ?
WHERE id = ?
")->execute([$deducted, (int) $row['obligation_id']]);
// ‏سداد الأصل كاملاً يُنهي الالتزام. بدون هذا يبقى قرض
// ‏مسدَّد «نشطاً» فيظهر للسائق ويزاحم غيره على السقف.
$con->prepare("
UPDATE driver_obligations
SET status = 'completed', ended_at = CURDATE()
WHERE id = ? AND status = 'active'
AND principal_amount > 0
AND principal_paid >= principal_amount
")->execute([(int) $row['obligation_id']]);
}
$con->commit();
} catch (PDOException $e) {
if ($con->inTransaction()) $con->rollBack();
// ‏الخصم تمّ فعلاً والقيد لم يُحدَّث — أخطر حالة هنا. لا إعادة
// ‏محاولة تلقائية: المرجع نفسه سيُرجع الخصم الأول لا خصماً
// ‏جديداً، لكن التباين بين الدفترين يحتاج عيناً بشرية.
error_log("[obligation] خُصم {$deducted} للقيد #{$ledgerId} ولم يُحدَّث الدفتر: " . $e->getMessage());
$failed++;
continue;
}
$total += $deducted;
if ($status === 'settled') $settled++;
elseif ($deducted > 0) $partial++;
else $idle++;
}
} catch (PDOException $e) {
fwrite(STDERR, '[obligation] فشل المرور: ' . $e->getMessage() . "\n");
exit(1);
}
printf(
"[obligation] سُوِّي %d، جزئي %d، بلا خصم %d، فشل %d — إجمالي %.3f\n",
$settled, $partial, $idle, $failed, $total
);
+16 -12
View File
@@ -9,6 +9,7 @@
require_once __DIR__ . '/../connect.php'; require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/eligibility.php'; require_once __DIR__ . '/eligibility.php';
require_once __DIR__ . '/../obligations/functions.php';
$driverId = $user_id ?? ''; $driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') { if (empty($driverId) || ($role ?? '') !== 'driver') {
@@ -22,7 +23,12 @@ if (!$policy) {
$reason = filterRequest('reason') ?: 'بطلب السائق'; $reason = filterRequest('reason') ?: 'بطلب السائق';
// ‏الوثيقة والالتزام يُغلقان معاً. التزام يبقى نشطاً بعد إلغاء وثيقته
// ‏يعني قسطاً يُقيَّد كل ليلة على سائقٍ بلا تغطية — عكس الثغرة السابقة
// ‏تماماً، وأسوأ منها: هناك مال يضيع على الشركة، وهنا مال يُؤخذ ظلماً.
try { try {
$con->beginTransaction();
// ‏الشرط على driver_id إلى جانب المعرّف: الوثيقة أتت من استعلام // ‏الشرط على driver_id إلى جانب المعرّف: الوثيقة أتت من استعلام
// ‏مقيَّد بالسائق أصلاً، لكن الشرط هنا يجعل الاستعلام آمناً بذاته // ‏مقيَّد بالسائق أصلاً، لكن الشرط هنا يجعل الاستعلام آمناً بذاته
// ‏لو أعيد استعماله يوماً في سياق آخر. // ‏لو أعيد استعماله يوماً في سياق آخر.
@@ -34,24 +40,22 @@ try {
$st->execute([mb_substr($reason, 0, 255), $policy['id'], $driverId]); $st->execute([mb_substr($reason, 0, 255), $policy['id'], $driverId]);
if ($st->rowCount() === 0) { if ($st->rowCount() === 0) {
$con->rollBack();
jsonError('تعذّر إلغاء الوثيقة'); jsonError('تعذّر إلغاء الوثيقة');
} }
} catch (PDOException $e) {
obligationClose($con, $driverId, $policy['code'], $reason);
$con->commit();
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
error_log('[assurance/cancel] ' . $e->getMessage()); error_log('[assurance/cancel] ' . $e->getMessage());
jsonError('Server error'); jsonError('Server error');
} }
$pending = 0.0; // ‏المستحق يُقرأ من الدفتر الموحّد لا من دفتر التأمين القديم: القديم
try { // ‏مجمَّد بعد الترحيل، وقراءته تعني رقماً لا يتغيّر مهما سدّد السائق.
$st = $con->prepare(" $pending = obligationPendingTotal($con, $driverId, $policy['code']);
SELECT COALESCE(SUM(amount), 0) FROM insurance_premium_ledger
WHERE policy_id = ? AND status = 'pending'
");
$st->execute([$policy['id']]);
$pending = (float) $st->fetchColumn();
} catch (PDOException $e) {
error_log('[assurance/cancel] تعذّرت قراءة المستحق: ' . $e->getMessage());
}
error_log("[assurance] أُلغيت وثيقة #{$policy['id']} للسائق $driverId"); error_log("[assurance] أُلغيت وثيقة #{$policy['id']} للسائق $driverId");
+19 -12
View File
@@ -32,28 +32,35 @@ $summary = ['pending_total' => 0.0, 'settled_total' => 0.0,
'currency' => $policy['currency']]; 'currency' => $policy['currency']];
$entries = []; $entries = [];
// ‏المصدر هو الدفتر الموحّد لا دفتر التأمين القديم (المجمَّد بعد ترحيل
// ‏2026_08_08). المفتاح صار (السائق + رمز المنتج) بدل رقم الوثيقة:
// ‏الدفتر الموحّد لا يعرف الوثائق، يعرف التزامات.
try { try {
// ‏المستحق من `amount_remaining` والمحصَّل من `amount_collected`:
// ‏قيدٌ حُصِّل نصفه يظهر في الرقمين معاً بحصّته الصحيحة، بينما جمع
// ‏`amount` حسب الحالة كان سيعدّه كاملاً في أحدهما ويكذب في كليهما.
$st = $con->prepare(" $st = $con->prepare("
SELECT status, SUM(amount) AS total SELECT COALESCE(SUM(amount_remaining), 0) AS pending_total,
FROM insurance_premium_ledger COALESCE(SUM(amount_collected), 0) AS settled_total
WHERE policy_id = ? FROM obligation_ledger
GROUP BY status WHERE driver_id = ? AND product_code = ?
"); ");
$st->execute([$policy['id']]); $st->execute([$driverId, $policy['code']]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { if ($row = $st->fetch(PDO::FETCH_ASSOC)) {
if ($row['status'] === 'pending') $summary['pending_total'] = (float) $row['total']; $summary['pending_total'] = (float) $row['pending_total'];
if ($row['status'] === 'settled') $summary['settled_total'] = (float) $row['total']; $summary['settled_total'] = (float) $row['settled_total'];
} }
// ‏آخر ثلاثين قيداً: كافية لشهر يومي، وتمنع رداً ينمو بلا حد. // ‏آخر ثلاثين قيداً: كافية لشهر يومي، وتمنع رداً ينمو بلا حد.
$st = $con->prepare(" $st = $con->prepare("
SELECT charge_date, amount, currency, status, note SELECT charge_date, amount, amount_collected, amount_remaining,
FROM insurance_premium_ledger currency, status, note
WHERE policy_id = ? FROM obligation_ledger
WHERE driver_id = ? AND product_code = ?
ORDER BY charge_date DESC ORDER BY charge_date DESC
LIMIT 30 LIMIT 30
"); ");
$st->execute([$policy['id']]); $st->execute([$driverId, $policy['code']]);
$entries = $st->fetchAll(PDO::FETCH_ASSOC); $entries = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) { } catch (PDOException $e) {
error_log('[assurance/get] تعذّرت قراءة الدفتر: ' . $e->getMessage()); error_log('[assurance/get] تعذّرت قراءة الدفتر: ' . $e->getMessage());
+20
View File
@@ -10,6 +10,7 @@
require_once __DIR__ . '/../connect.php'; require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/eligibility.php'; require_once __DIR__ . '/eligibility.php';
require_once __DIR__ . '/../obligations/functions.php';
// ‏الهوية من الـJWT لا من الطلب — الأهلية معلومة عن السائق نفسه، // ‏الهوية من الـJWT لا من الطلب — الأهلية معلومة عن السائق نفسه،
// ‏وقراءتها بمعرّف مُرسَل تكشف أرقام غيره. // ‏وقراءتها بمعرّف مُرسَل تكشف أرقام غيره.
@@ -37,10 +38,29 @@ try {
$current = assuranceActivePolicy($con, $driverId); $current = assuranceActivePolicy($con, $driverId);
// ‏نداء واحد للمحفظة لكل الشاشة: الملخّص عن السائق لا عن الخطة.
// ‏بدونه كانت الشاشة تعرض «مؤهَّل» ثم يُرفض الطلب عند الضغط — وهو أسوأ
// ‏من الرفض المبكر: يعد السائق بشيء ثم يسحبه.
$incomeSummary = obligationWalletIncomeSummary($driverId);
$out = []; $out = [];
foreach ($plans as $plan) { foreach ($plans as $plan) {
$check = assuranceCheckEligibility($con, $driverId, $plan); $check = assuranceCheckEligibility($con, $driverId, $plan);
// ‏بوابة الائتمان تُقاس على منتج الالتزام المقابل للخطة. غيابه يعني
// ‏خطة لم تُرحَّل بعد — لا نحجبها ولا ندّعي أهليةً لها، بل نتركها
// ‏لحكم شروط الخطة وحدها ونسجّل الحالة.
$product = obligationProduct($con, $plan['code']);
if ($product) {
$gate = obligationCheckWalletIncome($product, $driverId, $incomeSummary);
if (!$gate['eligible']) {
$check['eligible'] = false;
$check['reasons'] = array_merge($check['reasons'], $gate['reasons']);
}
} else {
error_log("[assurance/plans] لا منتج التزام للخطة {$plan['code']}");
}
$out[] = [ $out[] = [
'id' => (int) $plan['id'], 'id' => (int) $plan['id'],
'code' => $plan['code'], 'code' => $plan['code'],
+34 -1
View File
@@ -9,6 +9,7 @@
require_once __DIR__ . '/../connect.php'; require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/eligibility.php'; require_once __DIR__ . '/eligibility.php';
require_once __DIR__ . '/../obligations/functions.php';
$driverId = $user_id ?? ''; $driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') { if (empty($driverId) || ($role ?? '') !== 'driver') {
@@ -49,7 +50,30 @@ if (!$check['eligible']) {
jsonError('لم تستوفِ شروط هذه الخطة بعد: ' . implode('، ', $check['reasons'])); jsonError('لم تستوفِ شروط هذه الخطة بعد: ' . implode('، ', $check['reasons']));
} }
// ‏بوابة الائتمان تُفحص هنا صراحةً رغم أن `obligationOpen` تفحصها أيضاً:
// ‏الفحص هناك حارسٌ أخير يرمي استثناءً، ورسالته تصل للسائق كخطأ عام.
// ‏السائق الذي يُرفض طلبه يستحق أن يعرف السبب وما يفعله ليستوفيه.
$product = obligationProduct($con, $plan['code']);
if (!$product) {
error_log("[assurance/subscribe] منتج الالتزام مفقود للخطة {$plan['code']}");
jsonError('تعذّر تفعيل الوثيقة حالياً');
}
$walletGate = obligationCheckWalletIncome($product, $driverId);
if (!$walletGate['eligible']) {
jsonError(
'التأمين يُخصم قسطه من أرباحك في المحفظة، ولذلك يشترط أن تمرّ أرباحك بها: '
. implode('، ', $walletGate['reasons'])
);
}
// ‏كتابتان في معاملة واحدة: الوثيقة (السجل التأميني الذي يقرأه التطبيق)
// ‏والالتزام (ما يراه محرك القيد والتسوية). وثيقة بلا التزام تعني تغطية
// ‏تُمنح ولا يُحصَّل قسطها أبداً — وهي بالضبط الثغرة التي جاء هذا المحرك
// ‏ليغلقها، فلا يجوز أن نعيد فتحها من باب الاشتراكات الجديدة.
try { try {
$con->beginTransaction();
$con->prepare(" $con->prepare("
INSERT INTO driver_insurance_policies INSERT INTO driver_insurance_policies
(driver_id, plan_id, status, started_at, rides_at_signup, rating_at_signup) (driver_id, plan_id, status, started_at, rides_at_signup, rating_at_signup)
@@ -57,7 +81,16 @@ try {
")->execute([$driverId, $planId, $check['rides'], $check['rating']]); ")->execute([$driverId, $planId, $check['rides'], $check['rating']]);
$policyId = (int) $con->lastInsertId(); $policyId = (int) $con->lastInsertId();
} catch (PDOException $e) {
obligationOpen($con, $driverId, $plan['code'], [
'cycle_amount' => (float) $plan['premium'],
'rides' => $check['rides'],
'rating' => $check['rating'],
]);
$con->commit();
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
error_log('[assurance/subscribe] تعذّر إنشاء الوثيقة: ' . $e->getMessage()); error_log('[assurance/subscribe] تعذّر إنشاء الوثيقة: ' . $e->getMessage());
jsonError('تعذّر تفعيل الوثيقة حالياً'); jsonError('تعذّر تفعيل الوثيقة حالياً');
} }
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* fuel/cancel_voucher.php — إلغاء قسيمة لم تُصرف
*
* ‏يحرّر المبلغ المحجوز فوراً. بدونه ينتظر السائق انتهاء الصلاحية ليعود
* ‏سقفه — وهو ينتظر عند مضخة ليتزوّد بمبلغ آخر.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
// ‏الشرط على driver_id إلى جانب الحالة: الرمز وحده يكفي للعثور على
// ‏قسيمة، ولا يجوز أن يُلغي سائقٌ قسيمة غيره برمز وقع بيده.
$st = $con->prepare("
UPDATE fuel_vouchers
SET status = 'cancelled'
WHERE driver_id = ? AND status = 'issued'
");
$st->execute([$driverId]);
if ($st->rowCount() === 0) {
jsonError('لا توجد قسيمة قابلة للإلغاء');
}
$obligation = fuelObligation($con, $driverId);
$state = $obligation ? fuelCreditState($con, $obligation) : null;
jsonSuccess([
'cancelled' => true,
'available' => $state['available'] ?? null,
], 'أُلغيت القسيمة وعاد المبلغ إلى سقفك');
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* fuel/enroll.php — تفعيل محفظة الوقود للسائق
*
* ‏يفتح التزام `fuel_wallet` بسقف ائتمان مبدئي. لا يصرف شيئاً — الصرف
* ‏يبدأ بقسيمة، والدَّين يُقيَّد عند تأكيد المحطة.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/../driver_assurance/eligibility.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
if (fuelObligation($con, $driverId)) {
jsonError('محفظة الوقود مفعّلة لديك بالفعل');
}
$product = obligationProduct($con, FUEL_PRODUCT_CODE);
if (!$product) {
error_log('[fuel/enroll] منتج محفظة الوقود غير موجود أو مُطفأ');
jsonError('محفظة الوقود غير متاحة حالياً');
}
// ‏شروط الرحلات والتقييم تُقاس بالدالة نفسها التي يستعملها التأمين:
// ‏المنطق واحد (سائق أثبت أنه باقٍ)، وازدواج تطبيقه كان سينتج معيارين
// ‏يفترقان بصمت عند أول تعديل.
$check = assuranceCheckEligibility($con, $driverId, $product);
if (!$check['eligible']) {
jsonError('لم تستوفِ شروط محفظة الوقود بعد: ' . implode('، ', $check['reasons']));
}
// ‏بوابة الائتمان أشدّ هنا منها في التأمين: الوقود قيمة فعلية تُسلَّم في
// ‏خزّان السيارة، ولا سبيل لاستردادها إلا من أرباح تمرّ بالمحفظة.
$gate = obligationCheckWalletIncome($product, $driverId);
if (!$gate['eligible']) {
jsonError(
'محفظة الوقود تُسدَّد من أرباحك في المحفظة، ولذلك تشترط أن تمرّ أرباحك بها: '
. implode('، ', $gate['reasons'])
);
}
// ‏السقف المبدئي واحد للجميع في هذه المرحلة. رفعه بحسب سلوك السداد قرار
// ‏لاحق يحتاج بيانات لا نملكها بعد — ومنح سقف متدرّج بلا تاريخ سداد
// ‏تخمينٌ يرتدي ثوب الخوارزمية.
$defaultLimit = (float) (getenv('FUEL_DEFAULT_CREDIT_LIMIT') ?: 30);
try {
$con->beginTransaction();
$obligationId = obligationOpen($con, $driverId, FUEL_PRODUCT_CODE, [
'cycle_amount' => 0, // ‏منتج سحب: لا قسط دوري له
'rides' => $check['rides'],
'rating' => $check['rating'],
]);
$con->prepare("UPDATE driver_obligations SET credit_limit = ? WHERE id = ?")
->execute([$defaultLimit, $obligationId]);
$con->commit();
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
error_log('[fuel/enroll] ' . $e->getMessage());
jsonError('تعذّر تفعيل محفظة الوقود حالياً');
}
error_log("[fuel] فُعّلت محفظة الوقود للسائق $driverId بسقف $defaultLimit");
jsonSuccess([
'obligation_id' => $obligationId,
'credit_limit' => $defaultLimit,
'available' => $defaultLimit,
'currency' => $product['currency'],
], 'تم تفعيل محفظة الوقود');
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* fuel/functions.php — منطق محفظة الوقود المشترك
* ─────────────────────────────────────────────────────────────
* ‏محفظة الوقود منتج `drawdown` في محرك الالتزامات: لا دورة فوترة له،
* ‏والاستحقاق يُقيَّد لحظة الصرف من المحطة لا على جدول زمني.
*
* ‏كل ما يخصّ المال هنا يقتصر على **قيد الدَّين**. الخصم من رصيد السائق
* ‏يبقى حكراً على محرك التسوية، كما في التأمين.
*/
require_once __DIR__ . '/../obligations/functions.php';
const FUEL_PRODUCT_CODE = 'fuel_wallet';
const FUEL_VOUCHER_TTL_MIN = 45;
/**
* ‏التزام الوقود النشط للسائق، أو null.
*/
function fuelObligation(PDO $con, string $driverId): ?array
{
$st = $con->prepare("
SELECT o.*, p.currency, p.daily_cap_percent
FROM driver_obligations o
JOIN obligation_products p ON p.id = o.product_id
WHERE o.driver_id = ? AND p.code = ? AND o.status = 'active'
LIMIT 1
");
$st->execute([$driverId, FUEL_PRODUCT_CODE]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* ‏يُنهي صلاحية القسائم التي مضى وقتها.
*
* ‏يُستدعى قبل أي حساب سقف لا في كرون: القسيمة المنتهية تحجز من سقف
* ‏السائق، وتأخير تحريرها ساعةً كاملةً حتى يمرّ الكرون يعني منعه من
* ‏التزوّد بسبب قسيمة ماتت. التنظيف عند القراءة يجعل الرقم صادقاً دائماً.
*/
function fuelExpireStaleVouchers(PDO $con, string $driverId): void
{
$con->prepare("
UPDATE fuel_vouchers
SET status = 'expired'
WHERE driver_id = ? AND status = 'issued' AND expires_at < NOW()
")->execute([$driverId]);
}
/**
* ‏حالة سقف السائق: المستخدَم، المحجوز، والمتاح.
*
* ‏المحجوز يُطرح كالمستخدَم تماماً: قسيمة أُصدرت ولم تُصرف بعد قد تُصرف
* ‏بعد لحظة، والسماح بإصدار قسائم بمجموع يتجاوز السقف يعني سائقاً
* ‏يتزوّد بضعف ما يستحق ثم لا نجد ما نخصم منه.
*
* @return array{limit: float, outstanding: float, held: float, available: float}
*/
function fuelCreditState(PDO $con, array $obligation): array
{
$obligationId = (int) $obligation['id'];
$st = $con->prepare("
SELECT COALESCE(SUM(amount_remaining), 0)
FROM obligation_ledger
WHERE obligation_id = ? AND status IN ('pending', 'partial')
");
$st->execute([$obligationId]);
$outstanding = (float) $st->fetchColumn();
$st = $con->prepare("
SELECT COALESCE(SUM(amount_authorized), 0)
FROM fuel_vouchers
WHERE obligation_id = ? AND status = 'issued' AND expires_at >= NOW()
");
$st->execute([$obligationId]);
$held = (float) $st->fetchColumn();
$limit = (float) $obligation['credit_limit'];
$available = round($limit - $outstanding - $held, 3);
return [
'limit' => $limit,
'outstanding' => round($outstanding, 3),
'held' => round($held, 3),
'available' => max($available, 0.0),
];
}
/**
* ‏يولّد رمز قسيمة غير متوقَّع.
*
* ‏`random_int` لا `rand`: الرمز مفتاحُ صرفٍ فعلي، ومولّد عشوائية قابل
* ‏للتنبّؤ يعني أن من يعرف رمزاً واحداً قد يخمّن التالي ويتزوّد على
* ‏حساب سائق آخر. عشرة أرقام تُبقي المجال واسعاً والرقم قابلاً للإملاء
* ‏على عامل المحطة هاتفياً.
*/
function fuelGenerateCode(PDO $con): string
{
for ($i = 0; $i < 10; $i++) {
$code = (string) random_int(1000000000, 9999999999);
$st = $con->prepare("SELECT 1 FROM fuel_vouchers WHERE code = ? LIMIT 1");
$st->execute([$code]);
if (!$st->fetchColumn()) {
return $code;
}
}
// ‏عشر محاولات فاشلة على مجال من تسعة مليارات ليست صدفة — هي عطل
// ‏(قاعدة ممتلئة أو مولّد معطوب). الاستمرار هنا يعني رمزاً مكرّراً.
throw new RuntimeException('تعذّر توليد رمز قسيمة فريد');
}
+80
View File
@@ -0,0 +1,80 @@
<?php
/**
* fuel/get.php — حالة محفظة الوقود للسائق
*
* ‏السقف، المستخدَم، المحجوز، والمتاح — ثم آخر الحركات. الأرقام الأربعة
* ‏معاً لا المتاح وحده: السائق الذي يرى «المتاح ٥» بلا تفسير يظنّها
* ‏عطلاً، والذي يرى «سقفك ٣٠، عليك ٢٠، محجوز ٥» يفهم ويتصرّف.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$obligation = fuelObligation($con, $driverId);
if (!$obligation) {
jsonSuccess([
'enrolled' => false,
'credit' => null,
'voucher' => null,
'entries' => [],
], 'محفظة الوقود غير مفعّلة');
}
fuelExpireStaleVouchers($con, $driverId);
$state = fuelCreditState($con, $obligation);
$voucher = null;
$entries = [];
try {
$st = $con->prepare("
SELECT id, code, amount_authorized, currency, expires_at
FROM fuel_vouchers
WHERE driver_id = ? AND status = 'issued' AND expires_at >= NOW()
LIMIT 1
");
$st->execute([$driverId]);
if ($row = $st->fetch(PDO::FETCH_ASSOC)) {
$voucher = [
'voucher_id' => (int) $row['id'],
'code' => $row['code'],
'amount' => (float) $row['amount_authorized'],
'expires_at' => $row['expires_at'],
];
}
// ‏آخر عشرين حركة: كافية لشهر تزوّد، وتمنع رداً ينمو بلا حد.
$st = $con->prepare("
SELECT charge_date, amount, amount_collected, amount_remaining,
currency, status, note
FROM obligation_ledger
WHERE obligation_id = ?
ORDER BY id DESC LIMIT 20
");
$st->execute([(int) $obligation['id']]);
$entries = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[fuel/get] تعذّرت قراءة الحالة: ' . $e->getMessage());
}
jsonSuccess([
'enrolled' => true,
'credit' => [
'limit' => $state['limit'],
'outstanding' => $state['outstanding'],
'held' => $state['held'],
'available' => $state['available'],
'currency' => $obligation['currency'],
// ‏نسبة الاقتطاع اليومي معروضة صراحةً: الخصم من الأرباح يجب ألّا
// ‏يكون مفاجأة، والسائق الذي يعرف أن الاقتطاع ١٥٪ يخطّط عليه.
'daily_cap_percent' => (float) $obligation['daily_cap_percent'],
],
'voucher' => $voucher,
'entries' => $entries,
], 'success');
+152
View File
@@ -0,0 +1,152 @@
<?php
/**
* fuel/redeem.php — تأكيد المحطة صرف القسيمة
* ─────────────────────────────────────────────────────────────
* ‏هذه هي **نقطة قيد الاستحقاق** لمنتج الوقود: اللحظة التي يتحوّل فيها
* ‏وقودٌ في الخزّان إلى دَين في الدفتر.
*
* ‏تُنادى من نظام المحطة لا من تطبيق السائق. السائق طرف مستفيد، وجعل
* ‏تأكيد الصرف بيده يعني أن ينفي ما استلمه.
*
* ‏لا تخصم شيئاً من رصيد أحد — تقيّد استحقاقاً، ويحصّله محرك التسوية
* ‏بالسقف اليومي. نفس الفصل الذي يحكم التأمين.
*/
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/functions.php';
try {
$con = Database::get('main');
} catch (Exception $e) {
error_log('[fuel/redeem] DB unavailable: ' . $e->getMessage());
jsonError('Server error', 500);
}
// ‏مصادقة المحطة بمفتاحها الخاص لا بمفتاح النظام المشترك: المفاتيح
// ‏موزّعة على أطراف خارجية، وتسريب مفتاح محطة يجب أن يكشف تلك المحطة
// ‏وحدها ويُبطَل بصفّ واحد.
$stationKey = $_SERVER['HTTP_X_STATION_KEY'] ?? '';
if (empty($stationKey)) {
jsonError('Unauthorized', 401);
}
$st = $con->prepare("
SELECT id, name_ar, discount_percent
FROM fuel_stations
WHERE api_key_hash = ? AND is_active = 1 LIMIT 1
");
$st->execute([hash('sha256', $stationKey)]);
$station = $st->fetch(PDO::FETCH_ASSOC);
if (!$station) {
jsonError('Unauthorized', 401);
}
$code = filterRequest('code');
$amount = (float) (filterRequest('amount', 'numeric') ?: 0);
if (empty($code) || $amount <= 0) {
jsonError('code و amount مطلوبان');
}
try {
$con->beginTransaction();
// ‏القفل ضروري لا احتياطي: طلبان متزامنان بالرمز نفسه (ضغطة مكرّرة
// ‏على جهاز المحطة، أو محاولة صرف مزدوج) كانا سيمرّان كلاهما فيُقيَّد
// ‏الدَّين مرتين على وقود صُرف مرة.
$st = $con->prepare("
SELECT v.*, o.driver_id AS o_driver
FROM fuel_vouchers v
JOIN driver_obligations o ON o.id = v.obligation_id
WHERE v.code = ? FOR UPDATE
");
$st->execute([$code]);
$voucher = $st->fetch(PDO::FETCH_ASSOC);
if (!$voucher) {
$con->rollBack();
jsonError('رمز غير معروف');
}
if ($voucher['status'] !== 'issued') {
$con->rollBack();
// ‏رسالة صريحة لا عامة: عامل المحطة يحتاج أن يعرف إن كانت صُرفت
// ‏سابقاً أم انتهت صلاحيتها، وإلا أعاد المحاولة أمام سائق ينتظر.
jsonError('القسيمة ' . match ($voucher['status']) {
'redeemed' => 'مصروفة سابقاً',
'expired' => 'منتهية الصلاحية',
'cancelled' => 'ملغاة',
default => 'غير قابلة للصرف',
});
}
if (strtotime($voucher['expires_at']) < time()) {
$con->prepare("UPDATE fuel_vouchers SET status = 'expired' WHERE id = ?")
->execute([(int) $voucher['id']]);
$con->commit();
jsonError('القسيمة منتهية الصلاحية');
}
// ‏الصرف لا يتجاوز المأذون. تجاوزه يعني ديناً لم يوافق عليه السائق
// ‏ولم يُحجز له سقف — أي خرقاً للسقف من باب خلفي.
if ($amount > (float) $voucher['amount_authorized']) {
$con->rollBack();
jsonError(
'المبلغ يتجاوز المأذون به في القسيمة ('
. (float) $voucher['amount_authorized'] . ')'
);
}
$con->prepare("
UPDATE fuel_vouchers
SET status = 'redeemed', station_id = ?, amount_redeemed = ?,
discount_percent = ?, redeemed_at = NOW()
WHERE id = ?
")->execute([
(int) $station['id'], $amount,
(float) $station['discount_percent'], (int) $voucher['id'],
]);
// ‏قيد الاستحقاق. `source_ref` رقم القسيمة — وهو ما يسمح بأكثر من
// ‏سحبة في اليوم الواحد (المفتاح الفريد يشمله)، ويجعل إعادة النداء
// ‏بالرمز نفسه غير قادرة على مضاعفة الدَّين.
$con->prepare("
INSERT INTO obligation_ledger
(obligation_id, driver_id, product_code, charge_date, source_ref,
amount, amount_collected, amount_remaining, currency, status, note)
VALUES (?, ?, ?, CURDATE(), ?, ?, 0, ?, ?, 'pending', ?)
")->execute([
(int) $voucher['obligation_id'], $voucher['driver_id'], FUEL_PRODUCT_CODE,
'voucher_' . (int) $voucher['id'],
$amount, $amount, $voucher['currency'],
mb_substr('تزوّد من ' . $station['name_ar'], 0, 255),
]);
$con->commit();
} catch (PDOException $e) {
if ($con->inTransaction()) $con->rollBack();
// ‏23000 على المفتاح الفريد يعني أن هذه القسيمة قُيّدت فعلاً — نداء
// ‏مكرّر لا خطأ. القسيمة مصروفة والدَّين مقيَّد مرة واحدة.
if ($e->getCode() === '23000') {
jsonSuccess(['code' => $code, 'duplicate' => true], 'القسيمة مصروفة سابقاً');
}
error_log('[fuel/redeem] ' . $e->getMessage());
jsonError('تعذّر إتمام الصرف', 500);
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
error_log('[fuel/redeem] ' . $e->getMessage());
jsonError('تعذّر إتمام الصرف', 500);
}
error_log("[fuel] صُرفت القسيمة {$code} في محطة #{$station['id']} بقيمة $amount");
jsonSuccess([
'code' => $code,
'amount' => $amount,
'currency' => $voucher['currency'],
'station' => $station['name_ar'],
'discount_percent' => (float) $station['discount_percent'],
], 'تم الصرف');
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* fuel/request_voucher.php — إصدار قسيمة تزوّد
*
* ‏السائق يطلب مبلغاً فيحصل على رمز يعطيه لعامل المحطة. لا دَين بعد —
* ‏القسيمة حجزٌ من السقف فقط، والدَّين يُقيَّد عند تأكيد المحطة الصرف.
*
* ‏لماذا قسيمة مسبقة لا خصم عند المضخة؟ لأن عامل المحطة لا يملك وسيلة
* ‏للتحقق من هوية السائق ولا من سقفه. القسيمة تنقل القرار إلى تطبيق
* ‏السائق حيث الهوية مؤكَّدة والسقف معلوم، ولا يبقى على المحطة إلا
* ‏تأكيد رقم.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$amount = (float) (filterRequest('amount', 'numeric') ?: 0);
if ($amount <= 0) {
jsonError('حدّد مبلغ التزوّد');
}
$obligation = fuelObligation($con, $driverId);
if (!$obligation) {
jsonError('فعّل محفظة الوقود أولاً');
}
// ‏تحرير المنتهي قبل الحساب: بدونه يبقى المبلغ محجوزاً بقسيمة ماتت،
// ‏فيُمنع السائق من التزوّد بسبب طلب نسيه قبل ساعة.
fuelExpireStaleVouchers($con, $driverId);
// ‏قسيمة مفتوحة واحدة في كل وقت. قسيمتان تعنيان رمزين في يد السائق،
// ‏وخطأ عامل المحطة في اختيار أحدهما يصرف المبلغ الخطأ — والأهم أن
// ‏السائق لا يحتاج أكثر من واحدة أصلاً.
$st = $con->prepare("
SELECT id, code, amount_authorized, expires_at
FROM fuel_vouchers
WHERE driver_id = ? AND status = 'issued' AND expires_at >= NOW()
LIMIT 1
");
$st->execute([$driverId]);
if ($open = $st->fetch(PDO::FETCH_ASSOC)) {
jsonError(
'لديك قسيمة سارية بالرمز ' . $open['code'] . ' بقيمة '
. (float) $open['amount_authorized'] . '. استخدمها أو ألغِها أولاً.'
);
}
$state = fuelCreditState($con, $obligation);
if ($amount > $state['available']) {
jsonError(
'المتاح في محفظة وقودك ' . $state['available'] . ' ' . $obligation['currency']
. '. سدّد المستحق عليك ليعود سقفك.'
);
}
try {
$code = fuelGenerateCode($con);
$con->prepare("
INSERT INTO fuel_vouchers
(obligation_id, driver_id, code, amount_authorized, currency, status, expires_at)
VALUES (?, ?, ?, ?, ?, 'issued', DATE_ADD(NOW(), INTERVAL ? MINUTE))
")->execute([
(int) $obligation['id'], $driverId, $code, $amount,
$obligation['currency'], FUEL_VOUCHER_TTL_MIN,
]);
$voucherId = (int) $con->lastInsertId();
} catch (Throwable $e) {
error_log('[fuel/request_voucher] ' . $e->getMessage());
jsonError('تعذّر إصدار القسيمة حالياً');
}
error_log("[fuel] قسيمة #$voucherId للسائق $driverId بقيمة $amount");
jsonSuccess([
'voucher_id' => $voucherId,
'code' => $code,
'amount' => $amount,
'currency' => $obligation['currency'],
'expires_in_min' => FUEL_VOUCHER_TTL_MIN,
'available_after' => round($state['available'] - $amount, 3),
], 'أعطِ هذا الرمز لعامل المحطة');
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* fuel/stations.php — المحطات الشريكة
*
* ‏قائمة للسائق، مرتّبة بالقرب إن أرسل موقعه. لا يُرجع مفاتيح المحطات
* ‏ولا أي حقل تشغيلي — القائمة تُعرض في تطبيق السائق، وكل ما يخرج إليه
* ‏يجب افتراض أنه علنيّ.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$lat = filterRequest('lat', 'numeric');
$lng = filterRequest('lng', 'numeric');
try {
if ($lat !== null && $lng !== null) {
// ‏مسافة تقريبية بفرق الإحداثيات لا Haversine: الغرض ترتيب قائمة
// ‏محطات في مدينة واحدة، والفارق بين الطريقتين على هذا النطاق
// ‏لا يغيّر ترتيباً — بينما الحساب المثلثي على كل صف يكلّف بلا مقابل.
$st = $con->prepare("
SELECT id, name_ar, chain_name, city, address, lat, lng, phone,
discount_percent,
(POW(lat - ?, 2) + POW(lng - ?, 2)) AS dist
FROM fuel_stations
WHERE is_active = 1
ORDER BY (lat IS NULL), dist ASC
LIMIT 50
");
$st->execute([(float) $lat, (float) $lng]);
} else {
$st = $con->query("
SELECT id, name_ar, chain_name, city, address, lat, lng, phone,
discount_percent
FROM fuel_stations
WHERE is_active = 1
ORDER BY city ASC, name_ar ASC
LIMIT 50
");
}
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[fuel/stations] ' . $e->getMessage());
jsonError('Server error');
}
$out = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'name' => $r['name_ar'],
'chain' => $r['chain_name'],
'city' => $r['city'],
'address' => $r['address'],
'lat' => $r['lat'] !== null ? (float) $r['lat'] : null,
'lng' => $r['lng'] !== null ? (float) $r['lng'] : null,
'phone' => $r['phone'],
'discount_percent' => (float) $r['discount_percent'],
], $rows);
jsonSuccess(['stations' => $out], 'success');
@@ -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'
);
+234
View File
@@ -0,0 +1,234 @@
<?php
/**
* obligations/functions.php — دوال محرك الالتزامات المشتركة
* ─────────────────────────────────────────────────────────────
* ‏الواجهة التي تستعملها المنتجات (تأمين، وقود، صيانة) لفتح التزام
* ‏وإغلاقه وقراءة المستحق عليه. لا شيء هنا يلمس مالاً: الخصم حكرٌ على
* ‏`bot/cron_obligation_settlement.php` وحده.
*
* ‏سبب وجود هذا الملف أن `driver_assurance/` كان يكتب في جداوله الخاصة،
* ‏وبقاؤه كذلك بعد الترحيل كان يعني أن كل اشتراك **جديد** لا يظهر في
* ‏الدفتر الموحّد — أي لا يُقيَّد له قسط ولا يُحصَّل منه شيء. الترحيل
* ‏لمرة واحدة يعالج الماضي؛ هذه الدوال تمنع تكرار الثغرة في المستقبل.
*/
const OBLIGATION_HTTP_TIMEOUT = 15;
function obligationWalletServerUrl(): string
{
// ‏bootstrap.php يعرّف GLOBAL_COUNTRY ثابتاً ولا يضعه في البيئة —
// ‏استخدم الثابت لا getenv (نفس فخّ foodWalletServerUrl).
$country = strtolower(defined('GLOBAL_COUNTRY') ? GLOBAL_COUNTRY : (getenv('GLOBAL_COUNTRY') ?: 'jordan'));
return match ($country) {
'egypt' => getenv('WALLET_SERVER_EGYPT') ?: 'https://wallet-egypt.siromove.com',
'syria' => getenv('WALLET_SERVER_SYRIA') ?: 'https://wallet-syria.siromove.com',
default => getenv('WALLET_SERVER_JORDAN') ?: 'https://walletintaleq.intaleq.xyz',
};
}
/**
* ‏نداء خادم-لخادم إلى سيرفر المحفظة. يُرجع الحمولة أو null عند الفشل.
*
* ‏printSuccess في سيرفر المحفظة يغلّف الحمولة في المفتاح "message" —
* ‏اصطلاحه لا خطأ تسمية هنا.
*/
function obligationWalletCall(string $path, array $params): ?array
{
$key = getenv('S2S_SHARED_KEY') ?: '';
if ($key === '') {
error_log('[obligation] S2S_SHARED_KEY غير معرّف');
return null;
}
$ch = curl_init(obligationWalletServerUrl() . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => OBLIGATION_HTTP_TIMEOUT,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'X-S2S-Api-Key: ' . $key,
],
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($body === false || $code !== 200) {
error_log("[obligation] نداء المحفظة فشل ($path): HTTP $code $err");
return null;
}
$json = json_decode((string) $body, true);
if (!is_array($json) || ($json['status'] ?? '') !== 'success' || !is_array($json['message'] ?? null)) {
error_log("[obligation] رد غير متوقّع ($path): " . substr((string) $body, 0, 200));
return null;
}
return $json['message'];
}
/**
* ‏ملخّص دخل السائق المارّ بالمحفظة في آخر ثلاثين يوماً، أو null عند
* ‏تعذّر الوصول. يُقرأ من سيرفر المحفظة لأن الحركات تعيش هناك.
*/
function obligationWalletIncomeSummary(string $driverId): ?array
{
return obligationWalletCall(
'/v2/main/ride/driverWallet/income_summary_s2s.php',
['driverID' => $driverId, 'window_days' => 30]
);
}
/**
* ‏بوابة الائتمان: هل تمرّ أرباح هذا السائق بالمحفظة بما يكفي؟
*
* ‏قرار المالك (2026-08-09): لا التزام ائتماني لمن لا تمرّ أرباحه
* ‏بالمحفظة. السبب أن سقف الخصم اليومي نسبة من الدخل المارّ بها، فسائق
* ‏الكاش سقفه صفر دائماً ولا يُحصَّل منه شيء أبداً — ومنحه وقوداً أو
* ‏صيانة بالدَّين تسليمُ قيمةٍ فعلية مقابل قناة سداد لا وجود لها.
*
* ‏الفشل مغلق: تعذُّر الوصول إلى سيرفر المحفظة يعني **رفضاً** لا قبولاً.
* ‏عطلٌ شبكي عابر يؤجّل منح ائتمان، بينما القبول عند الشك يمنحه لمن قد
* ‏لا يُسترد منه — والخطأ الثاني وحده هو الذي يكلّف مالاً.
*
* ‏`$summary` يُمرَّر جاهزاً حين تُفحص عدة منتجات لسائق واحد (شاشة
* ‏الخطط): الملخّص عن السائق لا عن المنتج، ونداء لكل خطة يعني أربعة
* ‏نداءات شبكية لبناء شاشة واحدة.
*
* @return array{eligible: bool, reasons: string[], summary: array|null}
*/
function obligationCheckWalletIncome(array $product, string $driverId, ?array $summary = null): array
{
if ((int) ($product['requires_wallet_income'] ?? 1) !== 1) {
return ['eligible' => true, 'reasons' => [], 'summary' => null];
}
$minDays = (int) ($product['min_wallet_days_30d'] ?? 0);
$minIncome = (float) ($product['min_wallet_income_30d'] ?? 0);
$summary ??= obligationWalletIncomeSummary($driverId);
if ($summary === null) {
return [
'eligible' => false,
'reasons' => ['تعذّر التحقق من حركة محفظتك حالياً. حاول لاحقاً.'],
'summary' => null,
];
}
$days = (int) ($summary['days_with_income'] ?? 0);
$income = (float) ($summary['total_income'] ?? 0);
$reasons = [];
if ($days < $minDays) {
$reasons[] = "أرباحك مرّت بالمحفظة في $days يوماً خلال آخر ٣٠ يوماً، والمطلوب $minDays";
}
if ($minIncome > 0 && $income < $minIncome) {
$reasons[] = 'إجمالي ما مرّ بمحفظتك خلال آخر ٣٠ يوماً أقل من الحد المطلوب';
}
return [
'eligible' => empty($reasons),
'reasons' => $reasons,
'summary' => $summary,
];
}
/**
* ‏يقرأ منتج التزام برمزه. يُرجع null إن لم يوجد أو كان مُطفأً.
*/
function obligationProduct(PDO $con, string $productCode): ?array
{
$st = $con->prepare("
SELECT * FROM obligation_products WHERE code = ? AND is_active = 1 LIMIT 1
");
$st->execute([$productCode]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* ‏يفتح التزاماً لسائق على منتج، ويُرجع معرّفه.
*
* ‏`cycle_amount` يُنسخ من المنتج لحظة الفتح ولا يُقرأ منه لاحقاً: رفع
* ‏سعر المنتج غداً يجب ألّا يغيّر قسط من اشترك اليوم بأثر رجعي.
*/
function obligationOpen(
PDO $con,
string $driverId,
string $productCode,
array $snapshot = [],
float $principal = 0.0,
?string $externalRef = null
): int {
$product = obligationProduct($con, $productCode);
if (!$product) {
throw new RuntimeException("منتج الالتزام غير موجود أو غير مفعّل: $productCode");
}
// ‏البوابة تُفحص هنا أيضاً لا في نقطة الاستدعاء وحدها. النقطة قد
// ‏تفحص وتنسى، أو يُضاف منتج جديد بنقطة جديدة تُغفلها — والفحص عند
// ‏الكتابة هو الوحيد الذي لا يمكن الالتفاف عليه بالسهو.
$gate = obligationCheckWalletIncome($product, $driverId);
if (!$gate['eligible']) {
throw new RuntimeException('بوابة الائتمان: ' . implode('، ', $gate['reasons']));
}
$con->prepare("
INSERT INTO driver_obligations
(driver_id, product_id, external_ref, status, started_at,
cycle_amount, principal_amount, rides_at_signup, rating_at_signup)
VALUES (?, ?, ?, 'active', CURDATE(), ?, ?, ?, ?)
")->execute([
$driverId,
(int) $product['id'],
$externalRef,
(float) ($snapshot['cycle_amount'] ?? 0),
$principal,
(int) ($snapshot['rides'] ?? 0),
(float) ($snapshot['rating'] ?? 0),
]);
return (int) $con->lastInsertId();
}
/**
* ‏يُغلق التزاماً نشطاً. لا يمسّ القيود المستحقّة — تصفيرها عند الإلغاء
* ‏كانت ستجعل الإلغاء وسيلةً للتهرّب من أيام استفاد منها السائق فعلاً.
*/
function obligationClose(PDO $con, string $driverId, string $productCode, string $reason): bool
{
$st = $con->prepare("
UPDATE driver_obligations o
JOIN obligation_products p ON p.id = o.product_id
SET o.status = 'cancelled', o.ended_at = CURDATE(), o.cancel_reason = ?
WHERE o.driver_id = ? AND p.code = ? AND o.status = 'active'
");
$st->execute([mb_substr($reason, 0, 255), $driverId, $productCode]);
return $st->rowCount() > 0;
}
/**
* ‏مجموع ما بقي على السائق في منتج ما.
*
* ‏الأساس `amount_remaining` لا `amount`: قيدٌ حُصِّل نصفه لم يعد مستحقاً
* ‏بكامله، وعرض الأصل للسائق بعد اقتطاعٍ منه مطالبةٌ بما دُفع.
*/
function obligationPendingTotal(PDO $con, string $driverId, string $productCode): float
{
$st = $con->prepare("
SELECT COALESCE(SUM(amount_remaining), 0)
FROM obligation_ledger
WHERE driver_id = ? AND product_code = ?
AND status IN ('pending', 'partial')
");
$st->execute([$driverId, $productCode]);
return (float) $st->fetchColumn();
}
+210
View File
@@ -0,0 +1,210 @@
<?php
/**
* ride/earnings/summary.php — ملخّص أرباح السائق من نقطة واحدة
* ─────────────────────────────────────────────────────────────
* ‏كانت شاشة الأرباح تبني نفسها من أربعة نداءات مختلفة، لكلٍّ منها لحظة
* ‏وصول ومنطق تجميع مستقلّان. النتيجة أن السائق يرى أرقاماً لا تتفق —
* ‏والسائق الذي لا يثق برقم أرباحه لا يثق بالمنصة كلها.
*
* ‏هذه النقطة تُرجع كل شيء بختم زمني واحد.
*
* ‏**تعريف مقصود:** «الأرباح» هنا من الرحلات لا من المحفظة. سؤال «كم
* ‏كسبت؟» غير سؤال «كم رصيدي؟»: الأول عن العمل، والثاني عن المال المتاح
* ‏بعد السحب والشحن والاقتطاعات. خلطهما هو ما أنتج الرقمين المتناقضين.
* ‏الرصيد يبقى في شاشة المحفظة، ويُشار إليه هنا بالاقتطاعات فقط.
*
* ‏خطآن في `gamification/getWeeklyAggregate.php` عولجا هنا:
*
* ١. `status = 'Finished'` وحدها. الحالة مكتوبة بحرفين مختلفين في
* ‏الجدول تاريخياً ('Finished' و'finished') — كما وثّق
* ‏`driver_assurance/eligibility.php` — فكان نصف رحلات السائق
* ‏تقريباً يسقط من أرباحه المعروضة.
*
* ٢. `TIMESTAMPDIFF(MINUTE, r.time, r.endtime)` على عمودَي TIME.
* ‏رحلة تبدأ 23:40 وتنتهي 00:10 تُحسب بـ**سالب** ٢٣ ساعة ونصف،
* ‏فتأكل ساعات يوم كامل. نستعمل `rideTimeStart`/`rideTimeFinish`
* ‏وهما timestamps كاملة.
*/
require_once __DIR__ . '/../../connect.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
// ‏الحالتان معاً في كل استعلام أدناه. ثابت واحد يمنع أن يُصحَّح أحدها
// ‏ويُنسى الآخر.
const EARNINGS_FINISHED = "r.status IN ('Finished', 'finished')";
/**
* ‏مجاميع فترة واحدة.
*
* ‏`price_for_driver` هو حصة السائق المثبَّتة لحظة عرض السعر — وهو ما
* ‏وُعد به قبل القبول. استعماله (لا اشتقاق نسبة العمولة الآن) يضمن أن
* ‏ما يراه في شاشة الأرباح هو ما رآه في شاشة الطلب.
*/
function earningsPeriod(PDO $con, string $driverId, string $dateCondition): array
{
$sql = "
SELECT
COUNT(r.id) AS trips,
COALESCE(SUM(r.price_for_driver), 0) AS net,
COALESCE(SUM(r.price), 0) AS gross,
COALESCE(SUM(r.distance), 0) AS distance,
COALESCE(SUM(
GREATEST(TIMESTAMPDIFF(MINUTE, r.rideTimeStart, r.rideTimeFinish), 0)
), 0) AS minutes
FROM ride r
WHERE r.driver_id = ?
AND " . EARNINGS_FINISHED . "
AND $dateCondition
";
$st = $con->prepare($sql);
$st->execute([$driverId]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: [];
$net = round((float) ($row['net'] ?? 0), 2);
$gross = round((float) ($row['gross'] ?? 0), 2);
return [
'trips' => (int) ($row['trips'] ?? 0),
'net' => $net,
'gross' => $gross,
// ‏العمولة تُعرض صراحةً لا تُطرح بصمت. السائق الذي يرى «كسبتَ ٢٠،
// ‏عمولة المنصة ٣، لك ١٧» يفهم؛ والذي يرى ١٧ وحدها يخمّن ويشكّ.
'commission' => round(max($gross - $net, 0), 2),
'distance' => round((float) ($row['distance'] ?? 0), 1),
'minutes' => (int) ($row['minutes'] ?? 0),
];
}
try {
$today = earningsPeriod($con, $driverId, 'DATE(r.created_at) = CURDATE()');
$week = earningsPeriod($con, $driverId, 'r.created_at >= DATE(NOW()) - INTERVAL 6 DAY');
$month = earningsPeriod($con, $driverId, 'r.created_at >= DATE_FORMAT(NOW(), "%Y-%m-01")');
// ‏الأسبوع السابق للمقارنة. المقارنة بالذات لا بالسائقين الآخرين:
// ‏«أعلى من أسبوعك الماضي» تحفّز الجميع، بينما ترتيب السائقين يُحبط
// ‏أكثرهم — ومن يحتاج التحفيز أكثر هو من سيقع في أسفل الترتيب.
$prevWeek = earningsPeriod(
$con,
$driverId,
'r.created_at >= DATE(NOW()) - INTERVAL 13 DAY AND r.created_at < DATE(NOW()) - INTERVAL 6 DAY'
);
$trend = null;
if ($prevWeek['net'] > 0) {
$trend = round((($week['net'] - $prevWeek['net']) / $prevWeek['net']) * 100, 1);
}
// ── السلسلة اليومية للرسم البياني ──
$st = $con->prepare("
SELECT DATE(r.created_at) AS day,
COUNT(r.id) AS trips,
COALESCE(SUM(r.price_for_driver), 0) AS net,
COALESCE(SUM(
GREATEST(TIMESTAMPDIFF(MINUTE, r.rideTimeStart, r.rideTimeFinish), 0)
), 0) AS minutes
FROM ride r
WHERE r.driver_id = ?
AND " . EARNINGS_FINISHED . "
AND r.created_at >= DATE(NOW()) - INTERVAL 6 DAY
GROUP BY DATE(r.created_at)
ORDER BY DATE(r.created_at) ASC
");
$st->execute([$driverId]);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
// ‏الأيام الفارغة تُملأ بأصفار هنا لا في التطبيق: رسم بياني ينقصه يوم
// ‏يُزيح باقي الأعمدة فيقرأ السائق الثلاثاء مكان الأربعاء.
$byDay = [];
foreach ($rows as $r) {
$byDay[$r['day']] = $r;
}
$daily = [];
for ($i = 6; $i >= 0; $i--) {
$day = date('Y-m-d', strtotime("-$i day"));
$r = $byDay[$day] ?? null;
$daily[] = [
'day' => $day,
'trips' => (int) ($r['trips'] ?? 0),
'net' => round((float) ($r['net'] ?? 0), 2),
'minutes' => (int) ($r['minutes'] ?? 0),
];
}
$best = null;
foreach ($daily as $d) {
if ($best === null || $d['net'] > $best['net']) {
$best = $d;
}
}
} catch (PDOException $e) {
error_log('[earnings/summary] ' . $e->getMessage());
jsonError('تعذّر حساب الأرباح حالياً', 500);
}
// ── الاقتطاعات ──
// ‏تُعرض في شاشة الأرباح لا في شاشة المنتج وحدها. بعد محرك الالتزامات
// ‏صار يُخصم من السائق قسط تأمين ودَين وقود، واقتطاعٌ لا يظهر بجانب
// ‏الأرباح التي اقتُطع منها يُقرأ سرقةً مهما كان مشروعاً.
$deductions = ['today' => 0.0, 'week' => 0.0, 'month' => 0.0, 'outstanding' => 0.0, 'items' => []];
try {
$st = $con->prepare("
SELECT
COALESCE(SUM(CASE WHEN DATE(updated_at) = CURDATE() THEN amount_collected ELSE 0 END), 0) AS today,
COALESCE(SUM(CASE WHEN updated_at >= DATE(NOW()) - INTERVAL 6 DAY THEN amount_collected ELSE 0 END), 0) AS week,
COALESCE(SUM(CASE WHEN updated_at >= DATE_FORMAT(NOW(), '%Y-%m-01') THEN amount_collected ELSE 0 END), 0) AS month,
COALESCE(SUM(amount_remaining), 0) AS outstanding
FROM obligation_ledger
WHERE driver_id = ?
");
$st->execute([$driverId]);
$d = $st->fetch(PDO::FETCH_ASSOC) ?: [];
$deductions['today'] = round((float) ($d['today'] ?? 0), 2);
$deductions['week'] = round((float) ($d['week'] ?? 0), 2);
$deductions['month'] = round((float) ($d['month'] ?? 0), 2);
$deductions['outstanding'] = round((float) ($d['outstanding'] ?? 0), 2);
// ‏تفصيل حسب المنتج: «اقتُطع ٣» سؤال، و«٣ لمحفظة الوقود» جواب.
$st = $con->prepare("
SELECT l.product_code, p.name_ar,
COALESCE(SUM(l.amount_remaining), 0) AS outstanding
FROM obligation_ledger l
JOIN driver_obligations o ON o.id = l.obligation_id
JOIN obligation_products p ON p.id = o.product_id
WHERE l.driver_id = ? AND l.amount_remaining > 0
GROUP BY l.product_code, p.name_ar
");
$st->execute([$driverId]);
$deductions['items'] = array_map(static fn(array $r): array => [
'product' => $r['product_code'],
'name' => $r['name_ar'],
'outstanding' => round((float) $r['outstanding'], 2),
], $st->fetchAll(PDO::FETCH_ASSOC));
} catch (PDOException $e) {
// ‏غياب جداول الالتزامات (قبل تنفيذ الترحيل) ليس خطأً يُفشل الشاشة:
// ‏الأرباح تُعرض والاقتطاعات أصفار. إسقاط الشاشة كلها لأن ميزةً
// ‏جانبية غير مهيّأة عقوبةٌ في غير محلها.
error_log('[earnings/summary] تعذّرت قراءة الاقتطاعات: ' . $e->getMessage());
}
jsonSuccess([
'today' => $today,
'week' => $week,
'month' => $month,
'prev_week' => $prevWeek,
'trend_percent' => $trend,
'daily' => $daily,
'best_day' => $best,
'deductions' => $deductions,
// ‏ختم زمني واحد لكل الأرقام. التطبيق يعرضه كـ«آخر تحديث»، فيعرف
// ‏السائق متى قِيست هذه الأرقام بدل أن يظنّ القديم حاضراً.
'as_of' => date('c'),
], 'success');
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* ride/earnings/trips.php — تفصيل أرباح كل رحلة
* ─────────────────────────────────────────────────────────────
* ‏السؤال الأول عند كل سائق في العالم: «لماذا أخذت هذا المبلغ من هذه
* ‏الرحلة؟» — ولم يكن في التطبيق ما يجيبه. `payment_history_driver_page`
* ‏يعرض حركات محفظة (شحن، سحب، تحويل)، لا تركيبة أجرة رحلة.
*
* ‏وأثر ذلك ليس شكاوى فحسب: المنصة تمنح إعفاء عمولة ٠٪ عند تحقيق سلسلة
* ‏(`hasZeroCommission` في `finish_ride_updates.php`) — هدية حقيقية
* ‏يدفع ثمنها الصندوق ولا يراها السائق في أي شاشة. نحن ندفع ولا نحصد.
*
* POST: date (اختياري YYYY-MM-DD) أو range=week|month، limit
*/
require_once __DIR__ . '/../../connect.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$date = filterRequest('date');
$range = filterRequest('range');
$limit = (int) (filterRequest('limit', 'int') ?: 50);
$limit = max(1, min($limit, 200));
$params = [$driverId];
if (!empty($date)) {
// ‏تاريخ اليوم الواحد: الاستعمال الأغلب — السائق ينقر عموداً في الرسم
// ‏البياني ليرى رحلات ذلك اليوم.
$dateCondition = 'DATE(r.created_at) = ?';
$params[] = $date;
} elseif ($range === 'month') {
$dateCondition = 'r.created_at >= DATE_FORMAT(NOW(), "%Y-%m-01")';
} else {
$dateCondition = 'r.created_at >= DATE(NOW()) - INTERVAL 6 DAY';
}
try {
$st = $con->prepare("
SELECT r.id, r.created_at, r.date, r.time, r.endtime,
r.rideTimeStart, r.rideTimeFinish,
r.start_location, r.end_location,
r.price, r.price_for_driver, r.price_for_passenger,
r.distance, r.carType, r.paymentMethod,
r.ai_negotiated_bonus,
GREATEST(TIMESTAMPDIFF(MINUTE, r.rideTimeStart, r.rideTimeFinish), 0) AS minutes
FROM ride r
WHERE r.driver_id = ?
AND r.status IN ('Finished', 'finished')
AND $dateCondition
ORDER BY r.created_at DESC
LIMIT $limit
");
$st->execute($params);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[earnings/trips] ' . $e->getMessage());
jsonError('تعذّر جلب تفاصيل الرحلات', 500);
}
$trips = array_map(static function (array $r): array {
$gross = round((float) $r['price'], 2);
$net = round((float) $r['price_for_driver'], 2);
$bonus = round((float) ($r['ai_negotiated_bonus'] ?? 0), 2);
// ‏العمولة مشتقّة من الفرق لا محسوبة بنسبة اليوم: النسبة تتغيّر
// ‏بالمنطقة والوقت وخصومات العمولة، وحسابها الآن بالنسبة الحالية
// ‏يُظهر للسائق رقماً يخالف ما اقتُطع منه فعلاً وقت الرحلة.
$commission = round(max($gross - $net, 0), 2);
// ‏إعفاء العمولة: صفرٌ حقيقي لا غياب بيانات. نميّزه صراحةً ليظهر في
// ‏التطبيق كمكسب («عمولة معفاة») لا كسطر فارغ.
$zeroCommission = $gross > 0 && $commission <= 0.009;
return [
'ride_id' => (int) $r['id'],
'at' => $r['created_at'],
'from' => $r['start_location'],
'to' => $r['end_location'],
'car_type' => $r['carType'],
'payment_method' => $r['paymentMethod'],
'distance_km' => round((float) $r['distance'], 1),
'minutes' => (int) $r['minutes'],
'breakdown' => [
'gross' => $gross,
'commission' => $commission,
'bonus' => $bonus,
'net' => $net,
'zero_commission' => $zeroCommission,
],
];
}, $rows);
// ‏مجاميع القائمة المعروضة نفسها. حسابها في التطبيق كان سيعني رقماً
// ‏يختلف عن الملخّص عند اختلاف التقريب — وهو بالضبط نوع التناقض الذي
// ‏جاءت هذه النقطة لتنهيه.
$sum = [
'trips' => count($trips),
'gross' => 0.0,
'commission' => 0.0,
'net' => 0.0,
];
foreach ($trips as $t) {
$sum['gross'] += $t['breakdown']['gross'];
$sum['commission'] += $t['breakdown']['commission'];
$sum['net'] += $t['breakdown']['net'];
}
foreach (['gross', 'commission', 'net'] as $k) {
$sum[$k] = round($sum[$k], 2);
}
jsonSuccess([
'trips' => $trips,
'summary' => $sum,
'as_of' => date('c'),
], 'success');
-526
View File
@@ -1,526 +0,0 @@
<?php
/**
* tools/panel/index.php — لوحة إدارة مفاتيح البيئة
* ─────────────────────────────────────────────────────────────
* ‏⚠️ هذه أعلى نقطة قيمةً في المنظومة: من يصل إليها يملك كل شيء دفعة
* ‏واحدة. لذلك القيود التالية ليست تزييناً، وإزالة أيٍّ منها تُبطل
* ‏الباقي:
*
* ١. ‏الاستماع على 127.0.0.1 فقط — لا تُفتح إلا عبر نفق SSH:
* ssh -L 8099:127.0.0.1:8099 root@server
* ‏ثم http://127.0.0.1:8099 في متصفّحك.
*
* ٢. ‏عبارة مرور منفصلة تماماً عن أي حساب آخر (ENV_PANEL_PASSPHRASE
* ‏مخزَّنة كتجزئة password_hash، لا نصّاً).
*
* ٣. ‏المفاتيح المجمّدة تُعرض حالتها فقط — لا قيمتها ولا حقل تعديل.
* ‏لا يوجد مسار في هذا الملف يكتبها، لا بواجهة ولا بطلب مصنوع.
*
* ٤. ‏كل تغيير يُسجَّل: من أين، ومتى، وأي مفتاح (لا قيمته).
*
* ‏التشغيل (على المضيف، لا داخل الحاوية — يحتاج الوصول إلى docker):
* cd backend/tools/panel && php -S 127.0.0.1:8099
*/
declare(strict_types=1);
require_once __DIR__ . '/../env_registry.php';
// ═══════════════════════════════════════════════════════════════
// القيد ١ — لا شيء غير المضيف المحلي
// ═══════════════════════════════════════════════════════════════
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
if (!in_array($remote, ['127.0.0.1', '::1'], true)) {
http_response_code(403);
exit('Forbidden — local access only.');
}
// ‏ترويسات الوكيل لا تُصدَّق: لو وُضعت اللوحة خلف nginx يوماً، فإن
// ‏X-Forwarded-For تجعل REMOTE_ADDR محلياً دائماً وتُبطل القيد أعلاه.
// ‏وجودها يعني أننا خلف وكيل — وهذا ما لا نريده أصلاً.
foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_FORWARDED'] as $h) {
if (!empty($_SERVER[$h])) {
http_response_code(403);
exit('Forbidden — panel must not be proxied.');
}
}
$root = dirname(__DIR__, 3);
$envFile = getenv('ENV_FILE_PATH') ?: "$root/docker/.env";
$logFile = __DIR__ . '/panel_audit.log';
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Strict',
'use_strict_mode' => true,
]);
/** ‏يسجّل حدثاً. القيم لا تُسجَّل أبداً — أسماء المفاتيح فقط. */
function auditLog(string $file, string $event, string $detail = ''): void
{
$line = sprintf("[%s] %s | %s | %s\n",
date('Y-m-d H:i:s'), $_SERVER['REMOTE_ADDR'] ?? '?', $event, $detail);
@file_put_contents($file, $line, FILE_APPEND);
@chmod($file, 0600);
}
// ═══════════════════════════════════════════════════════════════
// القيد ٢ — عبارة المرور
// ═══════════════════════════════════════════════════════════════
$env = envRead($envFile);
$hash = $env['ENV_PANEL_PASSPHRASE'] ?? '';
if ($hash === '' || !str_starts_with($hash, '$2y$')) {
http_response_code(503);
echo '<meta charset="utf-8"><pre style="font-family:monospace;padding:2rem">';
echo "اللوحة معطّلة: لم تُضبط ENV_PANEL_PASSPHRASE.\n\n";
echo "ولّد تجزئة وضعها في docker/.env:\n\n";
echo " php -r 'echo password_hash(\"عبارتك\", PASSWORD_BCRYPT), PHP_EOL;'\n\n";
echo "ثم: ENV_PANEL_PASSPHRASE='\$2y\$...'\n\n";
echo "اجعلها عبارة لا تشبه أي كلمة مرور أخرى عندك.\n";
echo '</pre>';
exit;
}
$error = '';
if (($_POST['action'] ?? '') === 'login') {
// ‏تأخير ثابت: يجعل تخمين العبارة بطيئاً بما يكفي، وbcrypt نفسه
// ‏يتكفّل بالباقي.
usleep(400_000);
if (password_verify((string) ($_POST['passphrase'] ?? ''), $hash)) {
session_regenerate_id(true);
$_SESSION['ok'] = true;
$_SESSION['csrf'] = bin2hex(random_bytes(16));
auditLog($logFile, 'LOGIN_OK');
} else {
$error = 'عبارة غير صحيحة.';
auditLog($logFile, 'LOGIN_FAIL');
}
}
if (($_GET['logout'] ?? '') === '1') {
session_destroy();
header('Location: ?');
exit;
}
$authed = !empty($_SESSION['ok']);
// ═══════════════════════════════════════════════════════════════
// العمليات
// ═══════════════════════════════════════════════════════════════
$notice = '';
if ($authed && ($_POST['action'] ?? '') !== '' && ($_POST['action'] ?? '') !== 'login') {
if (!hash_equals((string) ($_SESSION['csrf'] ?? ''), (string) ($_POST['csrf'] ?? ''))) {
http_response_code(400);
exit('CSRF mismatch');
}
$key = (string) ($_POST['key'] ?? '');
$action = (string) $_POST['action'];
// ── الحارس الجامع ──
// ‏قبل أي فرع: المجمّدة ممنوعة. الفحص هنا لا في كل فرع، فلا يمكن
// ‏لفرع جديد يُضاف لاحقاً أن ينساه.
if (in_array($key, ENV_FROZEN, true)) {
http_response_code(403);
auditLog($logFile, 'BLOCKED_FROZEN', $key);
exit('هذا المفتاح مجمّد ولا يمكن تعديله من اللوحة.');
}
$rotatable = ENV_GENERATED + ENV_SESSION_SCOPED;
if ($action === 'rotate' && isset($rotatable[$key])) {
$backup = envWrite($envFile, [$key => envGenerate($rotatable[$key])]);
if ($backup) {
auditLog($logFile, 'ROTATE', $key);
$notice = "دُوِّر $key. نسخة احتياطية: " . basename($backup)
. ' — أعد تشغيل حاويات php.';
} else {
$error = 'تعذّرت الكتابة.';
}
} elseif ($action === 'set' && in_array($key, ENV_EXTERNAL, true)) {
$value = trim((string) ($_POST['value'] ?? ''));
if ($value === '') {
$error = 'القيمة فارغة.';
} else {
$backup = envWrite($envFile, [$key => $value]);
if ($backup) {
auditLog($logFile, 'SET_EXTERNAL', $key);
$notice = "حُدِّث $key.";
} else {
$error = 'تعذّرت الكتابة.';
}
}
} elseif ($action === 'rotate_db' && isset(ENV_DB_CREDENTIALS[$key])) {
[$notice, $error] = rotateDbPassword($envFile, $logFile, $env, $key);
} else {
$error = 'عملية غير معروفة أو مفتاح غير قابل للتعديل.';
}
$env = envRead($envFile); // إعادة القراءة بعد أي تغيير
}
/**
* ‏يدوّر كلمة مرور مستخدم قاعدة بيانات بالترتيب الصحيح.
*
* ‏الترتيب هو كل شيء: ALTER USER أولاً، ثم **تحقّق باتصال فعلي**، ثم
* ‏كتابة .env. لو كتبنا .env أولاً وفشل ALTER، لصار التطبيق يحمل كلمة
* ‏لا يقبلها الخادم — انقطاع كامل. ولو نجح ALTER وفشلت الكتابة، نرجع
* ‏كلمة المرور القديمة فوراً بدل ترك الطرفين متنافرين.
*
* ‏ولا تُلمس البيانات في أي خطوة: هذا تغيير في mysql.user لا غير.
*/
function rotateDbPassword(string $envFile, string $logFile, array $env, string $key): array
{
$map = ENV_DB_CREDENTIALS[$key];
$user = $env[$map['user']] ?? '';
$dbName = $env[$map['db']] ?? '';
$rootPass = $env['MYSQL_ROOT_PASSWORD'] ?? '';
$oldPass = $env[$key] ?? '';
if ($user === '' || $dbName === '' || $rootPass === '') {
return ['', "ينقص {$map['user']} أو {$map['db']} أو MYSQL_ROOT_PASSWORD."];
}
// ‏كلمة مرور بلا رموز تحتاج هروباً في الشل — hex آمنة في كل سياق.
$newPass = envGenerate(20);
$alter = sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($newPass)
);
[$code] = runMysql($rootPass, $alter);
if ($code !== 0) {
auditLog($logFile, 'DB_ROTATE_FAIL_ALTER', $key);
return ['', 'فشل ALTER USER — لم يتغيّر شيء.'];
}
// ‏تحقّق: هل تعمل الكلمة الجديدة فعلاً؟ الثقة بأن ALTER نجح لا تكفي
// ‏(قد يكون المستخدم معرَّفاً بمضيف آخر مثلاً).
if (!verifyDbLogin($user, $newPass, $dbName)) {
runMysql($rootPass, sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($oldPass)
));
auditLog($logFile, 'DB_ROTATE_ROLLBACK', $key);
return ['', 'الكلمة الجديدة لم تعمل — أُعيدت القديمة.'];
}
$backup = envWrite($envFile, [$key => $newPass]);
if (!$backup) {
runMysql($rootPass, sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($oldPass)
));
auditLog($logFile, 'DB_ROTATE_ROLLBACK_WRITE', $key);
return ['', 'تعذّرت كتابة .env — أُعيدت كلمة المرور القديمة.'];
}
auditLog($logFile, 'DB_ROTATE', "$key ($user@$dbName)");
return ["دُوِّرت كلمة مرور $user. أعد تشغيل حاويات php الآن.", ''];
}
/** ‏هروب معرّف MySQL بالعلامة الخلفية. */
function quoteIdent(string $s): string
{
return '`' . str_replace('`', '``', $s) . '`';
}
/** ‏هروب سلسلة نصية لـ MySQL. */
function quoteLiteral(string $s): string
{
return "'" . addcslashes($s, "\\'\"\0\n\r\032") . "'";
}
/**
* ‏ينفّذ SQL في حاوية mysql.
*
* ‏كلمة المرور تمرّ عبر متغيّر بيئة لا وسيطاً في سطر الأوامر: الوسيط
* ‏يظهر في `ps` لأي مستخدم على الجهاز.
*/
function runMysql(string $rootPass, string $sql): array
{
$cmd = 'docker compose -f ' . escapeshellarg(dirname(__DIR__, 3) . '/docker/docker-compose.yml')
. ' exec -T -e MYSQL_PWD mysql mysql -uroot -e ' . escapeshellarg($sql) . ' 2>&1';
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open($cmd, $descriptors, $pipes, null, ['MYSQL_PWD' => $rootPass]);
if (!is_resource($proc)) return [1, 'proc_open failed'];
$out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), $out];
}
/** ‏يتأكد أن الاعتماد الجديد يسجّل دخولاً فعلياً. */
function verifyDbLogin(string $user, string $pass, string $db): bool
{
$cmd = 'docker compose -f ' . escapeshellarg(dirname(__DIR__, 3) . '/docker/docker-compose.yml')
. ' exec -T -e MYSQL_PWD mysql mysql -u' . escapeshellarg($user)
. ' ' . escapeshellarg($db) . ' -e "SELECT 1" 2>&1';
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open($cmd, $descriptors, $pipes, null, ['MYSQL_PWD' => $pass]);
if (!is_resource($proc)) return false;
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return proc_close($proc) === 0;
}
/** ‏حالة المفتاح بلا كشف قيمته. */
function keyState(array $env, string $name): string
{
if (!array_key_exists($name, $env)) return 'مفقود';
return envIsPlaceholder($env[$name]) ? 'نائب' : 'مضبوط';
}
/** ‏بصمة قصيرة تكفي للتمييز ولا تكفي للاستعمال. */
function keyFingerprint(array $env, string $name): string
{
$v = $env[$name] ?? '';
return $v === '' ? '—' : substr(hash('sha256', $v), 0, 8);
}
$csrf = $_SESSION['csrf'] ?? '';
?>
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>مفاتيح البيئة — سيرو</title>
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background:#12151a; color:#e6e9ef; margin:0; padding:2rem 1rem; }
.wrap { max-width: 940px; margin: 0 auto; }
h1 { font-size:1.4rem; margin:0 0 .3rem; }
.sub { color:#8b93a7; font-size:.85rem; margin-bottom:1.5rem; }
.card { background:#1a1f27; border:1px solid #2a3140; border-radius:12px;
padding:1.1rem 1.25rem; margin-bottom:1.25rem; }
.card h2 { font-size:1rem; margin:0 0 .25rem; }
.card .hint { color:#8b93a7; font-size:.8rem; margin:0 0 .9rem; line-height:1.6; }
table { width:100%; border-collapse:collapse; }
td, th { padding:.5rem .4rem; text-align:right; border-bottom:1px solid #232a35;
font-size:.88rem; vertical-align:middle; }
th { color:#8b93a7; font-weight:600; font-size:.78rem; }
code { font-family: ui-monospace, monospace; font-size:.82rem; }
.state { font-size:.75rem; padding:.15rem .5rem; border-radius:999px; }
.ok { background:#12341f; color:#5ed68b; }
.miss { background:#3a1f1f; color:#ff8b8b; }
.ph { background:#3a3320; color:#ffc861; }
button { background:#2d6cdf; color:#fff; border:0; border-radius:8px;
padding:.4rem .9rem; font-size:.82rem; cursor:pointer; font-family:inherit; }
button:hover { background:#3d7cef; }
button.warn { background:#8a3a2a; }
button.warn:hover { background:#a1462f; }
input[type=text], input[type=password] { background:#12151a; border:1px solid #2a3140;
color:#e6e9ef; border-radius:8px; padding:.45rem .6rem; font-family:inherit;
font-size:.85rem; width:100%; box-sizing:border-box; }
.frozen { opacity:.72; }
.banner { border-radius:10px; padding:.75rem 1rem; margin-bottom:1.25rem; font-size:.88rem; }
.banner.err { background:#3a1f1f; border:1px solid #6b3030; }
.banner.ok { background:#12341f; border:1px solid #2f6b45; }
.banner.danger { background:#33221a; border:1px solid #7a4426; line-height:1.7; }
form.inline { display:inline; }
.row-actions { display:flex; gap:.5rem; align-items:center; }
</style>
</head>
<body>
<div class="wrap">
<?php if (!$authed): ?>
<h1>مفاتيح البيئة</h1>
<p class="sub">وصول محلي فقط عبر نفق SSH.</p>
<?php if ($error): ?><div class="banner err"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="card">
<form method="post">
<input type="hidden" name="action" value="login">
<p class="hint">عبارة مرور اللوحة</p>
<input type="password" name="passphrase" autofocus autocomplete="off">
<p></p>
<button type="submit">دخول</button>
</form>
</div>
<?php else: ?>
<h1>مفاتيح البيئة</h1>
<p class="sub">
<code><?= htmlspecialchars($envFile) ?></code> ·
<a href="?logout=1" style="color:#8b93a7">خروج</a>
</p>
<?php if ($notice): ?><div class="banner ok"><?= htmlspecialchars($notice) ?></div><?php endif; ?>
<?php if ($error): ?><div class="banner err"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="banner danger">
<strong>بعد أي تغيير</strong> — أعد تشغيل الحاويات لتقرأ الجديد:
<code>docker compose restart php php_food php_transit</code><br>
وتذكّر أن <code>WEBHOOK_SECRET_KEY</code> له نظير في تطبيق
<code>sms_relay</code> يحتاج إعادة بناء، و<code>S2S_SHARED_KEY</code>
و<code>INTERNAL_SOCKET_KEY</code> لهما نظائر في خوادم السوكيت والدفع.
</div>
<!-- ── أسرار داخلية ── -->
<div class="card">
<h2>أسرار داخلية</h2>
<p class="hint">طرفاها كلاهما لنا. التدوير آمن — يكفي إعادة تشغيل ما يحملها.</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th><th></th></tr>
<?php foreach (ENV_GENERATED as $name => $bytes):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
<td>
<form method="post" class="inline">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit"><?= $s === 'مضبوط' ? 'تدوير' : 'توليد' ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── مرتبطة بالجلسات ── -->
<div class="card">
<h2>مرتبطة بالجلسات</h2>
<p class="hint">
تدويرها يُخرج <strong>كل</strong> السائقين والركّاب من حساباتهم ويطالبهم
بتسجيل دخول جديد. ليس عطلاً، لكنه ليس شيئاً يُفعل وقت الذروة.
</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th><th></th></tr>
<?php foreach (ENV_SESSION_SCOPED as $name => $bytes):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
<td>
<form method="post" class="inline"
onsubmit="return confirm('سيُخرج هذا كل المستخدمين من حساباتهم. متابعة؟')">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit" class="warn">تدوير</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── قواعد البيانات ── -->
<div class="card">
<h2>كلمات مرور قواعد البيانات</h2>
<p class="hint">
التدوير يجري بالترتيب الصحيح: <code>ALTER USER</code> ثم تحقّق باتصال
فعلي ثم كتابة <code>.env</code> — وإن فشلت أي خطوة تُستعاد الكلمة
القديمة فوراً. <strong>لا مساس بالبيانات ولا إعادة بناء</strong>؛ هذا
تغيير في <code>mysql.user</code> لا غير.
</p>
<table>
<tr><th>المفتاح</th><th>المستخدم</th><th>القاعدة</th><th>الحالة</th><th></th></tr>
<?php foreach (ENV_DB_CREDENTIALS as $name => $map):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><code><?= htmlspecialchars($env[$map['user']] ?? '—') ?></code></td>
<td><code><?= htmlspecialchars($env[$map['db']] ?? '—') ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td>
<?php if ($s === 'مضبوط'): ?>
<form method="post" class="inline"
onsubmit="return confirm('تدوير كلمة مرور القاعدة. أعد تشغيل حاويات php فوراً بعدها. متابعة؟')">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate_db">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit" class="warn">تدوير</button>
</form>
<?php else: ?><span style="color:#8b93a7">—</span><?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── طرف ثالث ── -->
<div class="card">
<h2>مفاتيح طرف ثالث</h2>
<p class="hint">لا تُولَّد — تُنسخ من لوحة المزوّد وتُلصق هنا.</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th style="width:45%">قيمة جديدة</th><th></th></tr>
<?php foreach (ENV_EXTERNAL as $name):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<form method="post">
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td>
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="set">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<input type="text" name="value" autocomplete="off" placeholder="اتركه فارغاً لعدم التغيير">
</td>
<td><button type="submit">حفظ</button></td>
</form>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── المجمّدة ── -->
<div class="card frozen">
<h2>⛔ مفاتيح مجمّدة — للعلم فقط</h2>
<p class="hint">
بيانات مخزَّنة في القاعدة مشتقّة من هذه المفاتيح: أسماء الركّاب
وهواتفهم ولوحات السيارات مشفّرة بـ <code>ENC_KEY</code>، وأعمدة
<code>*_bidx</code> محسوبة بـ <code>BLIND_INDEX_PEPPER</code>.
تغيير أيٍّ منها <strong>لا يعيد تشفير شيء</strong> — يجعل كل صف قديم
غير قابل للقراءة إلى الأبد، ولا نسخة احتياطية تنفع لأنها هي نفسها
مشفّرة بالمفتاح القديم. تدويرها ترحيلُ بيانات لا تغييرُ إعداد،
ولذلك لا يوجد لها زر هنا ولا مسار يكتبها في هذا الملف.
</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th></tr>
<?php foreach (ENV_FROZEN as $name):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<p class="sub">سجلّ التغييرات: <code>backend/tools/panel/panel_audit.log</code></p>
<?php endif; ?>
</div>
</body>
</html>
+15 -3
View File
@@ -82,13 +82,25 @@ DEX=/usr/bin/docker compose -f $SIRO/docker-compose.yml exec -T php php
*/1 * * * * $DEX /var/www/backend/bot/cron_sms_requests.php >> /var/log/sms_requests.log 2>&1 */1 * * * * $DEX /var/www/backend/bot/cron_sms_requests.php >> /var/log/sms_requests.log 2>&1
# ============================================================ # ============================================================
# التأمين للسائق # التزامات السائق (تأمين، وقود، صيانة)
# ============================================================ # ============================================================
# يقيّد قسط كل وثيقة نشطة بلغت دورتها. لا يخصم من رصيد أحد — الرصيد # مُصدِر الاستحقاق: يقيّد استحقاق كل التزام دوري بلغت دورته في
# في payment_server، وهذا دفتر استحقاق تقرأه التسوية. # obligation_ledger. لا يخصم من رصيد أحد — الرصيد في payment_server.
# مرة يومياً بعد منتصف الليل: القيد اليومي مرتبط بالتاريخ لا بالساعة. # مرة يومياً بعد منتصف الليل: القيد اليومي مرتبط بالتاريخ لا بالساعة.
15 0 * * * $DEX /var/www/backend/bot/cron_insurance_premiums.php >> /var/log/insurance.log 2>&1 15 0 * * * $DEX /var/www/backend/bot/cron_insurance_premiums.php >> /var/log/insurance.log 2>&1
# محرك التسوية: الجهة الوحيدة التي تخصم فعلياً من محفظة السائق.
#
# لماذا 23:30 لا بعد المُصدِر مباشرة؟
# لأن السقف اليومي نسبة من أرباح *اليوم*، وهي تُقرأ من صفوف محفظة
# السائق المؤرخة بتاريخ اليوم. تشغيله الساعة 00:45 يعني قراءة يومٍ
# عمره خمس وأربعون دقيقة — أرباحه صفر، فالسقف صفر، فلا يُخصم شيء
# أبداً. المحرك كان سيعمل كل ليلة بلا خطأ واحد وبلا تحصيل قرش.
#
# 23:30 تقرأ يوم عمل مكتملاً تقريباً. النصف ساعة المتبقية ليست ضياعاً:
# ما لم يُحصَّل يبقى في الدفتر ويُلاحَق غداً.
30 23 * * * $DEX /var/www/backend/bot/cron_obligation_settlement.php >> /var/log/obligations.log 2>&1
# ============================================================ # ============================================================
# الإشعارات والتسويق # الإشعارات والتسويق
# ============================================================ # ============================================================
@@ -0,0 +1,271 @@
# سيرو — سجلّ الإضافات وسياساتها
> **ما هذا الملف؟** شرحٌ بلغة غير تقنية لكل ميزة نضيفها: ما هي، لماذا أضفناها، كيف تعمل، وما قواعدها الرقمية.
>
> **لمن؟** للمالك ليتابع بلا قراءة كود، ولخدمة العملاء لتجيب السائق والراكب، ولعرضه على المستثمرين.
>
> **قاعدة التحديث:** كل ميزة جديدة تُضاف هنا فور إنجازها. الملف مرتّب من الأحدث إلى الأقدم.
>
> آخر تحديث: 2026-08-10 · المصدر: [دراسة الفرص](../04_features/SIRO_OPPORTUNITY_STUDY_AR.md)
---
# شاشة أرباح السائق (جديدة)
### ما هي
شاشة واحدة تجيب السائق عن: كم كسبت اليوم؟ كم اقتُطع مني ولماذا؟ وكم أخذت من **كل رحلة** بالتفصيل؟
### لماذا أعدنا بناءها
ثلاثة أسباب، أولها الأخطر:
**١) كان فشل الشبكة يظهر للسائق كـ«دخلك صفر».** عند تعذّر التحميل كان التطبيق يولّد سبعة أيام بأصفار ويعرضها كأنها أرباحه الحقيقية. السائق يرى دخل أسبوعه وقد تبخّر بسبب انقطاع إنترنت لحظي. لا يُقاس هذا بالجهد بل بالثقة.
**٢) الأرقام كانت لا تتفق.** شاشة الرصيد تُحدَّث فوراً، وشاشة الإحصائيات كانت مخزّنة **٣ ساعات** بلا أي إشارة إلى قِدَمها. السائق يفتح شاشتين فيجد رقمين مختلفين، فلا يثق بأيّهما.
**٣) لم يكن هناك تفصيل للرحلة.** السؤال الأول عند كل سائق في العالم — «لماذا أخذت هذا المبلغ من هذه الرحلة؟» — لم يكن له جواب في التطبيق. وهذا مصدر أغلب شكاوى «الحساب غلط».
### كيف تعمل الآن
شاشة **واحدة** اسمها «أرباحي» حلّت محل شاشتين منفصلتين (الأرباح والإحصائيات). الترتيب فيها مقصود: **المال أولاً، ثم التقدّم**.
| الترتيب | القسم |
|---|---|
| ١ | أرباح اليوم + تركيبة المبلغ |
| ٢ | الاقتطاعات (تأمين، وقود) |
| ٣ | آخر ٧ أيام — رسم بياني **يُنقر** لعرض رحلات أي يوم |
| ٤ | هذا الشهر — الصافي، معدّل الساعة، معدّل اليوم |
| ٥ | الرحلات بتفصيل كل واحدة |
| ٦ | تقدّمي: هدف اليوم، المستوى، سلوك القيادة، الإنجازات |
كان التلعيب والمستويات في شاشة منفصلة تسبق الأرباح، فيمرّر السائق ثلاث شاشات ليصل إلى ما فتح التطبيق من أجله.
| كان | صار |
|---|---|
| شاشتان بأرقام لا تتفق | شاشة واحدة بمصدر واحد |
| أربعة نداءات تصل في لحظات مختلفة | نداء واحد بختم زمني واحد |
| فشل التحميل = أصفار | «تعذّر التحميل» + آخر رقم صادق معلَّم بوقته |
| كاش ٣ ساعات صامت | دقيقتان، وأي رقم قديم يظهر بلافتة «هذه أرقام قبل كذا» |
| يبدأ بتقرير شهري | يبدأ بـ**أرباح اليوم** |
| الصافي فقط | دفع الراكب ← عمولة المنصة ← صافيك |
| لا شيء عن الاقتطاعات | التأمين والوقود ظاهران بجانب الأرباح |
**تعريف مهم للجميع:** «أرباحي» ≠ «رصيدي». الأول عن العمل (الرحلات)، والثاني عن المال المتاح بعد الشحن والسحب والاقتطاعات. خلطهما هو ما أنتج الرقمين المتناقضين، وقد فُصلا الآن: الأرباح في شاشة الأرباح، والرصيد في شاشة المحفظة.
### قرارات منتج
- **العمولة تُعرض صراحةً** لا تُطرح بصمت. من يرى التركيبة يفهم، ومن يرى الصافي وحده يخمّن ويشكّ.
- **إعفاء العمولة ٠٪** صار مرئياً كوسم على الرحلة. كنا ندفع ثمن هذه الهدية ولا نحصد أثرها لأن السائق لا يراها.
- **المقارنة بالذات لا بالآخرين**: «أعلى من أسبوعك الماضي بـ١٢٪». ترتيب السائقين يُحبط أكثر من يحتاج التحفيز — فهو من سيقع في أسفله.
- **الاقتطاعات في شاشة الأرباح إلزامياً.** خصمٌ لا يظهر بجانب الأرباح التي اقتُطع منها يُقرأ سرقةً مهما كان مشروعاً. هذا شرط قبل تشغيل محفظة الوقود لا بعده.
### خطآن حسابيان صُحّحا
- كانت الأرباح تُحسب على حالة `Finished` وحدها، والحالة مكتوبة في قاعدة البيانات بحرفين مختلفين تاريخياً — أي أن **نحو نصف رحلات السائق كان يسقط من أرباحه المعروضة**.
- ساعات العمل كانت تُحسب بطريقة تجعل رحلة تبدأ ١١:٤٠ ليلاً وتنتهي ١٢:١٠ تُحسب بـ**سالب** ٢٣ ساعة ونصف، فتأكل ساعات يوم كامل.
### لخدمة العملاء
**«أرباحي اختفت / صارت صفراً»** — لم يعد ممكناً بعد هذا التحديث. إن ظهرت رسالة «تعذّر تحميل أرباحك» فهي مشكلة اتصال فقط، والشاشة تقول ذلك صراحةً: أرباحك محفوظة ولم يضع منها شيء.
**«الرقم في شاشة الأرباح غير الرقم في المحفظة»** — طبيعي ومقصود. الأرباح = ما كسبته من الرحلات. الرصيد = المتاح فعلاً بعد الشحن والسحب والاقتطاعات.
**«لماذا أخذت هذا المبلغ من الرحلة؟»** — يفتح السائق الرحلة في شاشة أرباحي فيرى: ما دفعه الراكب، المكافأة إن وُجدت، عمولة المنصة، ثم صافيه.
**«خُصم مني ولا أعرف لماذا»** — بطاقة «الاقتطاعات» في شاشة الأرباح تعرض كل التزام باسمه والمتبقي عليه.
---
# محور اقتصاد السائق
> **الفكرة الجامعة:** مشكلة السائق في أسواقنا ليست نسبة العمولة، بل **رأس المال**: الوقود يومياً، والصيانة فجأة، والسيارة نفسها. من يحلّ له هذه المشكلة يربطه بالمنصة لسنوات — وهذا أعمق خندق تنافسي متاح لنا.
>
> بنينا هذا المحور على ثلاث مراحل: محرك عام يدير أي التزام مالي على السائق، ثم بوابة تحمي المنصة من الإقراض بلا استرداد، ثم أول منتج فعلي فوقهما: محفظة الوقود.
---
## ١) محرك الالتزامات — الأساس المشترك
### ما هو
نظام واحد يدير أي مبلغ مستحق على السائق للمنصة أو لشريك: قسط تأمين، دَين وقود، قسط صيانة، وقرض مركبة مستقبلاً.
### لماذا بنيناه
كنا أمام خيارين: بناء نظام تحصيل منفصل لكل منتج، أو نظام واحد يخدمها جميعاً. اخترنا الثاني لسببين:
**الأول — سبب مباشر:** التأمين كان يسجّل الأقساط على السائقين منذ إطلاقه، **ولم يكن هناك ما يحصّلها**. الأقساط تتراكم على الورق فقط. اكتُشف هذا عند التخطيط لمحفظة الوقود.
**الثاني — سبب استراتيجي:** كل منتج مالي جديد كان سيحتاج نسخة من أخطر منطق في المنصة، وهو الذي يلمس مال السائق. أربع نسخ = أربعة أضعاف احتمال الخطأ.
### كيف يعمل — بثلاث خطوات
1. **التسجيل:** المنتج يسجّل «على السائق كذا» (قسط اليوم، قيمة الوقود الذي أخذه).
2. **الدفتر:** كل هذه المبالغ تدخل سجلاً واحداً دائماً، لا يُمحى منه شيء.
3. **التحصيل:** برنامج واحد يعمل كل ليلة الساعة ١١:٣٠ مساءً، يمرّ على الدفتر ويخصم من أرباح السائق.
**نقطة مهمة:** لا يوجد في المنصة كلها إلا جهة واحدة تخصم من رصيد السائق. أي منتج جديد يسجّل مبلغاً فقط ولا يلمس المال.
### السياسات الرقمية
| القاعدة | القيمة | لماذا |
|---|---|---|
| سقف الخصم اليومي | نسبة من **أرباح اليوم** لا مبلغ ثابت | حتى لا يجد السائق محفظته صفراً في يوم ضعيف |
| أرضية الرصيد | لا يُدفع السائق إلى السالب أبداً | الرصيد السالب يوقفه عن العمل، فيتعذّر السداد أصلاً |
| المتبقي | يُرحَّل لليوم التالي، **لا يُسقَط** | الدَّين لا يضيع، لكنه لا يُجبى بالقوة |
| ترتيب الأولوية | التأمين ← الوقود ← الصيانة | انقطاع التأمين يُفقد السائق تغطيته؛ تأخّر قسط صيانة يوماً لا يضرّ أحداً |
| السقف مشترك | ثلاثة منتجات لا تأخذ ثلاثة أسقف | وإلا اقتُطع ٧٥٪ من يوم السائق |
### لخدمة العملاء
**«لماذا خُصم مني مبلغ اليوم؟»** — عليك التزام مسجّل (تأمين أو وقود). الخصم نسبة من أرباح اليوم لا مبلغ ثابت. التفاصيل كاملة في شاشة المنتج داخل التطبيق.
**«خُصم مني أقل من قيمة القسط»** — هذا مقصود. النظام لا يأخذ أكثر من النسبة المسموحة من دخل اليوم. الباقي يُلاحَق غداً بلا أي غرامة تأخير.
**«لم يُخصم مني شيء اليوم»** — لم تدخل أرباح إلى محفظتك اليوم، أو دخلت بمبلغ صغير. لا شيء مطلوب من السائق.
**«ألغيت اشتراكي، هل يسقط ما عليّ؟»** — لا. الإلغاء يوقف الأقساط **القادمة** ولا يمسّ الماضي. أيام التغطية التي استفاد منها السائق مستحقة.
---
## ٢) بوابة الائتمان — من يستحق أن نموّله
### ما هي
شرط إضافي قبل منح السائق أي التزام مالي: **يجب أن تمرّ أرباحه بمحفظة سيرو**.
### لماذا أضفناها
سقف الخصم نسبة من الأرباح المارّة بالمحفظة. سائق يحصّل نقداً ولا يمرّ ماله بالمحفظة يظهر بأرباح صفر كل يوم — فلا يُخصم منه شيء **أبداً**.
هذا محتمَل في التأمين (قسط صغير، خسارة محدودة). وهو غير محتمَل في الوقود: هناك نسلّم السائق قيمةً فعلية في خزّان سيارته مقابل وعد بالسداد من قناة لا يمرّ بها ماله. **هذه هبة لا ائتمان.**
### كيف تعمل
قبل تفعيل أي منتج، يقيس النظام كم **يوماً** دخل فيه مالٌ إلى محفظة السائق خلال آخر ٣٠ يوماً.
**المقياس أيام لا مبلغ** — والسبب أن السؤال ليس «كم يكسب؟» بل «هل قناة التحصيل تعمل؟». دخل واحد كبير قد يكون استرداداً أو تسوية حادثة؛ اثنا عشر يوماً متفرّقاً يعني قناة حيّة.
### السياسات الرقمية
| المنتج | أيام الدخل المطلوبة (من ٣٠) | لماذا هذه العتبة |
|---|---|---|
| التأمين | ٨ أيام | مشروط أصلاً بـ٢٠٠ رحلة وتقييم ٤.٥ — التشديد فوقها يفرغه من غرضه |
| محفظة الوقود | ١٢ يوماً | قيمة فعلية تُسلَّم، والاسترداد الوحيد من أرباح تمرّ بالمحفظة |
**قواعد إضافية:**
- تعذّر التحقق = **رفض** لا قبول. تأجيل منح ائتمان أهون من منحه لمن لا يُسترد منه.
- الشرط على الاشتراكات **الجديدة** فقط. لا يُسحب التزام قائم من سائق بسبب شرط سُنّ بعده.
- العتبات قابلة للتغيير بتعديل رقم في قاعدة البيانات، بلا إصدار جديد من التطبيق.
### لخدمة العملاء
**«لماذا رُفض طلبي رغم أن رحلاتي كافية؟»** — الشرط ليس عدد الرحلات وحده، بل أن تمرّ الأرباح بالمحفظة. الرسالة في التطبيق تذكر العدد الحالي والمطلوب بالضبط.
**«كيف أستوفي الشرط؟»** — بالعمل عبر المحفظة بدل النقد. الشرط يُعاد قياسه تلقائياً كل مرة، فلا حاجة لطلب مراجعة.
**«رُفض طلبي برسالة تعذّر التحقق»** — عطل مؤقت. يُعاد المحاولة بعد قليل. ليس رفضاً نهائياً.
---
## ٣) محفظة الوقود — أول منتج فعلي
### ما هي
السائق يتزوّد وقوداً من محطة شريكة **بلا نقد**، والقيمة تُقيَّد ديناً عليه يُخصم من أرباحه تدريجياً.
### لماذا
الوقود هو مشكلة السائق اليومية الأولى، ونقص السيولة صباحاً يمنعه من العمل. حلّ هذه المشكلة أثره أقوى من أي زيادة في نسبته — لأننا نعالج **توقيت** المال لا مقداره. وهو أيضاً ما يجعل مغادرته إلى منافس قراراً مكلفاً.
### كيف تعمل — دورة كاملة
1. السائق يفعّل المحفظة مرة واحدة (بشرط الأهلية وبوابة الائتمان).
2. عند الحاجة، يطلب من التطبيق قسيمة بمبلغ محدّد.
3. يحصل على **رمز من ١٠ أرقام** صالح ٤٥ دقيقة.
4. يعطي الرمز لعامل المحطة، والمحطة تؤكّد المبلغ المصروف فعلاً.
5. عند التأكيد — **وليس قبله** — يُسجَّل الدَّين على السائق.
6. محرك التسوية يحصّله ليلاً بنسبة ١٥٪ من أرباح اليوم.
7. كلما سدّد، عاد سقفه متاحاً — كبطاقة ائتمان متجدّدة.
**لماذا قسيمة مسبقة لا خصم عند المضخة؟** لأن عامل المحطة لا يملك وسيلة للتحقق من هوية السائق ولا من سقفه. القسيمة تنقل القرار إلى التطبيق حيث الهوية مؤكَّدة والسقف معلوم، ولا يبقى على المحطة إلا تأكيد رقم.
### السياسات الرقمية
| القاعدة | القيمة | لماذا |
|---|---|---|
| السقف المبدئي | ٣٠ (بعملة البلد) | موحّد للجميع مبدئياً؛ التدرّج بحسب السداد قرار لاحق يحتاج بيانات |
| نسبة الخصم اليومي | ١٥٪ من أرباح اليوم | أقل من التأمين (٢٥٪) لأن دَين الوقود أكبر، وخنق السائق يعيده محتاجاً للدَّين |
| صلاحية القسيمة | ٤٥ دقيقة | القسيمة تحجز من السقف؛ المفتوحة إلى الأبد تمنع صاحبها من التزوّد |
| قسائم مفتوحة | واحدة فقط في كل وقت | رمزان في يد السائق = صرف المبلغ الخطأ |
| المبلغ المصروف | لا يتجاوز المأذون | تجاوزه دَين لم يوافق عليه السائق |
| المصروف أقل من المأذون | مسموح وطبيعي | يطلب ٢٠ ويتزوّد بـ١٥ — الدَّين على ١٥ |
**حماية الأموال المدمجة:**
- كل محطة لها مفتاح خاص مخزَّن مشفَّراً. تسريب مفتاح محطة يكشف تلك المحطة وحدها ويُبطَل فوراً.
- صرف القسيمة مرة واحدة قطعاً. الضغط المكرّر على جهاز المحطة أو محاولة صرف مزدوج لا تُنتج ديناً مضاعفاً.
- السائق لا يستطيع تأكيد الصرف بنفسه — هو الطرف المستفيد، وتأكيده يعني أن ينفي ما استلمه.
### لخدمة العملاء
**«الرمز لا يعمل عند المحطة»** — تحقّق من ثلاثة: هل مضت ٤٥ دقيقة؟ هل صُرف سابقاً؟ هل المحطة ضمن الشبكة الشريكة؟ رسالة جهاز المحطة تحدّد السبب بالضبط.
**«ألغيت القسيمة، متى يعود سقفي؟»** — فوراً.
**«سقفي المتاح أقل من ٣٠ رغم أني لم أستخدم شيئاً»** — إمّا عليك دَين سابق لم يُسدَّد بالكامل، أو لديك قسيمة سارية تحجز مبلغاً. الشاشة تعرض الأربعة أرقام منفصلة: السقف، المستحق، المحجوز، المتاح.
**«صُرف مبلغ أقل مما طلبت»** — طبيعي. الدَّين على ما صُرف فعلاً لا ما طُلب.
**«لماذا سقف الوقود ١٥٪ والتأمين ٢٥٪؟»** — دَين الوقود أكبر من قسط يومي. اقتطاع ربع الدخل لسداده يترك السائق بلا ما يكفي ليعمل غداً، فيعود محتاجاً وقوداً بالدَّين — وتلك حلقة لا تُغلق.
---
## ٤) التأمين على السائق (سابق — أُعيد ربطه)
### ما هو
تغطية تأمينية من شريك مرخّص، بقسط يومي أو شهري يُخصم من أرباح السائق.
### الحالة
كان مبنياً وتُسجَّل أقساطه، **بلا أي تحصيل**. بعد بناء محرك الالتزامات صار يعمل ضمنه بالكامل: الأقساط المتراكمة سابقاً صارت قابلة للتحصيل، والاشتراكات الجديدة تدخل النظام تلقائياً.
### السياسات الرقمية
| القاعدة | القيمة |
|---|---|
| الحد الأدنى للرحلات المكتملة | ٢٠٠ |
| الحد الأدنى للتقييم | ٤.٥ |
| الحد الأدنى لعمر الحساب | ٣٠ يوماً |
| أيام الدخل بالمحفظة | ٨ من ٣٠ |
| نسبة الخصم اليومي | ٢٥٪ من أرباح اليوم |
| وثيقة نشطة لكل سائق | واحدة |
**قرار المنتج:** التأمين ليس خدمة تُمنح لكل من سجّل، بل **مكافأة استمرار**. منحه لمن قد يختفي بعد أسبوع يحوّله من أداة ولاء إلى نزيف. والشروط معروضة في التطبيق لمن لم يستوفها بعد — مع تقدّمه نحوها — لأن الشرط الظاهر هدفٌ يُسعى إليه، والمخفي مجرد رفض.
### لخدمة العملاء
**«تبديل الخطة»** — لا يتم بصمت. إلغاء صريح ثم اشتراك جديد، ليبقى في السجل أثر لكل تغيير مالي.
**«ألغيت وثيقتي وبقي عليّ مبلغ»** — نعم، أقساط أيام التغطية التي مضت. الإلغاء لا يمحو الماضي.
---
# ما قبل هذا المحور
بنود أخرى من دراسة الفرص أُنجزت أو بدأت قبل هذا العمل، ولم تُوثَّق هنا بعد بالتفصيل نفسه:
- **الحجز المسبق** — يعمل
- **الطلب عبر SMS** (للمناطق بلا إنترنت) — البنية قائمة
- **محرك الإسناد بالدفعات** — مبني ويمكن تشغيله بمفتاح إعداد
- **تعويض السائق عن عدم حضور الراكب** — يعمل
- **زر السلامة (SOS)** — مبني، وتنقصه غرفة عمليات تستقبله
- **مواصلاتي (النقل الجامعي والمؤسسي)** — الكود شبه مكتمل، وفيه ثغرات معروفة
*تُوثَّق تباعاً بالتنسيق نفسه.*
---
# لم يُنجز بعد من دراسة الفرص
سيرو للأعمال · الطرود والمشاوير · اشتراك الراكب · إغلاق التسرّب خارج المنصة · طبقة المعالم الشعبية · الصيانة بالتقسيط · تمويل المركبات
**تنبيه قائم:** أي تمويل حقيقي يمرّ عبر شريك مرخّص، لا من ميزانية سيرو مباشرة — هذا الخط يقترب من التمويل المنظَّم قانونياً.
---
# ملاحظات للعرض على المستثمرين
**ما يميّز هذا المحور:** المنافسون يتنافسون على نسبة العمولة — وهي حرب أسعار تنتهي بخسارة الجميع. نحن نعالج **رأس مال السائق** ومشكلة توقيت السيولة، وهي مشكلة لا يحلّها خفض العمولة إطلاقاً.
**الأصل الحقيقي المتكوّن:** بيانات دخل السائق المؤكَّدة والمارّة بمحفظتنا. هي التي تجعلنا أقدر من أي بنك على تقييم جدارته الائتمانية — وهي المنتج الذي يُعرض على شريك التمويل لاحقاً. القرض ليس الهدف؛ البيانات هي.
**الأثر على البقاء:** السائق الذي يعتمد على محفظة الوقود يومياً ينتقل من «مورّد» إلى «شريك بحساب مفتوح». مغادرته لمنافس تعني تسوية حساب، لا مجرد حذف تطبيق.
**صدقٌ في العرض:** المحفظة أُطلقت بمنتج واحد (الوقود) وسقف محافظ (٣٠)، لأن التوسّع في الائتمان قبل رؤية سلوك السداد الفعلي مخاطرة لا داعي لها. التوسّع قرار بيانات لا طموح.
@@ -0,0 +1,178 @@
# محرك الالتزامات العام — تصميم وتنفيذ
> بند 3.4 من [دراسة الفرص](SIRO_OPPORTUNITY_STUDY_AR.md) — اقتصاد السائق: الوقود، الصيانة، والتمويل.
> تاريخ التنفيذ: 2026-08-08. الحالة: الخطوتان ١ و٢ منفَّذتان (الجداول + التسوية).
---
## لماذا الآن — ثغرة قائمة لا تحسين مستقبلي
`cron_insurance_premiums.php` يقيّد أقساط التأمين منذ إطلاقه، وتعليقه يقول إن الدفتر «تقرأه التسوية». **لم تكن التسوية موجودة.** لا مرجع واحد لجدول `insurance_premium_ledger` في المشروع كله خارج الكرون والـmigration.
النتيجة العملية: كل قسط تأمين قُيّد حتى اليوم بقي بحالة `pending`، ولم يُحصَّل منه شيء.
الخيار كان: كتابة تسوية خاصة بالتأمين، أو تعميم النموذج مرة واحدة. والوقود والصيانة والتمويل — البنود الثلاثة التالية في اقتصاد السائق — كلها نفس الشكل: التزام دوري أو مقسّط يُخصم من أرباح السائق. كتابة تسوية لكل منها تعني أربع نسخ من أخطر منطق في المنصة: الذي يلمس مال السائق.
---
## البنية
فصل ثلاث مسؤوليات كان الكود يخلطها:
| المسؤولية | الملف | ماذا يفعل |
|---|---|---|
| مُصدِر الاستحقاق | `bot/cron_insurance_premiums.php` وما يليه | يقيّد «على السائق كذا». لا يلمس مالاً |
| الدفتر الموحّد | `obligation_ledger` | سجل دائم واحد مهما كان المصدر |
| محرك التسوية | `bot/cron_obligation_settlement.php` | **الجهة الوحيدة** التي تخصم من الرصيد |
منتج جديد = صفّ في `obligation_products` + مُصدِر استحقاق صغير. لا يلمس التسوية ولا الرصيد إطلاقاً.
### الجداول
| الجديد | يعمّم |
|---|---|
| `obligation_products` | `insurance_plans` |
| `driver_obligations` | `driver_insurance_policies` |
| `obligation_ledger` | `insurance_premium_ledger` |
| `obligation_settlements` (قاعدة المحفظة) | — جديد كلياً |
ثلاثة فروق جوهرية عن نموذج التأمين:
**`kind`** يحدّد سلوك الاستحقاق: `recurring` يتكرّر بلا نهاية (تأمين)، `installment` له أصل ينتهي بسداده (صيانة، تمويل)، `drawdown` يُقيَّد عند السحب لا على جدول (وقود).
**`amount_collected` / `amount_remaining`** في الدفتر. القسط يُدفع كاملاً أو لا يُدفع، أما الوقود والصيانة فتحصيلهما جزئي بطبعه: قيدٌ بقيمة عشرة قد يُحصَّل على ثلاثة أيام. بلا هذين العمودين لا يمكن تمثيل ذلك إلا بتفتيت القيد، فيضيع أثر الاستحقاق الأصلي.
**`priority`** لترتيب المزاحمة: التأمين (١٠) قبل الوقود (٢٠) قبل الصيانة (٣٠). انقطاع التأمين يُلغي وثيقة ويفقد السائق تغطيته؛ تأخّر قسط صيانة يوماً لا يكلّف أحداً شيئاً.
---
## سقف الخصم اليومي — القرار الذي يقرّر نجاح الميزة
الخصم نسبة من **أرباح اليوم** لا مبلغ ثابت، بسقف على مستوى المنتج (`daily_cap_percent`، افتراضي ٢٥٪) وأرضية للرصيد (`OBLIGATION_MIN_BALANCE`، افتراضي صفر).
السبب: سائق عليه قسط تأمين وقرض صيانة في يوم ضعيف سيفتح التطبيق ويجد صفراً — ويهجر المنصة. المتبقي يُرحَّل، لا يُسقَط.
الأساس هو دخل اليوم لا الرصيد المتراكم: الخصم من رصيد قديم يفاجئ سائقاً لم يعمل اليوم، والخصم من دخل حاضر يبقى محسوساً كاقتطاع من كسبٍ وقع للتوّ.
**السقف مشترك بين المنتجات:** نقطة الخصم تطرح ما اقتُطع اليوم من كل المنتجات قبل حساب المتاح. بدون هذا الطرح تصير ثلاثة منتجات بسقف ٢٥٪ تأخذ ٧٥٪ من يوم السائق.
---
## بوابة الائتمان — لا التزام إلا لمن تمرّ أرباحه بالمحفظة
قرار المالك (2026-08-09). سببه المباشر أن سقف الخصم نسبة من الدخل المارّ بالمحفظة: **سائق الكاش الذي يحصّل نقداً سقفه صفر كل يوم، فلا يُحصَّل منه شيء أبداً.** منحه وقوداً أو صيانة بالدَّين تسليمُ قيمةٍ فعلية مقابل قناة سداد لا وجود لها — أي هبة لا ائتمان.
الشرط مخزَّن مع المنتج (`requires_wallet_income`, `min_wallet_days_30d`, `min_wallet_income_30d`) على غرار شروط الرحلات والتقييم القائمة، لأن العتبة تختلف بطبيعة المنتج.
**المقياس أيام لا مبلغ.** السؤال ليس «كم يكسب؟» بل «هل قناة التحصيل تعمل؟». دخل واحد كبير قد يكون استرداداً أو تسوية حادثة؛ اثنا عشر يوماً متفرّقاً قناةٌ حيّة.
**الفشل مغلق.** تعذُّر الوصول إلى سيرفر المحفظة يعني رفضاً لا قبولاً. عطلٌ شبكي عابر يؤجّل منح ائتمان، بينما القبول عند الشك يمنحه لمن قد لا يُسترد منه — والخطأ الثاني وحده يكلّف مالاً.
**الفحص في طبقتين.** نقطة الاستدعاء تفحص لتعرض للسائق سبباً مفهوماً وما ينقصه؛ و`obligationOpen` تفحص ثانيةً عند الكتابة كحارس أخير. النقطة قد تنسى، أو يُضاف منتج جديد بمسار جديد يُغفلها — والفحص عند الكتابة وحده هو الذي لا يمكن الالتفاف عليه بالسهو.
**التأمين يخضع للبوابة بعتبة متساهلة** (ثمانية أيام، بلا شرط مبلغ): القسط الذي تتحمّله الشركة ثم تسترده ائتمانٌ بالمعنى نفسه، لكن التأمين مشروط أصلاً بمئتي رحلة وتقييم جيد، وتشديد بوابة ثانية فوقها يفرغ المنتج من غرضه. تغيير العتبة صفٌّ واحد في `obligation_products`.
الشرط يمسّ الاشتراكات **الجديدة** وحدها. الالتزامات القائمة لا تُفحص ثانيةً: سحب تغطية من سائق مؤمَّن اليوم بسبب شرط سُنّ اليوم عقوبة بأثر رجعي.
---
## الحد بين الخادمين
الرصيد في `payment_server`، القيد في القاعدة الرئيسية. لا كتابة عابرة للحدود.
### لماذا نقطة خصم جديدة بدل `add_s2s_reward.php` بمبلغ سالب
**`add_s2s_reward.php` لا يضمن عدم التكرار.** لا مفتاح فريد على `driverWallet.paymentID`، ولا فحص تكرار فيها إلا لتحديات `daily_/weekly_`. تعليق `food/admin/courier_settlement.php` يقول إن `paymentID` «يمنع ازدواج الصرف عند إعادة المحاولة» — وهذا غير صحيح، لا شيء يفرضه.
هذا مقبول تقريباً للإيداعات (إيداع مكرّر يُسترد)، وغير مقبول إطلاقاً للخصومات: محرك التسوية يعيد المحاولة بطبعه.
ولا يمكن ببساطة إضافة مفتاح فريد على `driverWallet.paymentID`: الجدول يحمل سنوات من صفوف قد تحمل معرّفات مكرّرة أصلاً. فالضمان بُني خارجه، في `obligation_settlements`.
### المرجع مبنيّ على عدّاد المحاولات لا على التاريخ
`settlement_ref = obl_{ledgerId}_{attempts}`
`attempts` لا يزيد إلا بعد تحديث الدفتر بنجاح. فنداء نجح في المحفظة وضاع ردّه في الطريق يُعاد لاحقاً — ولو بعد أيام — بالمرجع نفسه، فتُرجع المحفظة خصمه الأول بدل تنفيذ خصم ثانٍ.
مرجعٌ مبنيّ على التاريخ (`obl_{id}_{YYYYMMDD}`، وهو ما كُتب أولاً) كان سيصل في اليوم التالي بمرجع جديد ويخصم المبلغ مرتين في هذه الحالة بالضبط.
---
## التوقيت
| الوقت | المهمة |
|---|---|
| 00:15 | مُصدِر الاستحقاق — يقيّد أقساط اليوم |
| 23:30 | محرك التسوية — يحصّل من أرباح اليوم |
التسوية **لا** تعمل بعد المُصدِر مباشرة. السقف نسبة من أرباح اليوم، وتشغيلها الساعة 00:45 يعني قراءة يومٍ عمره خمس وأربعون دقيقة: أرباحه صفر، فالسقف صفر، فلا يُخصم شيء أبداً. كان المحرك سيعمل كل ليلة بلا خطأ واحد وبلا تحصيل قرش.
23:30 تقرأ يوم عمل مكتملاً تقريباً. النصف ساعة المتبقية ليست ضياعاً: ما لم يُحصَّل يبقى في الدفتر ويُلاحَق غداً.
**لا خصم داخل `finish_ride_updates.php`.** المسار حرج والسائق ينتظر ردّه؛ نداء شبكي إلى سيرفر المحفظة داخله يعني تعليق شاشته على بطء طرف ثالث، وفشلاً في التسوية يظهر له كفشل في إنهاء رحلته.
---
## ما نُفِّذ
| الملف | الدور |
|---|---|
| `backend/migrations/2026_08_08_obligations_engine.sql` | الجداول الثلاثة + ترحيل التأمين |
| `backend/migrations/2026_08_09_obligation_credit_gate.sql` | أعمدة بوابة الائتمان |
| `payment_server/v2/main/ride/driverWallet/income_summary_s2s.php` | ملخّص الدخل المارّ بالمحفظة |
| `payment_server/migrations/2026_08_08_obligation_settlements.sql` | سجل الخصومات (ضمان عدم التكرار) |
| `payment_server/v2/main/ride/driverWallet/deduct_s2s_obligation.php` | نقطة الخصم — سقف + عدم تكرار |
| `backend/bot/cron_obligation_settlement.php` | محرك التسوية |
| `backend/obligations/functions.php` | فتح/إغلاق التزام وقراءة المستحق |
| `backend/bot/cron_insurance_premiums.php` | صار مُصدِر استحقاق عاماً |
| `backend/driver_assurance/{subscribe,cancel,get,plans}.php` | ربط بالدفتر الموحّد + بوابة الائتمان |
| `docker/crontab.production` | جدولة التسوية |
### الترحيل
جداول التأمين **تبقى كما هي** — لا تُحذف ولا تُعدَّل، وتصير شاهداً تاريخياً. بياناتها تُنقل إلى النموذج العام، والقيود المعلّقة (وهي مال حقيقي تراكم بلا تحصيل) تصير مرئية لمحرك التسوية.
القيود القديمة بحالة `failed` تعود `pending`: الفشل السابق لم يكن قراراً بل غياب محرك، وحجبها الآن يعني إسقاط مال مستحق فعلاً.
قسم الترحيل يعمل أكثر من مرة بلا ضرر — كل إدراج مشروط بعدم وجود ما يقابله.
**لماذا لم يكفِ الترحيل وحده:** `driver_assurance/subscribe.php` كان يكتب في جداوله الخاصة فقط. بقاؤه كذلك كان يعني أن كل اشتراك **جديد** لا يظهر في الدفتر الموحّد — أي لا يُقيَّد له قسط ولا يُحصَّل منه شيء. الترحيل يعالج الماضي؛ ربط `subscribe/cancel` يمنع تكرار الثغرة مستقبلاً.
---
## حدود معروفة — تُحسم قبل الخطوة ٤
**سائق الكاش محجوب لا محصَّل منه.** بوابة الائتمان تمنع منحه التزاماً أصلاً، وهذا هو القرار المتّخذ. لكنها تعالج الدخول لا الخروج: سائق كان دخله يمرّ بالمحفظة ثم تحوّل إلى الكاش بعد فتح التزامه يبقى التزامه قائماً بسقف صفر — يتراكم في الدفتر بلا تحصيل. لا يوجد بعد ما يرصد هذا التحوّل أو ينبّه عليه.
**تعارض قيد وحساب.** لو خُصم من المحفظة ولم يُحدَّث الدفتر (فشل قاعدة بيانات بعد نجاح النداء)، يُسجَّل السطر في `error_log` بصيغة صريحة ولا تُعاد المحاولة تلقائياً. المرجع الثابت يمنع الخصم المزدوج، لكن التباين بين الدفترين يحتاج عيناً بشرية. لا توجد بعد لوحة تعرض هذه الحالات.
**لم يُختبر مقابل قاعدة بيانات.** كل ملفات PHP اجتازت `php -l`، أما الـmigrations فلم تُنفَّذ — لا MySQL ولا Docker على جهاز التطوير هذا. يجب تشغيلها على بيئة اختبار قبل الإنتاج.
---
## محفظة الوقود (الخطوة ٣ — منفَّذة)
أول منتج من نوع `drawdown`: لا دورة فوترة، والاستحقاق يُقيَّد لحظة الصرف من المحطة.
| الملف | الدور |
|---|---|
| `backend/migrations/2026_08_10_fuel_wallet.sql` | `source_ref` + `credit_limit` + المحطات + القسائم + المنتج |
| `backend/fuel/functions.php` | حساب السقف وتوليد الرموز |
| `backend/fuel/{enroll,get,stations,request_voucher,cancel_voucher}.php` | مسار السائق |
| `backend/fuel/redeem.php` | مُصدِر الاستحقاق — تأكيد المحطة |
**تعديل على المخطّط اقتضاه المنتج:** المفتاح الفريد `(obligation_id, charge_date)` كان يمنع قيدين لالتزام واحد في يوم واحد — وهو المطلوب للتأمين، وكاسرٌ للوقود: سائق يتزوّد مرتين في يوم يُرفض قيده الثاني فيحصل على وقود بلا دَين. أُضيف `source_ref` إلى المفتاح، فارغاً للمنتجات الدورية (فيبقى ضمانها حرفياً) ومملوءاً برقم القسيمة للوقود. سلسلة فارغة لا `NULL`: MySQL يعتبر كل `NULL` مختلفاً، فعمود قابل للتفريغ كان سيلغي الحماية عن التأمين بصمت.
**`credit_limit` لا `principal_amount`:** الأصل ينفد بالسداد (قرض صيانة)، وسقف الوقود متجدّد — يُسدَّد فيعود متاحاً.
**السقف المتاح** = `credit_limit` − الدَّين القائم − القسائم السارية. حجز القسائم يُطرح كالدَّين تماماً: قسيمة قد تُصرف بعد لحظة، والسماح بإصدار قسائم تتجاوز السقف يعني سائقاً يتزوّد بضعف ما يستحق.
---
## الخطوات التالية
4. الصيانة بالتقسيط (`installment`) — المحرك يدعمها بالكامل، ينقصها منتج ومسار ورشة
5. مؤشر الجدارة الائتمانية — قراءة فقط، بلا التزام مالي، تمهيداً لشريك التمويل
6. رصد تحوّل السائق من المحفظة إلى الكاش بعد فتح التزامه (انظر الحدود المعروفة)
تحذير الدراسة قائم: أي تمويل حقيقي يمرّ عبر شريك مرخّص، لا من ميزانية سيرو.
@@ -0,0 +1,49 @@
-- ============================================================
-- سجل خصومات الالتزامات — الطرف المالي من محرك الالتزامات
--
-- ‏لماذا جدول جديد بدل إعادة استخدام `add_s2s_reward.php`؟
--
-- ‏لأن تلك النقطة لا تضمن عدم التكرار. لا مفتاح فريد على
-- ‏`driverWallet.paymentID`، ولا فحص تكرار فيها إلا لتحديات
-- ‏`daily_/weekly_`. تعليق `backend/food/admin/courier_settlement.php`
-- ‏يقول إن paymentID «يمنع ازدواج الصرف عند إعادة المحاولة» — وهذا غير
-- ‏صحيح، لا شيء في الكود ولا في المخطّط يفرضه.
--
-- ‏وهذا مقبول تقريباً للإيداعات (إيداع مكرّر خطأ يُسترد)، وغير مقبول
-- ‏إطلاقاً للخصومات: محرك التسوية يعيد المحاولة بطبعه، وخصم مكرّر من
-- ‏رصيد سائق هو أسوأ عطل ممكن في المنصة — يفقد ثقته ولا يعود.
--
-- ‏ولا يمكن ببساطة إضافة مفتاح فريد على `driverWallet.paymentID`: الجدول
-- ‏يحمل سنوات من صفوف قد تحمل معرّفات مكرّرة أصلاً، وإضافة القيد قد تفشل
-- ‏أو تكسر مسارات دفع قائمة. فالضمان يُبنى هنا، خارجه.
--
-- ‏العقد: صفّ واحد لكل `settlement_ref`. محاولة ثانية بنفس المرجع لا
-- ‏تخصم شيئاً وتُرجع نتيجة المحاولة الأولى كما هي.
-- ============================================================
CREATE TABLE IF NOT EXISTS `obligation_settlements` (
`id` INT NOT NULL AUTO_INCREMENT,
-- ‏المرجع الذي يبنيه محرك التسوية: obl_{ledgerId}_{attempts}
-- ‏مبنيّ على عدّاد المحاولات لا على التاريخ، لأن العدّاد لا يزيد إلا
-- ‏بعد تحديث دفتر الالتزامات بنجاح: نداء نجح هنا وضاع ردّه في الطريق
-- ‏يُعاد لاحقاً بالمرجع نفسه فيستردّ نتيجته الأولى. مرجعٌ مبنيّ على
-- ‏التاريخ كان سيصل غداً بمرجع جديد ويخصم المبلغ مرتين.
`settlement_ref` VARCHAR(120) NOT NULL,
`driver_id` VARCHAR(100) NOT NULL,
`product_code` VARCHAR(60) NOT NULL,
`requested_amount` DECIMAL(12,3) NOT NULL COMMENT 'ما طلبه المحرك',
`deducted_amount` DECIMAL(12,3) NOT NULL COMMENT 'ما خُصم فعلاً بعد السقف',
-- ‏لقطة الأساس الذي حُسب عليه السقف. بدونها لا يمكن لاحقاً شرح
-- ‏«لماذا خُصم ثلاثة لا خمسة؟» — والسائق سيسأل.
`day_earnings` DECIMAL(12,3) NOT NULL DEFAULT 0,
`cap_percent` DECIMAL(5,2) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_settlement_ref` (`settlement_ref`),
KEY `idx_driver_date` (`driver_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,198 @@
<?php
/**
* deduct_s2s_obligation.php — خصم التزام من محفظة السائق
* ─────────────────────────────────────────────────────────────
* ‏نقطة الخصم الوحيدة لمحرك الالتزامات (تأمين، وقود، صيانة، تمويل).
*
* ‏لماذا لا نستخدم add_s2s_reward.php بمبلغ سالب؟
*
* ١. ‏لا يضمن عدم التكرار. لا مفتاح فريد على paymentID ولا فحص إلا
* ‏لتحديات daily_/weekly_. إيداع مكرّر يُسترد، أما خصم مكرّر من
* ‏رصيد سائق فهو عطل لا يُصلحه اعتذار.
*
* ٢. ‏السقف اليومي يجب أن يُحسب حيث تعيش البيانات. أرباح اليوم في
* ‏هذه القاعدة لا في القاعدة الرئيسية، وحسابه هناك كان سيعني
* ‏نقل الرصيد عبر الشبكة ثم اتخاذ قرار مالي على لقطة قديمة.
*
* ‏العقد مع المحرك:
* ‏المدخل : settlement_ref, driverID, product_code, amount, cap_percent
* ‏المخرج : deducted (ما خُصم فعلاً — قد يكون أقل من المطلوب أو صفراً)
*
* ‏الخصم الجزئي ليس فشلاً بل السلوك الطبيعي: السقف يحمي السائق، والمتبقي
* ‏يُرحَّل ليوم أفضل. المحرك هو من يقرّر ماذا يفعل بالمتبقي.
*
* ‏إعادة الطلب بنفس settlement_ref آمنة دائماً: لا تخصم شيئاً وتُرجع
* ‏نتيجة المحاولة الأولى حرفياً. المحرك يبني المرجع على عدّاد محاولات
* ‏القيد لا على التاريخ، فنداء ضاع ردّه يُعاد بالمرجع نفسه مهما تأخّر.
*/
require_once __DIR__ . '/../../jwtconnect.php';
// ‏jwtconnect يقبل ستة مسارات مصادقة. الخصم من مال السائق يقبل واحداً:
// ‏نداء خادم-لخادم. لا JWT سائق — لا معنى لأن يطلب سائق خصم نفسه.
$providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? '';
$expectedKey = getenv('S2S_SHARED_KEY') ?: '';
if (empty($expectedKey) || empty($providedKey) || !hash_equals($expectedKey, $providedKey)) {
http_response_code(401);
printFailure('Unauthorized: Invalid or missing X-S2S-Api-Key.');
exit;
}
$settlementRef = filterRequest('settlement_ref');
$driverID = filterRequest('driverID');
$productCode = filterRequest('product_code');
$amount = filterRequest('amount', 'numeric');
$capPercent = filterRequest('cap_percent', 'numeric');
if (empty($settlementRef) || empty($driverID) || empty($productCode) || $amount === null) {
printFailure('Missing required parameters: settlement_ref, driverID, product_code, amount');
exit;
}
$amount = round((float) $amount, 3);
$capPercent = $capPercent === null ? 25.0 : (float) $capPercent;
if ($amount <= 0) {
printFailure('amount must be greater than zero');
exit;
}
// ‏سقف بلا حدّ أعلى يعني تفريغ محفظة السائق في يوم واحد بضغطة إعداد
// ‏خاطئة. مئة بالمئة مسموحة كحدّ أقصى صريح لا كنتيجة خطأ مطبعي.
if ($capPercent < 0 || $capPercent > 100) {
printFailure('cap_percent must be between 0 and 100');
exit;
}
/**
* ‏يُرجع محاولة سابقة بنفس المرجع إن وُجدت.
*
* ‏هذه الدالة هي كل ضمان عدم التكرار: تُستدعى قبل أي عمل، وتُستدعى
* ‏ثانيةً عند خرق المفتاح الفريد — لأن بين الفحص والكتابة نافذة، ونسختان
* ‏من الكرون قد تعملان معاً.
*/
function obligationPriorAttempt(PDO $con, string $ref): ?array
{
$st = $con->prepare("
SELECT deducted_amount, requested_amount, day_earnings, cap_percent
FROM obligation_settlements WHERE settlement_ref = ? LIMIT 1
");
$st->execute([$ref]);
$row = $st->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
function obligationReplay(array $prior, string $ref): void
{
printSuccess([
'settlement_ref' => $ref,
'deducted' => (float) $prior['deducted_amount'],
'requested' => (float) $prior['requested_amount'],
'day_earnings' => (float) $prior['day_earnings'],
'cap_percent' => (float) $prior['cap_percent'],
'replayed' => true,
]);
}
try {
if ($prior = obligationPriorAttempt($con, $settlementRef)) {
obligationReplay($prior, $settlementRef);
exit;
}
// ‏الرصيد الحالي: driverWallet دفتر حركات لا رصيداً مخزّناً — الرصيد
// ‏هو مجموع صفوفه، والخصم صفّ بمبلغ سالب.
$st = $con->prepare("SELECT COALESCE(SUM(amount), 0) FROM driverWallet WHERE driverID = ?");
$st->execute([$driverID]);
$balance = (float) $st->fetchColumn();
// ‏أرباح اليوم = الصفوف الموجبة فقط. الأساس هو ما دخل اليوم لا الرصيد
// ‏المتراكم: الخصم من رصيد قديم يفاجئ سائقاً لم يعمل اليوم، والخصم من
// ‏دخل اليوم يبقى محسوساً كاقتطاع من كسبٍ حاضر.
$st = $con->prepare("
SELECT COALESCE(SUM(amount), 0) FROM driverWallet
WHERE driverID = ? AND amount > 0 AND DATE(dateCreated) = CURDATE()
");
$st->execute([$driverID]);
$dayEarnings = (float) $st->fetchColumn();
// ‏ما اقتطعه المحرك اليوم من كل المنتجات. بدون هذا الطرح يصير السقف
// ‏لكل منتج على حدة: ثلاثة منتجات بسقف ٢٥٪ تأخذ ٧٥٪ من يوم السائق.
$st = $con->prepare("
SELECT COALESCE(SUM(deducted_amount), 0) FROM obligation_settlements
WHERE driver_id = ? AND DATE(created_at) = CURDATE()
");
$st->execute([$driverID]);
$takenToday = (float) $st->fetchColumn();
$allowance = round(($dayEarnings * $capPercent / 100) - $takenToday, 3);
// ‏أرضية الرصيد: لا يدفع المحرك سائقاً إلى السالب مهما استحقّ عليه.
// ‏الدَّين المرحَّل خيار السائق يواصل به العمل؛ الرصيد السالب المفاجئ
// ‏يوقفه عن العمل، فيتعذّر السداد أصلاً.
$minBalance = (float) (getenv('OBLIGATION_MIN_BALANCE') ?: 0);
$headroom = round($balance - $minBalance, 3);
$deducted = min($amount, $allowance, $headroom);
if ($deducted < 0) {
$deducted = 0.0;
}
$deducted = round($deducted, 3);
$con->beginTransaction();
// ‏السجل يُكتب دائماً — حتى حين لا يُخصم شيء. صفر مسجَّل يعني «حاولنا
// ‏اليوم ولم يكن هناك ما يُخصم منه»، وهو ما يمنع ملاحقة السائق مراراً
// ‏في اليوم نفسه ويشرح لاحقاً لماذا تأخّر التحصيل.
$con->prepare("
INSERT INTO obligation_settlements
(settlement_ref, driver_id, product_code, requested_amount,
deducted_amount, day_earnings, cap_percent)
VALUES (?, ?, ?, ?, ?, ?, ?)
")->execute([
$settlementRef, $driverID, $productCode,
$amount, $deducted, $dayEarnings, $capPercent,
]);
if ($deducted > 0) {
// ‏paymentMethod عمود varchar(20) — لا يتّسع لاسم المنتج. التفصيل
// ‏يعيش في paymentID (وهو settlement_ref) وفي جدول التسويات.
$con->prepare("
INSERT INTO driverWallet (driverID, paymentID, amount, paymentMethod)
VALUES (?, ?, ?, 'obligation')
")->execute([$driverID, $settlementRef, -$deducted]);
}
$con->commit();
printSuccess([
'settlement_ref' => $settlementRef,
'deducted' => $deducted,
'requested' => $amount,
'day_earnings' => $dayEarnings,
'cap_percent' => $capPercent,
'balance_before' => $balance,
'replayed' => false,
]);
} catch (PDOException $e) {
if ($con->inTransaction()) {
$con->rollBack();
}
// ‏23000 = خرق المفتاح الفريد على settlement_ref: نسخة أخرى سبقتنا
// ‏بالمرجع نفسه. ليست حالة خطأ — هي بالضبط ما صُمّم المفتاح لمنعه.
if ($e->getCode() === '23000' && ($prior = obligationPriorAttempt($con, $settlementRef))) {
obligationReplay($prior, $settlementRef);
exit;
}
error_log('[obligation/deduct] ' . $e->getMessage());
http_response_code(500);
printFailure('Settlement failed');
} catch (Exception $e) {
if ($con->inTransaction()) {
$con->rollBack();
}
error_log('[obligation/deduct] ' . $e->getMessage());
http_response_code(500);
printFailure('Settlement failed');
}
@@ -0,0 +1,77 @@
<?php
/**
* income_summary_s2s.php — ملخّص دخل السائق المارّ بالمحفظة
* ─────────────────────────────────────────────────────────────
* ‏يجيب على سؤال واحد: هل قناة تحصيل هذا السائق حيّة؟
*
* ‏تستعمله بوابة الائتمان في محرك الالتزامات. سائق الكاش الذي يحصّل
* ‏نقداً ولا تمرّ أرباحه بالمحفظة يظهر بأرباح صفر كل يوم، فسقف الخصم
* ‏اليومي صفر، فلا يُحصَّل منه شيء أبداً — ومنحه وقوداً أو صيانة بالدَّين
* ‏يعني تسليمه قيمةً فعلية مقابل وعد بالسداد من قناة لا يمرّ بها ماله.
*
* ‏المقياس الأساسي أيام لا مبلغ: السؤال ليس «كم يكسب؟» بل «هل المال
* ‏يمرّ من هنا بانتظام؟». دخل واحد كبير قد يكون استرداداً أو تسوية
* ‏حادثة؛ اثنا عشر يوماً متفرّقاً قناةٌ تعمل.
*
* ‏قراءة فقط — لا تكتب شيئاً ولا تلمس رصيداً.
*/
require_once __DIR__ . '/../../jwtconnect.php';
// ‏نداء خادم-لخادم حصراً. الرد يُبنى عليه قرار منح ائتمان، فلا يُفتح
// ‏لجلسة سائق قد تسأل عن غيره.
$providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? '';
$expectedKey = getenv('S2S_SHARED_KEY') ?: '';
if (empty($expectedKey) || empty($providedKey) || !hash_equals($expectedKey, $providedKey)) {
http_response_code(401);
printFailure('Unauthorized: Invalid or missing X-S2S-Api-Key.');
exit;
}
$driverID = filterRequest('driverID');
$windowDays = (int) (filterRequest('window_days', 'numeric') ?: 30);
if (empty($driverID)) {
printFailure('Missing required parameter: driverID');
exit;
}
// ‏نافذة محدودة: مسح سنوات من الحركات لكل فحص أهلية حِمل بلا فائدة —
// ‏دخلُ سائقٍ قبل ستة أشهر لا يقول شيئاً عن قناته اليوم.
if ($windowDays < 1 || $windowDays > 180) {
$windowDays = 30;
}
try {
// ‏الصفوف الموجبة وحدها. السالبة خصومات (عمولة، التزامات) وعدّها
// ‏هنا كان سيجعل سائقاً يُخصم منه كثيراً يبدو ذا قناة نشطة.
//
// ‏وباستثناء صفوف الالتزامات صراحةً: 'obligation' مرجعها هذا المحرك
// ‏نفسه، ولو ظهرت يوماً بمبلغ موجب (تصحيح، استرداد) لصارت البوابة
// ‏تقيس أثر نفسها.
$st = $con->prepare("
SELECT
COUNT(DISTINCT DATE(dateCreated)) AS days_with_income,
COALESCE(SUM(amount), 0) AS total_income,
MAX(DATE(dateCreated)) AS last_income_date
FROM driverWallet
WHERE driverID = ?
AND amount > 0
AND paymentMethod <> 'obligation'
AND dateCreated >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
");
$st->execute([$driverID, $windowDays]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: [];
printSuccess([
'driver_id' => $driverID,
'window_days' => $windowDays,
'days_with_income' => (int) ($row['days_with_income'] ?? 0),
'total_income' => (float) ($row['total_income'] ?? 0),
'last_income_date' => $row['last_income_date'] ?? null,
]);
} catch (PDOException $e) {
error_log('[obligation/income_summary] ' . $e->getMessage());
http_response_code(500);
printFailure('Failed to read income summary');
}
@@ -0,0 +1,129 @@
// fuel_admin_controller.dart — تحكم شاشات محفظة الوقود (لوحة إدارة سيرو)
import 'package:get/get.dart';
import 'fuel_admin_models.dart';
import 'fuel_admin_service.dart';
class FuelAdminController extends GetxController {
bool isLoading = false;
List<FuelStation> stations = [];
/// ‏حالة المنتج — لا حالة الشاشة. الشاشة تعمل دائماً؛ المنتج قد يكون
/// ‏مُجهَّزاً وغير مُشغَّل حتى يتم الاتفاق مع سلسلة المحطات.
bool productActive = false;
String searchQuery = '';
bool isSaving = false;
bool isLoadingDrawdowns = false;
FuelDrawdownsPage? drawdowns;
String? lastError;
@override
void onInit() {
super.onInit();
fetchStations();
}
Future<void> fetchStations() async {
isLoading = true;
update();
final res = await FuelAdminService.listStations(search: searchQuery);
if (res.success && res.data != null) {
stations = res.data!.stations;
productActive = res.data!.productActive;
lastError = null;
} else {
lastError = res.message;
}
isLoading = false;
update();
}
/// ‏يُرجع المفتاح الجديد عند الإنشاء، أو null عند التعديل/الفشل.
///
/// ‏المفتاح يظهر مرة واحدة فقط — مخزَّن مُهشَّراً على الخادم ولا سبيل
/// ‏لاستعادته. الشاشة مسؤولة عن عرضه فوراً قبل أي انتقال.
Future<String?> saveStation({
int? stationId,
required String nameAr,
String? chainName,
String? city,
String? address,
double? lat,
double? lng,
String? phone,
required double discountPercent,
required bool isActive,
}) async {
isSaving = true;
update();
final res = await FuelAdminService.saveStation(
stationId: stationId,
nameAr: nameAr,
chainName: chainName,
city: city,
address: address,
lat: lat,
lng: lng,
phone: phone,
discountPercent: discountPercent,
isActive: isActive,
);
isSaving = false;
lastError = res.success ? null : res.message;
update();
if (!res.success) return null;
await fetchStations();
return res.data?['api_key']?.toString();
}
Future<String?> rotateKey(int stationId) async {
final res = await FuelAdminService.rotateKey(stationId);
if (!res.success) {
lastError = res.message;
update();
return null;
}
return res.data?['api_key']?.toString();
}
/// ‏تشغيل المنتج أو إيقافه. الخادم يرفض التشغيل بلا محطة فعّالة واحدة —
/// ‏سائق يفعّل محفظته ثم لا يجد أين يصرف قسيمته تجربةٌ تُفقد الثقة.
Future<bool> toggleProduct(bool value) async {
final res = await FuelAdminService.toggleProduct(value);
if (res.success) {
productActive = res.data ?? value;
lastError = null;
update();
return true;
}
lastError = res.message;
update();
return false;
}
Future<void> fetchDrawdowns({int? stationId, String? status}) async {
isLoadingDrawdowns = true;
update();
final res =
await FuelAdminService.listDrawdowns(stationId: stationId, status: status);
if (res.success) {
drawdowns = res.data;
lastError = null;
} else {
lastError = res.message;
}
isLoadingDrawdowns = false;
update();
}
}
@@ -0,0 +1,146 @@
// fuel_admin_models.dart — نماذج بيانات محفظة الوقود (لوحة إدارة سيرو)
class FuelStation {
final int id;
final String nameAr;
final String? chainName;
final String? city;
final String? address;
final double? lat;
final double? lng;
final String? phone;
final double discountPercent;
final bool isActive;
/// ‏أرقام الأداء: محطة بلا صرف اسمٌ في جدول لا شريك يعمل.
final int redeemCount;
final double redeemTotal;
final String? lastRedeemAt;
FuelStation({
required this.id,
required this.nameAr,
required this.discountPercent,
required this.isActive,
this.chainName,
this.city,
this.address,
this.lat,
this.lng,
this.phone,
this.redeemCount = 0,
this.redeemTotal = 0,
this.lastRedeemAt,
});
factory FuelStation.fromJson(Map<String, dynamic> j) => FuelStation(
id: int.tryParse(j['id'].toString()) ?? 0,
nameAr: j['name_ar']?.toString() ?? '',
chainName: j['chain_name']?.toString(),
city: j['city']?.toString(),
address: j['address']?.toString(),
lat: double.tryParse(j['lat']?.toString() ?? ''),
lng: double.tryParse(j['lng']?.toString() ?? ''),
phone: j['phone']?.toString(),
discountPercent:
double.tryParse(j['discount_percent']?.toString() ?? '0') ?? 0,
isActive: j['is_active'] == true,
redeemCount: int.tryParse(j['redeem_count']?.toString() ?? '0') ?? 0,
redeemTotal: double.tryParse(j['redeem_total']?.toString() ?? '0') ?? 0,
lastRedeemAt: j['last_redeem_at']?.toString(),
);
}
class FuelStationsPage {
final List<FuelStation> stations;
/// ‏حالة المنتج نفسه. قرار المالك: تُجهَّز الشاشات ولا يُشغَّل المنتج إلا
/// ‏بعد إتمام الاتفاق مع سلسلة المحطات — فالشاشة تعمل كاملةً بينما
/// ‏المنتج مُطفأ، والفرق يجب أن يكون ظاهراً لا مخفياً.
final bool productActive;
FuelStationsPage({required this.stations, required this.productActive});
factory FuelStationsPage.fromJson(Map<String, dynamic> j) => FuelStationsPage(
stations: (j['stations'] is List)
? (j['stations'] as List)
.map((s) => FuelStation.fromJson(Map<String, dynamic>.from(s)))
.toList()
: <FuelStation>[],
productActive: j['product_active'] == true,
);
}
class FuelVoucher {
final int id;
final String driverId;
final String code;
final double amountAuthorized;
final double amountRedeemed;
final String currency;
final String status;
final String? station;
final String? redeemedAt;
final String? createdAt;
FuelVoucher({
required this.id,
required this.driverId,
required this.code,
required this.amountAuthorized,
required this.amountRedeemed,
required this.currency,
required this.status,
this.station,
this.redeemedAt,
this.createdAt,
});
factory FuelVoucher.fromJson(Map<String, dynamic> j) => FuelVoucher(
id: int.tryParse(j['id'].toString()) ?? 0,
driverId: j['driver_id']?.toString() ?? '',
code: j['code']?.toString() ?? '',
amountAuthorized:
double.tryParse(j['amount_authorized']?.toString() ?? '0') ?? 0,
amountRedeemed:
double.tryParse(j['amount_redeemed']?.toString() ?? '0') ?? 0,
currency: j['currency']?.toString() ?? '',
status: j['status']?.toString() ?? '',
station: j['station']?.toString(),
redeemedAt: j['redeemed_at']?.toString(),
createdAt: j['created_at']?.toString(),
);
}
class FuelDrawdownsPage {
final List<FuelVoucher> vouchers;
final double lentTotal;
final double collectedTotal;
final double outstandingTotal;
final int driversCount;
FuelDrawdownsPage({
required this.vouchers,
required this.lentTotal,
required this.collectedTotal,
required this.outstandingTotal,
required this.driversCount,
});
factory FuelDrawdownsPage.fromJson(Map<String, dynamic> j) {
final s = Map<String, dynamic>.from(j['summary'] ?? {});
return FuelDrawdownsPage(
vouchers: (j['vouchers'] is List)
? (j['vouchers'] as List)
.map((v) => FuelVoucher.fromJson(Map<String, dynamic>.from(v)))
.toList()
: <FuelVoucher>[],
lentTotal: double.tryParse(s['lent_total']?.toString() ?? '0') ?? 0,
collectedTotal:
double.tryParse(s['collected_total']?.toString() ?? '0') ?? 0,
outstandingTotal:
double.tryParse(s['outstanding_total']?.toString() ?? '0') ?? 0,
driversCount: int.tryParse(s['drivers_count']?.toString() ?? '0') ?? 0,
);
}
}
@@ -0,0 +1,127 @@
// fuel_admin_service.dart — طبقة الاتصال بـ backend/Admin/fuel
//
// ‏محفظة الوقود: السائق يتزوّد بلا نقد، والقيمة تُقيَّد ديناً يُخصم من
// ‏أرباحه. هذه الشاشة تدير المحطات الشريكة وتشغّل المنتج أو توقفه.
import '../functions/crud.dart';
import '../../constant/links.dart';
import 'fuel_admin_models.dart';
class FuelApiResult<T> {
final bool success;
final T? data;
final String message;
FuelApiResult(this.success, this.data, this.message);
}
class FuelAdminService {
static String get _base => '${AppLink.server}/Admin/fuel';
static String _errMsg(dynamic res) {
if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
if (res is Map && res['message'] is String) return res['message'];
return 'حدث خطأ، حاول مجدداً';
}
static Future<FuelApiResult<FuelStationsPage>> listStations({
String? search,
String? city,
bool onlyActive = false,
}) async {
final payload = <String, dynamic>{};
if (search != null && search.isNotEmpty) payload['search'] = search;
if (city != null && city.isNotEmpty) payload['city'] = city;
if (onlyActive) payload['only_active'] = '1';
final res = await CRUD().post(link: '$_base/list.php', payload: payload);
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
return FuelApiResult(
true,
FuelStationsPage.fromJson(Map<String, dynamic>.from(res['message'])),
'ok',
);
}
return FuelApiResult(false, null, _errMsg(res));
}
/// ‏يُرجع `api_key` عند الإنشاء فقط — مخزَّن مُهشَّراً على الخادم، فلا
/// ‏سبيل لعرضه ثانيةً. الشاشة مسؤولة عن إظهاره فوراً وبوضوح.
static Future<FuelApiResult<Map<String, dynamic>>> saveStation({
int? stationId,
required String nameAr,
String? chainName,
String? city,
String? address,
double? lat,
double? lng,
String? phone,
required double discountPercent,
required bool isActive,
}) async {
final res = await CRUD().post(link: '$_base/save.php', payload: {
if (stationId != null) 'station_id': stationId.toString(),
'name_ar': nameAr,
if (chainName != null && chainName.isNotEmpty) 'chain_name': chainName,
if (city != null && city.isNotEmpty) 'city': city,
if (address != null && address.isNotEmpty) 'address': address,
if (lat != null) 'lat': lat.toString(),
if (lng != null) 'lng': lng.toString(),
if (phone != null && phone.isNotEmpty) 'phone': phone,
'discount_percent': discountPercent.toString(),
'is_active': isActive ? '1' : '0',
});
if (res is Map && res['status'] == 'success') {
return FuelApiResult(
true,
Map<String, dynamic>.from(res['message'] ?? {}),
res['message'] is Map ? 'ok' : (res['message']?.toString() ?? 'ok'),
);
}
return FuelApiResult(false, null, _errMsg(res));
}
static Future<FuelApiResult<Map<String, dynamic>>> rotateKey(int stationId) async {
final res = await CRUD().post(
link: '$_base/rotate_key.php',
payload: {'station_id': stationId.toString()},
);
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
return FuelApiResult(true, Map<String, dynamic>.from(res['message']), 'ok');
}
return FuelApiResult(false, null, _errMsg(res));
}
static Future<FuelApiResult<bool>> toggleProduct(bool isActive) async {
final res = await CRUD().post(
link: '$_base/toggle_product.php',
payload: {'is_active': isActive ? '1' : '0'},
);
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
return FuelApiResult(
true, res['message']['product_active'] == true, 'ok');
}
return FuelApiResult(false, null, _errMsg(res));
}
static Future<FuelApiResult<FuelDrawdownsPage>> listDrawdowns({
int? stationId,
String? status,
}) async {
final payload = <String, dynamic>{};
if (stationId != null) payload['station_id'] = stationId.toString();
if (status != null && status.isNotEmpty) payload['status'] = status;
final res = await CRUD().post(link: '$_base/drawdowns.php', payload: payload);
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
return FuelApiResult(
true,
FuelDrawdownsPage.fromJson(Map<String, dynamic>.from(res['message'])),
'ok',
);
}
return FuelApiResult(false, null, _errMsg(res));
}
}
@@ -42,6 +42,7 @@ import 'analytics/live_analytics_page.dart';
import 'package:siro_admin/views/widgets/responsive_layout.dart'; import 'package:siro_admin/views/widgets/responsive_layout.dart';
import 'package:siro_admin/views/widgets/web_sidebar.dart'; import 'package:siro_admin/views/widgets/web_sidebar.dart';
import '../transit/org_list_page.dart'; import '../transit/org_list_page.dart';
import 'fuel/fuel_stations_page.dart';
import 'package:siro_admin/views/widgets/glass_container.dart'; import 'package:siro_admin/views/widgets/glass_container.dart';
class AdminHomePage extends StatefulWidget { class AdminHomePage extends StatefulWidget {
@@ -1352,6 +1353,13 @@ class _AdminHomePageState extends State<AdminHomePage>
() => Get.to(() => const TransitOrgListPage())), () => Get.to(() => const TransitOrgListPage())),
], ],
), ),
ActionCategory(
title: 'اقتصاد السائق',
items: [
ActionItem('محطات الوقود', Icons.local_gas_station_rounded, const Color(0xFF10B981),
() => Get.to(() => const FuelStationsPageView())),
],
),
ActionCategory( ActionCategory(
title: 'إدارة النظام الجديد', title: 'إدارة النظام الجديد',
items: [ items: [
@@ -0,0 +1,174 @@
// fuel_drawdowns_page.dart — حركة القسائم وملخّص الائتمان
//
// ‏الشاشة التي تُجيب: كم أقرضنا؟ كم استُرد؟ وكم بقي في الشارع؟
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../controller/fuel/fuel_admin_controller.dart';
import '../../../controller/fuel/fuel_admin_models.dart';
class FuelDrawdownsPageView extends StatefulWidget {
const FuelDrawdownsPageView({super.key});
@override
State<FuelDrawdownsPageView> createState() => _FuelDrawdownsPageViewState();
}
class _FuelDrawdownsPageViewState extends State<FuelDrawdownsPageView> {
String? _status;
@override
void initState() {
super.initState();
Get.find<FuelAdminController>().fetchDrawdowns();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return GetBuilder<FuelAdminController>(
builder: (c) => Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surface,
foregroundColor: cs.onSurface,
title: const Text('حركة القسائم'),
),
body: Column(
children: [
if (c.drawdowns != null) _summary(c.drawdowns!, cs),
_filters(c, cs),
Expanded(
child: c.isLoadingDrawdowns
? Center(child: CircularProgressIndicator(color: cs.primary))
: (c.drawdowns?.vouchers.isEmpty ?? true)
? Center(
child: Text('لا توجد قسائم',
style: TextStyle(color: cs.onSurfaceVariant)))
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: c.drawdowns!.vouchers.length,
itemBuilder: (_, i) =>
_voucherCard(c.drawdowns!.vouchers[i], cs),
),
),
],
),
),
);
}
/// ‏الملخّص على كامل المنتج لا على القائمة المعروضة: رقم يتغيّر بتغيّر
/// ‏الفلتر يُقرأ كأنه حقيقة عن المنصة وهو ليس كذلك.
Widget _summary(FuelDrawdownsPage d, ColorScheme cs) => Container(
margin: const EdgeInsets.fromLTRB(12, 12, 12, 4),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
_stat('أُقرض', d.lentTotal.toString(), cs.onSurface, cs),
_stat('استُرد', d.collectedTotal.toString(), cs.primary, cs),
_stat('قائم', d.outstandingTotal.toString(), cs.tertiary, cs),
_stat('سائق', d.driversCount.toString(), cs.onSurfaceVariant, cs),
],
),
);
Widget _stat(String label, String value, Color color, ColorScheme cs) =>
Expanded(
child: Column(
children: [
Text(value,
style: TextStyle(
color: color, fontSize: 16, fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(label,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11)),
],
),
);
Widget _filters(FuelAdminController c, ColorScheme cs) {
const options = {
null: 'الكل',
'issued': 'سارية',
'redeemed': 'مصروفة',
'expired': 'منتهية',
'cancelled': 'ملغاة',
};
return SizedBox(
height: 46,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
children: options.entries.map((e) {
final selected = _status == e.key;
return Padding(
padding: const EdgeInsets.only(left: 8),
child: ChoiceChip(
label: Text(e.value),
selected: selected,
onSelected: (_) {
setState(() => _status = e.key);
c.fetchDrawdowns(status: e.key);
},
),
);
}).toList(),
),
);
}
Widget _voucherCard(FuelVoucher v, ColorScheme cs) {
final (color, label) = switch (v.status) {
'redeemed' => (cs.primary, 'مصروفة'),
'issued' => (cs.tertiary, 'سارية'),
'cancelled' => (cs.error, 'ملغاة'),
_ => (cs.onSurfaceVariant, 'منتهية'),
};
// ‏المصروف لا المأذون: السائق قد يطلب عشرين ويتزوّد بخمسة عشر،
// ‏والدَّين على ما صُرف. عرض المأذون هنا يضخّم الأرقام في عين القارئ.
final amount = v.status == 'redeemed' ? v.amountRedeemed : v.amountAuthorized;
return Card(
color: cs.surfaceContainerHighest,
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
title: Row(
children: [
Text('$amount ${v.currency}',
style: TextStyle(
color: cs.onSurface, fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(label, style: TextStyle(color: color, fontSize: 11)),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
[
'سائق ${v.driverId}',
if (v.station != null && v.station!.isNotEmpty) v.station!,
v.redeemedAt ?? v.createdAt ?? '',
].where((s) => s.isNotEmpty).join(' · '),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
),
),
);
}
}
@@ -0,0 +1,212 @@
// fuel_station_form.dart — إضافة محطة أو تعديلها
//
// ‏يُرجع مفتاح المحطة الجديد عبر Get.back عند الإنشاء، وnull عند التعديل.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../controller/fuel/fuel_admin_controller.dart';
import '../../../controller/fuel/fuel_admin_models.dart';
class FuelStationForm extends StatefulWidget {
final FuelStation? station;
const FuelStationForm({super.key, this.station});
@override
State<FuelStationForm> createState() => _FuelStationFormState();
}
class _FuelStationFormState extends State<FuelStationForm> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _name;
late final TextEditingController _chain;
late final TextEditingController _city;
late final TextEditingController _address;
late final TextEditingController _phone;
late final TextEditingController _lat;
late final TextEditingController _lng;
late final TextEditingController _discount;
late bool _isActive;
bool get _isEdit => widget.station != null;
@override
void initState() {
super.initState();
final s = widget.station;
_name = TextEditingController(text: s?.nameAr ?? '');
_chain = TextEditingController(text: s?.chainName ?? '');
_city = TextEditingController(text: s?.city ?? '');
_address = TextEditingController(text: s?.address ?? '');
_phone = TextEditingController(text: s?.phone ?? '');
_lat = TextEditingController(text: s?.lat?.toString() ?? '');
_lng = TextEditingController(text: s?.lng?.toString() ?? '');
_discount = TextEditingController(text: (s?.discountPercent ?? 0).toString());
_isActive = s?.isActive ?? true;
}
@override
void dispose() {
for (final c in [_name, _chain, _city, _address, _phone, _lat, _lng, _discount]) {
c.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final c = Get.find<FuelAdminController>();
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surface,
foregroundColor: cs.onSurface,
title: Text(_isEdit ? 'تعديل محطة' : 'محطة جديدة'),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_field(cs, _name, 'اسم المحطة *', required: true),
_field(cs, _chain, 'السلسلة (إن كانت فرعاً)'),
_field(cs, _city, 'المدينة'),
_field(cs, _address, 'العنوان'),
_field(cs, _phone, 'الهاتف', keyboard: TextInputType.phone),
Row(
children: [
Expanded(
child: _field(cs, _lat, 'خط العرض',
keyboard: TextInputType.number)),
const SizedBox(width: 10),
Expanded(
child: _field(cs, _lng, 'خط الطول',
keyboard: TextInputType.number)),
],
),
_field(
cs,
_discount,
'نسبة خصم الشريك (٪)',
keyboard: TextInputType.number,
validator: (v) {
final d = double.tryParse(v ?? '');
if (d == null) return 'أدخل رقماً';
if (d < 0 || d > 100) return 'بين 0 و 100';
return null;
},
),
// ‏الخصم يُسجَّل مع كل سحبة لا يُقرأ لاحقاً من هنا: النسبة
// ‏تتغيّر بالتفاوض، وحساب تسويات الماضي بنسبة اليوم تزوير.
Padding(
padding: const EdgeInsets.only(right: 4, bottom: 12),
child: Text(
'يُسجَّل مع كل عملية صرف. تعديله لا يغيّر عمليات سابقة.',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
),
SwitchListTile(
value: _isActive,
activeThumbColor: cs.primary,
contentPadding: EdgeInsets.zero,
title: Text('محطة فعّالة', style: TextStyle(color: cs.onSurface)),
subtitle: Text(
_isActive
? 'تظهر للسائقين وتستطيع تأكيد القسائم'
: 'مخفيّة عن السائقين ولا تستطيع تأكيد أي قسيمة',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
onChanged: (v) => setState(() => _isActive = v),
),
const SizedBox(height: 20),
SizedBox(
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: cs.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
onPressed: c.isSaving ? null : () => _submit(c),
child: c.isSaving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: Text(_isEdit ? 'حفظ التعديلات' : 'إنشاء المحطة',
style: TextStyle(color: cs.onPrimary)),
),
),
if (!_isEdit)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'سيُولَّد مفتاح خاص بالمحطة عند الإنشاء، ويظهر مرة واحدة فقط.',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
textAlign: TextAlign.center,
),
),
],
),
),
);
}
Widget _field(
ColorScheme cs,
TextEditingController ctrl,
String label, {
bool required = false,
TextInputType? keyboard,
String? Function(String?)? validator,
}) =>
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextFormField(
controller: ctrl,
keyboardType: keyboard,
style: TextStyle(color: cs.onSurface),
validator: validator ??
(required
? (v) => (v == null || v.trim().isEmpty) ? 'مطلوب' : null
: null),
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: cs.onSurfaceVariant),
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
);
Future<void> _submit(FuelAdminController c) async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final key = await c.saveStation(
stationId: widget.station?.id,
nameAr: _name.text.trim(),
chainName: _chain.text.trim(),
city: _city.text.trim(),
address: _address.text.trim(),
lat: double.tryParse(_lat.text.trim()),
lng: double.tryParse(_lng.text.trim()),
phone: _phone.text.trim(),
discountPercent: double.tryParse(_discount.text.trim()) ?? 0,
isActive: _isActive,
);
if (c.lastError != null) {
Get.snackbar('تعذّر الحفظ', c.lastError!);
return;
}
Get.back(result: key);
}
}
@@ -0,0 +1,402 @@
// fuel_stations_page.dart — إدارة محطات الوقود الشريكة
//
// ‏الشاشة تعمل كاملةً حتى والمنتج مُطفأ: قرار المالك أن تُجهَّز الأمور
// ‏وتُبنى الشاشات، ولا يُشغَّل المنتج إلا بعد إتمام الاتفاق مع سلسلة
// ‏المحطات. لذلك اللافتة العلوية تقول الحالة صراحةً بدل إخفائها.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../../../controller/fuel/fuel_admin_controller.dart';
import '../../../controller/fuel/fuel_admin_models.dart';
import 'fuel_drawdowns_page.dart';
import 'fuel_station_form.dart';
class FuelStationsPageView extends StatefulWidget {
const FuelStationsPageView({super.key});
@override
State<FuelStationsPageView> createState() => _FuelStationsPageViewState();
}
class _FuelStationsPageViewState extends State<FuelStationsPageView> {
final _searchCtrl = TextEditingController();
@override
void initState() {
super.initState();
Get.put(FuelAdminController());
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return GetBuilder<FuelAdminController>(
builder: (c) => Scaffold(
backgroundColor: cs.surface,
body: Column(
children: [
Container(
padding: const EdgeInsets.fromLTRB(16, 50, 16, 12),
child: Row(
children: [
GestureDetector(
onTap: () => Get.back(),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.outline),
),
child: Icon(Icons.arrow_back_ios_new_rounded,
color: cs.onSurfaceVariant, size: 16),
),
),
const SizedBox(width: 12),
Text('محفظة الوقود — المحطات',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: Icon(Icons.receipt_long_rounded, color: cs.tertiary),
tooltip: 'حركة القسائم',
onPressed: () => Get.to(() => const FuelDrawdownsPageView()),
),
IconButton(
icon: Icon(Icons.add, color: cs.primary),
tooltip: 'إضافة محطة',
onPressed: () => _openForm(c, null),
),
],
),
),
_productBanner(c, cs),
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
controller: _searchCtrl,
style: TextStyle(color: cs.onSurface),
onSubmitted: (v) {
c.searchQuery = v;
c.fetchStations();
},
decoration: InputDecoration(
hintText: 'ابحث باسم المحطة أو السلسلة أو العنوان...',
hintStyle: TextStyle(color: cs.onSurfaceVariant),
prefixIcon: Icon(Icons.search, color: cs.onSurfaceVariant),
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
),
Expanded(
child: c.isLoading
? Center(child: CircularProgressIndicator(color: cs.primary))
: c.stations.isEmpty
? _emptyState(cs)
: RefreshIndicator(
onRefresh: c.fetchStations,
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: c.stations.length,
itemBuilder: (_, i) =>
_stationCard(c, c.stations[i], cs),
),
),
),
],
),
),
);
}
/// ‏لافتة الحالة. «مُجهَّز وغير مُشغَّل» حالة مقصودة لا عطل — وإخفاؤها
/// ‏يجعل من يعرض المنصة يظنّ المنتج حياً، أو يظنّه معطوباً.
Widget _productBanner(FuelAdminController c, ColorScheme cs) {
final active = c.productActive;
final color = active ? cs.primary : cs.tertiary;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(active ? Icons.check_circle_rounded : Icons.pause_circle_rounded,
color: color, size: 20),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(active ? 'المنتج مُشغَّل' : 'مُجهَّز — غير مُشغَّل',
style: TextStyle(
color: color, fontWeight: FontWeight.w700, fontSize: 14)),
const SizedBox(height: 2),
Text(
active
? 'السائقون المؤهَّلون يستطيعون التفعيل وإصدار القسائم'
: 'الشاشة تعمل للتجهيز والعرض. لا تفعيل ولا قسائم للسائقين.',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
],
),
),
Switch(
value: active,
activeThumbColor: cs.primary,
onChanged: (v) => _confirmToggle(c, v),
),
],
),
);
}
/// ‏تأكيد قبل التشغيل لا بعده. التشغيل يفتح باب إقراض حقيقي بمال
/// ‏الشركة، ومفتاح يُقلب بالخطأ في عرضٍ أمام مستثمر ليس أمراً هيّناً.
Future<void> _confirmToggle(FuelAdminController c, bool value) async {
final cs = Theme.of(context).colorScheme;
final ok = await Get.dialog<bool>(
AlertDialog(
backgroundColor: cs.surfaceContainerHighest,
title: Text(value ? 'تشغيل محفظة الوقود؟' : 'إيقاف محفظة الوقود؟',
style: TextStyle(color: cs.onSurface)),
content: Text(
value
? 'سيتمكّن السائقون المؤهَّلون من التزوّد بالدَّين فوراً. لا تُشغّله قبل إتمام الاتفاق مع المحطات.'
: 'سيتوقّف التفعيل وإصدار القسائم. الالتزامات القائمة ودفترها لا تتأثر.',
style: TextStyle(color: cs.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('إلغاء')),
TextButton(
onPressed: () => Get.back(result: true),
child: Text(value ? 'تشغيل' : 'إيقاف',
style: TextStyle(color: value ? cs.primary : cs.error)),
),
],
),
);
if (ok != true) return;
final done = await c.toggleProduct(value);
if (!done && c.lastError != null) {
Get.snackbar('تعذّر التنفيذ', c.lastError!,
backgroundColor: cs.error.withValues(alpha: 0.15));
}
}
Widget _emptyState(ColorScheme cs) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.local_gas_station_rounded,
size: 48, color: cs.onSurfaceVariant),
const SizedBox(height: 12),
Text('لا توجد محطات بعد',
style: TextStyle(color: cs.onSurface, fontSize: 16)),
const SizedBox(height: 6),
Text('أضف أول محطة شريكة لتجهيز المنتج',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
],
),
);
Widget _stationCard(FuelAdminController c, FuelStation s, ColorScheme cs) {
final statusColor = s.isActive ? cs.primary : cs.onSurfaceVariant;
return Card(
color: cs.surfaceContainerHighest,
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
title: Row(
children: [
Expanded(
child: Text(s.nameAr,
style: TextStyle(
color: cs.onSurface, fontWeight: FontWeight.bold)),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(s.isActive ? 'فعّالة' : 'موقوفة',
style: TextStyle(color: statusColor, fontSize: 11)),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
[
if (s.chainName != null && s.chainName!.isNotEmpty) s.chainName!,
if (s.city != null && s.city!.isNotEmpty) s.city!,
].join(' · '),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 6),
Row(
children: [
Icon(Icons.percent_rounded, size: 13, color: cs.tertiary),
const SizedBox(width: 2),
Text('خصم ${s.discountPercent}',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
const SizedBox(width: 12),
Icon(Icons.local_gas_station, size: 13, color: cs.onSurfaceVariant),
const SizedBox(width: 2),
Text('${s.redeemCount} صرف',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
const SizedBox(width: 12),
Text('${s.redeemTotal}',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
],
),
],
),
),
trailing: PopupMenuButton<String>(
color: cs.surfaceContainerHighest,
icon: Icon(Icons.more_vert, color: cs.onSurfaceVariant, size: 18),
onSelected: (v) async {
if (v == 'edit') {
_openForm(c, s);
} else if (v == 'rotate') {
await _rotateKey(c, s);
}
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'edit',
child: Text('تعديل', style: TextStyle(color: cs.onSurface))),
PopupMenuItem(
value: 'rotate',
child: Text('توليد مفتاح جديد',
style: TextStyle(color: cs.error))),
],
),
onTap: () => _openForm(c, s),
),
);
}
Future<void> _openForm(FuelAdminController c, FuelStation? station) async {
final apiKey = await Get.to<String?>(
() => FuelStationForm(station: station),
);
if (apiKey != null && apiKey.isNotEmpty) {
await _showKey(apiKey, station?.nameAr ?? 'المحطة الجديدة');
}
}
Future<void> _rotateKey(FuelAdminController c, FuelStation s) async {
final cs = Theme.of(context).colorScheme;
final ok = await Get.dialog<bool>(
AlertDialog(
backgroundColor: cs.surfaceContainerHighest,
title: Text('توليد مفتاح جديد؟', style: TextStyle(color: cs.onSurface)),
content: Text(
'المفتاح الحالي لـ«${s.nameAr}» سيتوقّف فوراً، ولن تستطيع المحطة تأكيد أي قسيمة حتى تستلم الجديد.',
style: TextStyle(color: cs.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('إلغاء')),
TextButton(
onPressed: () => Get.back(result: true),
child: Text('توليد', style: TextStyle(color: cs.error))),
],
),
);
if (ok != true) return;
final key = await c.rotateKey(s.id);
if (key != null) {
await _showKey(key, s.nameAr);
} else if (c.lastError != null) {
Get.snackbar('تعذّر التوليد', c.lastError!,
backgroundColor: cs.error.withValues(alpha: 0.15));
}
}
/// ‏المفتاح يظهر مرة واحدة — مخزَّن مُهشَّراً على الخادم فلا سبيل
/// ‏لاستعادته. الحوار لا يُغلق باللمس خارجه: إغلاقه بالخطأ قبل النسخ
/// ‏يعني محطة بلا مفتاح ودورة توليد جديدة.
Future<void> _showKey(String key, String stationName) async {
final cs = Theme.of(context).colorScheme;
await Get.dialog<void>(
barrierDismissible: false,
AlertDialog(
backgroundColor: cs.surfaceContainerHighest,
title: Text('مفتاح $stationName',
style: TextStyle(color: cs.onSurface, fontSize: 16)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.outline),
),
child: SelectableText(key,
style: TextStyle(
color: cs.onSurface, fontSize: 12, fontFamily: 'monospace')),
),
const SizedBox(height: 10),
Text(
'انسخه الآن وسلّمه للمحطة. لن يظهر مرة أخرى — المفتاح مخزَّن مشفّراً ولا يمكن استرجاعه.',
style: TextStyle(color: cs.error, fontSize: 11),
),
],
),
actions: [
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: key));
Get.snackbar('نُسخ', 'المفتاح في الحافظة');
},
child: Text('نسخ', style: TextStyle(color: cs.primary)),
),
TextButton(
onPressed: () => Get.back(), child: const Text('تم')),
],
),
);
}
}
@@ -6,6 +6,7 @@ import 'package:siro_admin/views/admin/captain/captain.dart';
import 'package:siro_admin/views/admin/passenger/passenger.dart'; import 'package:siro_admin/views/admin/passenger/passenger.dart';
import 'package:siro_admin/views/admin/drivers/driver_tracker_screen.dart'; import 'package:siro_admin/views/admin/drivers/driver_tracker_screen.dart';
import 'package:siro_admin/views/transit/org_list_page.dart'; import 'package:siro_admin/views/transit/org_list_page.dart';
import 'package:siro_admin/views/admin/fuel/fuel_stations_page.dart';
import 'package:siro_admin/views/admin/static/static.dart'; import 'package:siro_admin/views/admin/static/static.dart';
import 'package:siro_admin/controller/admin/static_controller.dart'; import 'package:siro_admin/controller/admin/static_controller.dart';
import 'package:siro_admin/views/admin/drivers/monitor_ride.dart'; import 'package:siro_admin/views/admin/drivers/monitor_ride.dart';
@@ -172,6 +173,15 @@ class WebSidebar extends StatelessWidget {
onTap: () => Get.to(() => const TransitOrgListPage()), onTap: () => Get.to(() => const TransitOrgListPage()),
), ),
_buildSectionHeader(context, 'اقتصاد السائق'),
_buildNavItem(
context,
title: 'محطات الوقود',
icon: Icons.local_gas_station_rounded,
index: 140,
onTap: () => Get.to(() => const FuelStationsPageView()),
),
_buildSectionHeader(context, 'المالية والأسعار'), _buildSectionHeader(context, 'المالية والأسعار'),
_buildNavItem( _buildNavItem(
context, context,
+7
View File
@@ -278,6 +278,13 @@ class AppLink {
static String get getDriverDetails => static String get getDriverDetails =>
"$seferCairoServer/ride/driverWallet/getDriverDetails.php"; "$seferCairoServer/ride/driverWallet/getDriverDetails.php";
// ================= Earnings Endpoints =================
// نقطة واحدة لكل أرقام شاشة الأرباح بختم زمني واحد. النداءات الأربعة
// السابقة كانت تصل في لحظات مختلفة فتُنتج أرقاماً لا تتفق.
static String get getEarningsSummary =>
"$endPoint/ride/earnings/summary.php";
static String get getEarningsTrips => "$endPoint/ride/earnings/trips.php";
// ================= Gamification Endpoints ================= // ================= Gamification Endpoints =================
static String get getWeeklyAggregate => static String get getWeeklyAggregate =>
"$endPoint/ride/gamification/getWeeklyAggregate.php"; "$endPoint/ride/gamification/getWeeklyAggregate.php";
@@ -299,7 +299,7 @@ class HomeCaptainController extends GetxController {
void goToWalletFromConnect() { void goToWalletFromConnect() {
Get.back(); Get.back();
Get.back(); Get.back();
Get.to(() => WalletCaptainRefactored()); Get.to(() => const WalletCaptainRefactored());
} }
void changeRideId() { void changeRideId() {
@@ -0,0 +1,176 @@
// earnings_controller.dart — مصدر واحد لأرقام شاشة الأرباح
//
// ‏كانت الأرقام تُبنى من أربعة نداءات مستقلة، فيصل كلٌّ منها في لحظة
// ‏مختلفة وتظهر أرقام لا تتفق. هنا نداء واحد بختم زمني واحد.
//
// ‏والأهم: **الفشل لا يُترجم أصفاراً**. الكود السابق كان عند فشل الشبكة
// ‏يولّد سبعة أيام بأصفار ويعرضها كأنها أرباح السائق — وهو يرى دخله وقد
// ‏تبخّر. الحالتان هنا منفصلتان: `hasError` غير «كسبتَ صفراً».
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:siro_driver/constant/links.dart';
import 'package:siro_driver/controller/functions/crud.dart';
import 'package:siro_driver/main.dart';
import 'earnings_models.dart';
class EarningsController extends GetxController {
EarningsSummary? summary;
List<TripEarning> trips = [];
bool isLoading = false;
bool isLoadingTrips = false;
/// ‏سبب الفشل كنصّ للسائق، أو null. وجوده يعني «لا نعرف أرباحك الآن»
/// ‏لا «أرباحك صفر» — والفرق بينهما هو الفرق بين عطلٍ يُحتمل وثقةٍ تُفقد.
String? errorMessage;
/// ‏هل المعروض من الذاكرة المؤقتة؟ الشاشة تُظهر ذلك صراحةً مع وقت
/// ‏القياس، فلا يقرأ السائق رقماً عمره ساعات على أنه لحظته.
bool isStale = false;
DateTime? lastFetch;
/// ‏اليوم المختار لقائمة الرحلات (null = آخر سبعة أيام).
String? selectedDay;
static const _cacheKey = 'earnings_summary_cache';
static const _cacheAtKey = 'earnings_summary_cached_at';
/// ‏دقيقتان لا ثلاث ساعات. الكاش الطويل السابق كان سبب تناقض شاشة
/// ‏الأرباح مع شاشة الرصيد؛ ومدة قصيرة تكفي لمنع النداءات المتكررة
/// ‏عند التنقّل بين الشاشات بلا أن تُقدّم للسائق ماضياً على أنه حاضر.
static const _freshFor = Duration(minutes: 2);
@override
void onInit() {
super.onInit();
_loadFromCache();
load();
}
void _loadFromCache() {
try {
final raw = box.read(_cacheKey);
if (raw == null) return;
summary = EarningsSummary.fromJson(
Map<String, dynamic>.from(jsonDecode(raw)));
final at = box.read(_cacheAtKey);
if (at != null) lastFetch = DateTime.tryParse(at.toString());
isStale = true;
update();
} catch (e) {
debugPrint('[Earnings] تعذّرت قراءة الكاش: $e');
}
}
Future<void> load({bool force = false}) async {
if (!force && lastFetch != null && !isStale) {
if (DateTime.now().difference(lastFetch!) < _freshFor) return;
}
isLoading = summary == null; // ‏دوّار فقط حين لا يوجد ما يُعرض
errorMessage = null;
update();
final res = await CRUD().get(link: AppLink.getEarningsSummary);
// ‏الرسائل مفصّلة بحسب سبب الفشل: «تحقق من اتصالك» تُصلح بفعل،
// ‏و«حدث خطأ» تترك السائق بلا حيلة.
if (res == 'no_internet') {
_fail('تعذّر الوصول للخادم — تحقّق من اتصالك');
return;
}
if (res == 'token_expired') {
_fail('انتهت الجلسة، حاول مجدداً');
return;
}
if (res == 'failure' || res == null) {
_fail('تعذّر تحميل أرباحك الآن');
return;
}
try {
final data = jsonDecode(res);
if (data['status'] != 'success' || data['message'] is! Map) {
_fail('تعذّر تحميل أرباحك الآن');
return;
}
summary = EarningsSummary.fromJson(
Map<String, dynamic>.from(data['message']));
box.write(_cacheKey, jsonEncode(data['message']));
box.write(_cacheAtKey, DateTime.now().toIso8601String());
lastFetch = DateTime.now();
isStale = false;
errorMessage = null;
} catch (e) {
debugPrint('[Earnings] تعذّر تحليل الرد: $e');
_fail('تعذّر تحميل أرباحك الآن');
return;
} finally {
isLoading = false;
update();
}
await loadTrips();
}
/// ‏الفشل يُبقي آخر بيانات معروفة معروضة ومعلَّمة كقديمة، ولا يستبدلها
/// ‏بأصفار. رقم قديم صادق أنفع من رقم حاضر كاذب.
void _fail(String message) {
errorMessage = message;
isLoading = false;
if (summary != null) isStale = true;
update();
}
Future<void> loadTrips({String? day}) async {
selectedDay = day;
isLoadingTrips = true;
update();
final res = await CRUD().get(
link: AppLink.getEarningsTrips,
payload: {
if (day != null) 'date': day,
if (day == null) 'range': 'week',
},
);
try {
if (res is String && res != 'failure' && res != 'no_internet' && res != 'token_expired') {
final data = jsonDecode(res);
if (data['status'] == 'success' && data['message'] is Map) {
final list = data['message']['trips'];
trips = (list is List)
? list
.map((t) => TripEarning.fromJson(Map<String, dynamic>.from(t)))
.toList()
: <TripEarning>[];
}
}
} catch (e) {
debugPrint('[Earnings] تعذّر تحليل الرحلات: $e');
}
isLoadingTrips = false;
update();
}
/// ‏نصّ «آخر تحديث» بصيغة يفهمها السائق بلا حساب.
String get lastUpdatedLabel {
if (lastFetch == null) return '';
final diff = DateTime.now().difference(lastFetch!);
if (diff.inMinutes < 1) return 'الآن';
if (diff.inMinutes < 60) return 'قبل ${diff.inMinutes} دقيقة';
if (diff.inHours < 24) return 'قبل ${diff.inHours} ساعة';
return 'قبل ${diff.inDays} يوم';
}
}
@@ -0,0 +1,226 @@
// earnings_models.dart — نماذج شاشة الأرباح
//
// ‏كل الأرقام تصل من نقطة واحدة (`ride/earnings/summary.php`) بختم زمني
// ‏واحد، فلا مجال لأن يتناقض رقمان في الشاشة نفسها.
class PeriodEarnings {
final int trips;
/// ‏صافي السائق — حصته المثبَّتة لحظة عرض السعر، أي ما وُعد به قبل القبول.
final double net;
/// ‏إجمالي ما دفعه الركاب.
final double gross;
/// ‏عمولة المنصة. تُعرض صراحةً لا تُطرح بصمت: السائق الذي يرى تركيبة
/// ‏المبلغ يفهم، والذي يرى الصافي وحده يخمّن ويشكّ.
final double commission;
final double distance;
final int minutes;
PeriodEarnings({
required this.trips,
required this.net,
required this.gross,
required this.commission,
required this.distance,
required this.minutes,
});
double get hours => minutes / 60.0;
factory PeriodEarnings.fromJson(Map<String, dynamic> j) => PeriodEarnings(
trips: int.tryParse(j['trips']?.toString() ?? '0') ?? 0,
net: double.tryParse(j['net']?.toString() ?? '0') ?? 0,
gross: double.tryParse(j['gross']?.toString() ?? '0') ?? 0,
commission: double.tryParse(j['commission']?.toString() ?? '0') ?? 0,
distance: double.tryParse(j['distance']?.toString() ?? '0') ?? 0,
minutes: int.tryParse(j['minutes']?.toString() ?? '0') ?? 0,
);
static PeriodEarnings empty() => PeriodEarnings(
trips: 0, net: 0, gross: 0, commission: 0, distance: 0, minutes: 0);
}
class DayEarnings {
final String day;
final int trips;
final double net;
final int minutes;
DayEarnings(
{required this.day,
required this.trips,
required this.net,
required this.minutes});
factory DayEarnings.fromJson(Map<String, dynamic> j) => DayEarnings(
day: j['day']?.toString() ?? '',
trips: int.tryParse(j['trips']?.toString() ?? '0') ?? 0,
net: double.tryParse(j['net']?.toString() ?? '0') ?? 0,
minutes: int.tryParse(j['minutes']?.toString() ?? '0') ?? 0,
);
}
class DeductionItem {
final String product;
final String name;
final double outstanding;
DeductionItem(
{required this.product, required this.name, required this.outstanding});
factory DeductionItem.fromJson(Map<String, dynamic> j) => DeductionItem(
product: j['product']?.toString() ?? '',
name: j['name']?.toString() ?? '',
outstanding: double.tryParse(j['outstanding']?.toString() ?? '0') ?? 0,
);
}
/// ‏الاقتطاعات (تأمين، وقود) تُعرض بجانب الأرباح التي اقتُطعت منها.
/// ‏اقتطاعٌ لا يظهر في مكانه يُقرأ سرقةً مهما كان مشروعاً.
class Deductions {
final double today;
final double week;
final double month;
final double outstanding;
final List<DeductionItem> items;
Deductions({
required this.today,
required this.week,
required this.month,
required this.outstanding,
required this.items,
});
bool get hasAny => today > 0 || outstanding > 0;
factory Deductions.fromJson(Map<String, dynamic> j) => Deductions(
today: double.tryParse(j['today']?.toString() ?? '0') ?? 0,
week: double.tryParse(j['week']?.toString() ?? '0') ?? 0,
month: double.tryParse(j['month']?.toString() ?? '0') ?? 0,
outstanding: double.tryParse(j['outstanding']?.toString() ?? '0') ?? 0,
items: (j['items'] is List)
? (j['items'] as List)
.map((i) => DeductionItem.fromJson(Map<String, dynamic>.from(i)))
.toList()
: <DeductionItem>[],
);
}
class EarningsSummary {
final PeriodEarnings today;
final PeriodEarnings week;
final PeriodEarnings month;
final PeriodEarnings prevWeek;
/// ‏نسبة التغيّر عن الأسبوع الماضي، أو null إن لم يكن هناك ما يُقارَن به.
/// ‏المقارنة بالذات لا بالسائقين الآخرين: ترتيبٌ يُحبط أكثر من يحتاج
/// ‏التحفيز — فهو من سيقع في أسفله.
final double? trendPercent;
final List<DayEarnings> daily;
final DayEarnings? bestDay;
final Deductions deductions;
final String asOf;
EarningsSummary({
required this.today,
required this.week,
required this.month,
required this.prevWeek,
required this.daily,
required this.deductions,
required this.asOf,
this.trendPercent,
this.bestDay,
});
factory EarningsSummary.fromJson(Map<String, dynamic> j) => EarningsSummary(
today: PeriodEarnings.fromJson(Map<String, dynamic>.from(j['today'] ?? {})),
week: PeriodEarnings.fromJson(Map<String, dynamic>.from(j['week'] ?? {})),
month: PeriodEarnings.fromJson(Map<String, dynamic>.from(j['month'] ?? {})),
prevWeek:
PeriodEarnings.fromJson(Map<String, dynamic>.from(j['prev_week'] ?? {})),
trendPercent: j['trend_percent'] == null
? null
: double.tryParse(j['trend_percent'].toString()),
daily: (j['daily'] is List)
? (j['daily'] as List)
.map((d) => DayEarnings.fromJson(Map<String, dynamic>.from(d)))
.toList()
: <DayEarnings>[],
bestDay: j['best_day'] is Map
? DayEarnings.fromJson(Map<String, dynamic>.from(j['best_day']))
: null,
deductions:
Deductions.fromJson(Map<String, dynamic>.from(j['deductions'] ?? {})),
asOf: j['as_of']?.toString() ?? '',
);
}
class TripBreakdown {
final double gross;
final double commission;
final double bonus;
final double net;
/// ‏إعفاء العمولة (سلسلة ٠٪) — هدية تدفع المنصة ثمنها ولم تكن تظهر
/// ‏للسائق في أي شاشة. نميّزها لتُعرض مكسباً لا سطراً فارغاً.
final bool zeroCommission;
TripBreakdown({
required this.gross,
required this.commission,
required this.bonus,
required this.net,
required this.zeroCommission,
});
factory TripBreakdown.fromJson(Map<String, dynamic> j) => TripBreakdown(
gross: double.tryParse(j['gross']?.toString() ?? '0') ?? 0,
commission: double.tryParse(j['commission']?.toString() ?? '0') ?? 0,
bonus: double.tryParse(j['bonus']?.toString() ?? '0') ?? 0,
net: double.tryParse(j['net']?.toString() ?? '0') ?? 0,
zeroCommission: j['zero_commission'] == true,
);
}
class TripEarning {
final int rideId;
final String at;
final String from;
final String to;
final String carType;
final String paymentMethod;
final double distanceKm;
final int minutes;
final TripBreakdown breakdown;
TripEarning({
required this.rideId,
required this.at,
required this.from,
required this.to,
required this.carType,
required this.paymentMethod,
required this.distanceKm,
required this.minutes,
required this.breakdown,
});
factory TripEarning.fromJson(Map<String, dynamic> j) => TripEarning(
rideId: int.tryParse(j['ride_id']?.toString() ?? '0') ?? 0,
at: j['at']?.toString() ?? '',
from: j['from']?.toString() ?? '',
to: j['to']?.toString() ?? '',
carType: j['car_type']?.toString() ?? '',
paymentMethod: j['payment_method']?.toString() ?? '',
distanceKm: double.tryParse(j['distance_km']?.toString() ?? '0') ?? 0,
minutes: int.tryParse(j['minutes']?.toString() ?? '0') ?? 0,
breakdown:
TripBreakdown.fromJson(Map<String, dynamic>.from(j['breakdown'] ?? {})),
);
}
@@ -1,293 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:siro_driver/constant/box_name.dart';
import 'package:siro_driver/constant/links.dart';
import 'package:siro_driver/controller/functions/crud.dart';
import 'package:siro_driver/models/model/driver/rides_summary_model.dart';
import '../../../main.dart';
class StatisticsController extends GetxController {
bool isLoading = false;
// ═══ Weekly Data ═══
List<DayStat> weeklyStats = [];
double weeklyEarnings = 0;
int weeklyTrips = 0;
double weeklyHours = 0;
// ═══ Monthly Data ═══
List<MonthlyPriceDriverModel> monthlyEarnings = [];
List<MonthlyRideModel> monthlyRides = [];
List<MonthlyDataModel> monthlyDuration = [];
double monthlyTotalEarnings = 0;
int monthlyTotalTrips = 0;
double monthlyTotalHours = 0;
String bestDay = '--';
double bestDayEarnings = 0;
// ═══ Tab State ═══
int selectedTab = 0; // 0=weekly, 1=monthly
@override
void onInit() {
super.onInit();
reloadData();
}
void changeTab(int tab) {
selectedTab = tab;
update();
}
// ═══════ جلب البيانات الأسبوعية ═══════
Future<void> fetchWeeklyData() async {
isLoading = true;
update();
try {
var res = await CRUD().get(
link: AppLink.getWeeklyAggregate,
payload: {
'driver_id': box.read(BoxName.driverID).toString(),
},
);
if (res != null && res != 'failure') {
box.write('stats_weekly_cache', res);
var data = jsonDecode(res);
if (data['message'] is List) {
weeklyStats = (data['message'] as List)
.map((e) => DayStat.fromJson(e))
.toList();
weeklyEarnings = weeklyStats.fold(0, (s, d) => s + d.earnings);
weeklyTrips = weeklyStats.fold(0, (s, d) => s + d.trips);
weeklyHours = weeklyStats.fold(0, (s, d) => s + d.hours);
}
}
} catch (e) {
debugPrint('❌ [Stats] Weekly fetch error: $e');
// Fallback: generate from local data
_generateLocalWeeklyData();
}
isLoading = false;
update();
}
// ═══════ جلب البيانات الشهرية ═══════
Future<void> fetchMonthlyData() async {
try {
// 1. أرباح شهرية
var earningsRes = await CRUD().getWallet(
link: AppLink.getAllPaymentFromRide,
payload: {'driverID': box.read(BoxName.driverID).toString()},
);
if (earningsRes != null && earningsRes != 'failure') {
box.write('stats_monthly_earnings_cache', earningsRes);
var data = jsonDecode(earningsRes);
if (data['message'] is List) {
monthlyEarnings = (data['message'] as List)
.map((e) => MonthlyPriceDriverModel.fromJson(e))
.toList();
monthlyTotalEarnings =
monthlyEarnings.fold(0, (s, d) => s + d.pricePerDay);
// أفضل يوم
if (monthlyEarnings.isNotEmpty) {
var best = monthlyEarnings
.reduce((a, b) => a.pricePerDay > b.pricePerDay ? a : b);
bestDay = best.day.toString();
bestDayEarnings = best.pricePerDay;
}
}
}
// 2. رحلات شهرية
var ridesRes = await CRUD().get(
link: AppLink.getRidesDriverByDay,
payload: {'driver_id': box.read(BoxName.driverID).toString()},
);
if (ridesRes != null && ridesRes != 'failure') {
box.write('stats_monthly_rides_cache', ridesRes);
var data = jsonDecode(ridesRes);
if (data['message'] is List) {
monthlyRides = (data['message'] as List)
.map((e) => MonthlyRideModel.fromJson(e))
.toList();
monthlyTotalTrips = monthlyRides.fold(0, (s, d) => s + d.countRide);
}
}
// 3. ساعات شهرية
var durationRes = await CRUD().get(
link: AppLink.getTotalDriverDuration,
payload: {'driver_id': box.read(BoxName.driverID).toString()},
);
if (durationRes != null && durationRes != 'failure') {
box.write('stats_monthly_duration_cache', durationRes);
var data = jsonDecode(durationRes);
if (data['message'] is List) {
monthlyDuration = (data['message'] as List)
.map((e) => MonthlyDataModel.fromJson(e))
.toList();
monthlyTotalHours =
monthlyDuration.fold(0, (s, d) => s + d.totalDuration.toDouble());
}
}
} catch (e) {
debugPrint('❌ [Stats] Monthly fetch error: $e');
}
update();
}
void _generateLocalWeeklyData() {
// Fallback بيانات محلية عند عدم توفر الـ API
final now = DateTime.now();
weeklyStats = List.generate(7, (i) {
final day = now.subtract(Duration(days: 6 - i));
return DayStat(
date: day,
dayName: _getDayName(day.weekday),
earnings: 0,
trips: 0,
hours: 0,
);
});
}
String _getDayName(int weekday) {
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
return days[weekday - 1];
}
bool _isFetching = false;
Future<void> reloadData({bool force = false}) async {
if (_isFetching) return;
_isFetching = true;
// التحقق من التخزين المؤقت (3 ساعات)
String? lastFetchStr = box.read('stats_last_fetch');
if (!force && lastFetchStr != null) {
try {
DateTime lastFetch = DateTime.parse(lastFetchStr);
if (DateTime.now().difference(lastFetch).inHours < 3) {
debugPrint('ℹ️ [Statistics] Loading cached statistics (last fetch: $lastFetchStr)');
// تحميل الكاش الأسبوعي
String? weeklyCache = box.read('stats_weekly_cache');
if (weeklyCache != null) {
var data = jsonDecode(weeklyCache);
if (data['message'] is List) {
weeklyStats = (data['message'] as List)
.map((e) => DayStat.fromJson(e))
.toList();
weeklyEarnings = weeklyStats.fold(0, (s, d) => s + d.earnings);
weeklyTrips = weeklyStats.fold(0, (s, d) => s + d.trips);
weeklyHours = weeklyStats.fold(0, (s, d) => s + d.hours);
}
}
// تحميل الكاش الشهري (الأرباح)
String? earningsCache = box.read('stats_monthly_earnings_cache');
if (earningsCache != null) {
var data = jsonDecode(earningsCache);
if (data['message'] is List) {
monthlyEarnings = (data['message'] as List)
.map((e) => MonthlyPriceDriverModel.fromJson(e))
.toList();
monthlyTotalEarnings =
monthlyEarnings.fold(0, (s, d) => s + d.pricePerDay);
if (monthlyEarnings.isNotEmpty) {
var best = monthlyEarnings
.reduce((a, b) => a.pricePerDay > b.pricePerDay ? a : b);
bestDay = best.day.toString();
bestDayEarnings = best.pricePerDay;
}
}
}
// تحميل الكاش الشهري (الرحلات)
String? ridesCache = box.read('stats_monthly_rides_cache');
if (ridesCache != null) {
var data = jsonDecode(ridesCache);
if (data['message'] is List) {
monthlyRides = (data['message'] as List)
.map((e) => MonthlyRideModel.fromJson(e))
.toList();
monthlyTotalTrips = monthlyRides.fold(0, (s, d) => s + d.countRide);
}
}
// تحميل الكاش الشهري (المدد)
String? durationCache = box.read('stats_monthly_duration_cache');
if (durationCache != null) {
var data = jsonDecode(durationCache);
if (data['message'] is List) {
monthlyDuration = (data['message'] as List)
.map((e) => MonthlyDataModel.fromJson(e))
.toList();
monthlyTotalHours =
monthlyDuration.fold(0, (s, d) => s + d.totalDuration.toDouble());
}
}
_isFetching = false;
update();
return;
}
} catch (e) {
debugPrint('⚠️ [Statistics] Failed to load cached statistics: $e');
}
}
isLoading = true;
update();
try {
debugPrint('📊 [Statistics] Reloading data from server...');
await fetchWeeklyData();
await fetchMonthlyData();
box.write('stats_last_fetch', DateTime.now().toIso8601String());
debugPrint('📊 [Statistics] Data reload complete.');
} catch (e) {
debugPrint('❌ [Statistics] Error reloading data: $e');
} finally {
_isFetching = false;
isLoading = false;
update();
}
}
}
// ═══════ نموذج إحصائية اليوم ═══════
class DayStat {
final DateTime date;
final String dayName;
final double earnings;
final int trips;
final double hours;
DayStat({
required this.date,
required this.dayName,
required this.earnings,
required this.trips,
required this.hours,
});
factory DayStat.fromJson(Map<String, dynamic> json) {
final date =
DateTime.tryParse(json['day']?.toString() ?? '') ?? DateTime.now();
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
return DayStat(
date: date,
dayName: dayNames[date.weekday - 1],
earnings: double.tryParse(json['earnings']?.toString() ?? '0') ?? 0,
trips: int.tryParse(json['trips']?.toString() ?? '0') ?? 0,
hours: double.tryParse(json['hours']?.toString() ?? '0') ?? 0,
);
}
}
@@ -12,7 +12,7 @@ import 'package:siro_driver/main.dart';
import 'package:siro_driver/views/Rate/rate_app_page.dart'; import 'package:siro_driver/views/Rate/rate_app_page.dart';
import 'package:siro_driver/views/auth/captin/contact_us_page.dart'; import 'package:siro_driver/views/auth/captin/contact_us_page.dart';
import 'package:siro_driver/views/auth/captin/invite_driver_screen.dart'; import 'package:siro_driver/views/auth/captin/invite_driver_screen.dart';
import 'package:siro_driver/views/home/statistics/statistics_dashboard.dart'; import 'package:siro_driver/views/home/statistics/earnings_page.dart';
import 'package:siro_driver/views/gamification/challenges_page.dart'; import 'package:siro_driver/views/gamification/challenges_page.dart';
import 'package:siro_driver/views/gamification/leaderboard_page.dart'; import 'package:siro_driver/views/gamification/leaderboard_page.dart';
import 'package:siro_driver/views/home/journal/schedule_page.dart'; import 'package:siro_driver/views/home/journal/schedule_page.dart';
@@ -67,12 +67,12 @@ class AppDrawer extends StatelessWidget {
title: 'Balance'.tr, title: 'Balance'.tr,
icon: Icons.account_balance_wallet, icon: Icons.account_balance_wallet,
color: Colors.green, color: Colors.green,
onTap: () => Get.to(() => WalletCaptainRefactored())), onTap: () => Get.to(() => const WalletCaptainRefactored())),
DrawerItem( DrawerItem(
title: 'Statistics'.tr, title: 'أرباحي',
icon: Icons.bar_chart_rounded, icon: Icons.payments_rounded,
color: Colors.deepPurple, color: Colors.teal,
onTap: () => Get.to(() => StatisticsDashboard())), onTap: () => Get.to(() => EarningsPage())),
DrawerItem( DrawerItem(
title: 'Challenges'.tr, title: 'Challenges'.tr,
icon: Icons.bolt_rounded, icon: Icons.bolt_rounded,
@@ -16,7 +16,7 @@ import '../../../../../controller/functions/crud.dart';
import '../../../../../controller/home/captin/order_request_controller.dart'; import '../../../../../controller/home/captin/order_request_controller.dart';
import '../../../../../print.dart'; import '../../../../../print.dart';
import '../../../../Rate/ride_calculate_driver.dart'; import '../../../../Rate/ride_calculate_driver.dart';
import 'package:siro_driver/views/home/statistics/statistics_dashboard.dart'; import 'package:siro_driver/views/home/statistics/earnings_page.dart';
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// Design Tokens (Responsive) // Design Tokens (Responsive)
@@ -103,7 +103,7 @@ GetBuilder<HomeCaptainController> leftMainMenuCaptainIcons() {
color: _T.blue, color: _T.blue,
tooltip: 'Statistics'.tr, tooltip: 'Statistics'.tr,
onTap: () { onTap: () {
Get.to(() => StatisticsDashboard()); Get.to(() => EarningsPage());
}, },
onLongPress: () => onLongPress: () =>
box.write(BoxName.statusDriverLocation, 'off'), box.write(BoxName.statusDriverLocation, 'off'),
@@ -26,14 +26,29 @@ import 'widgets/financial_summary_card.dart';
import 'widgets/promo_gamification_card.dart'; import 'widgets/promo_gamification_card.dart';
import 'widgets/transaction_preview_item.dart'; import 'widgets/transaction_preview_item.dart';
class WalletCaptainRefactored extends StatelessWidget { class WalletCaptainRefactored extends StatefulWidget {
WalletCaptainRefactored({super.key}); const WalletCaptainRefactored({super.key});
@override
State<WalletCaptainRefactored> createState() =>
_WalletCaptainRefactoredState();
}
class _WalletCaptainRefactoredState extends State<WalletCaptainRefactored> {
final CaptainWalletController controller = Get.put(CaptainWalletController()); final CaptainWalletController controller = Get.put(CaptainWalletController());
@override @override
Widget build(BuildContext context) { void initState() {
super.initState();
// ‏كان التحديث يُنادى داخل build. وbuild يعمل عشرات المرات — عند كل
// ‏إعادة رسم، وكل تغيّر حالة، وكل دوران للشاشة — فكان كل واحدة منها
// ‏تُطلق نداءات شبكية. مكانه هنا: مرة واحدة عند فتح الشاشة، والباقي
// ‏بالسحب للتحديث أو بزر التحديث.
controller.refreshCaptainWallet(); controller.refreshCaptainWallet();
}
@override
Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: FinanceDesignSystem.backgroundColor, backgroundColor: FinanceDesignSystem.backgroundColor,
appBar: AppBar( appBar: AppBar(
@@ -0,0 +1,729 @@
// earnings_page.dart — شاشة أرباح السائق
//
// ‏الترتيب مقصود ويخالف الشاشة السابقة: **اليوم أولاً**. الشاشة القديمة
// ‏كانت تبدأ بترويسة زخرفية بارتفاع ٢٠٠ ثم تبويبَي أسبوعي/شهري — والسائق
// ‏يفتح الشاشة ليعرف كم كسب اليوم، لا ليقرأ تقريراً شهرياً.
//
// ‏ثم الاقتطاعات، ثم أسبوعه، ثم تفصيل كل رحلة.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:siro_driver/constant/currency.dart';
import 'package:siro_driver/constant/finance_design_system.dart';
import 'package:siro_driver/controller/gamification/gamification_controller.dart';
import 'package:siro_driver/controller/home/statistics/earnings_controller.dart';
import 'package:siro_driver/controller/home/statistics/earnings_models.dart';
import 'widgets/behavior_and_achievements.dart';
import 'widgets/daily_goal_widget.dart';
import 'widgets/level_progress_widget.dart';
import 'widgets/stat_summary_card.dart';
class EarningsPage extends StatelessWidget {
EarningsPage({super.key});
// ‏Get.put هنا لا في build: الشاشة السابقة كانت تنادي التحديث داخل
// ‏build، وbuild يعمل عشرات المرات — أي نداء شبكي مع كل إعادة رسم.
final EarningsController c = Get.put(EarningsController());
final GamificationController gam = Get.put(GamificationController());
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: FinanceDesignSystem.backgroundColor,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: Text('أرباحي',
style: TextStyle(
fontWeight: FontWeight.bold,
color: FinanceDesignSystem.primaryDark)),
leading: IconButton(
icon: Icon(Icons.arrow_back_ios_new_rounded,
color: FinanceDesignSystem.primaryDark, size: 20),
onPressed: () => Get.back(),
),
),
body: GetBuilder<EarningsController>(
builder: (ctl) {
if (ctl.isLoading && ctl.summary == null) {
return const Center(child: CircularProgressIndicator());
}
// ‏لا بيانات ولا كاش: نعرض الخطأ صريحاً مع زر إعادة. الشاشة
// ‏السابقة كانت تعرض سبعة أيام بأصفار في هذه الحالة — فيرى
// ‏السائق دخله وقد تبخّر، وهي أسوأ رسالة يمكن أن يتلقّاها.
if (ctl.summary == null) {
return _errorState(ctl);
}
final s = ctl.summary!;
// ‏الترتيب هو القرار الأهم في هذه الشاشة بعد دمج الإحصائيات
// ‏فيها: **المال أولاً، ثم التقدّم**. السائق يفتحها ليعرف كم
// ‏كسب، لا ليرى مستواه — ووضع التلعيب قبل الأرباح كان يجعله
// ‏يمرّر ثلاث شاشات ليصل إلى ما جاء من أجله.
return RefreshIndicator(
onRefresh: () async {
await ctl.load(force: true);
await gam.fetchGamificationData(force: true);
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: FinanceDesignSystem.horizontalPadding,
vertical: 8),
children: [
if (ctl.errorMessage != null) _staleBanner(ctl),
_todayCard(s, ctl),
const SizedBox(height: 16),
if (s.deductions.hasAny) ...[
_deductionsCard(s.deductions),
const SizedBox(height: 16),
],
_weekCard(s, ctl),
const SizedBox(height: 16),
_monthCard(s),
const SizedBox(height: 16),
_tripsSection(ctl),
const SizedBox(height: 24),
_progressSection(),
const SizedBox(height: 40),
],
),
);
},
),
);
}
/// ‏قسم التقدّم — ما كان شاشة «الإحصائيات» المنفصلة.
///
/// ‏في GetBuilder خاصّ به: بيانات التلعيب تصل من نقطة أخرى وفي وقت
/// ‏آخر، وربطها بمُنشئ الأرباح كان سيعيد رسم الشاشة كلها — بأرقام
/// ‏المال وقوائم الرحلات — كلما تحدّث عدّاد إنجاز.
Widget _progressSection() => GetBuilder<GamificationController>(
builder: (gc) {
if (gc.isLoading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('تقدّمي', style: FinanceDesignSystem.headingStyle),
const SizedBox(height: 12),
// ‏هدف اليوم بعد الأرباح مباشرةً منطقياً — لكنه هنا لأنه
// ‏تحفيز لا معلومة: مكانه بعد أن يعرف السائق أرقامه.
DailyGoalWidget(controller: gc),
const SizedBox(height: 16),
LevelProgressWidget(controller: gc),
const SizedBox(height: 16),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
StatSummaryCard(
icon: Icons.local_taxi_rounded,
label: 'Total Trips'.tr,
value: gc.totalTrips.toString(),
color: FinanceDesignSystem.accentBlue,
),
StatSummaryCard(
icon: Icons.star_rounded,
label: 'Rating'.tr,
value: gc.averageRating.toStringAsFixed(1),
color: const Color(0xFFFFD700),
),
StatSummaryCard(
icon: Icons.whatshot_rounded,
label: 'Day Streak'.tr,
value: '${gc.consecutiveDays}',
color: const Color(0xFFFF5722),
),
StatSummaryCard(
icon: Icons.people_rounded,
label: 'Referrals'.tr,
value: gc.totalReferrals.toString(),
color: const Color(0xFF9C27B0),
),
],
),
const SizedBox(height: 16),
BehaviorSection(gc: gc),
const SizedBox(height: 24),
AchievementsSection(gc: gc),
],
);
},
);
Widget _errorState(EarningsController ctl) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off_rounded,
size: 48, color: FinanceDesignSystem.textMuted),
const SizedBox(height: 12),
Text(
ctl.errorMessage ?? 'تعذّر تحميل أرباحك',
textAlign: TextAlign.center,
style: TextStyle(
color: FinanceDesignSystem.primaryDark, fontSize: 15),
),
const SizedBox(height: 6),
Text(
'أرباحك محفوظة ولم يضع منها شيء — المشكلة في التحميل فقط.',
textAlign: TextAlign.center,
style: TextStyle(
color: FinanceDesignSystem.textSecondary, fontSize: 12),
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () => ctl.load(force: true),
icon: const Icon(Icons.refresh_rounded),
label: const Text('إعادة المحاولة'),
),
],
),
),
);
/// ‏بيانات قديمة معروضة بعد فشل التحديث. إظهار ذلك صراحةً أصدق من
/// ‏تقديم رقمٍ عمره ساعات على أنه لحظته.
Widget _staleBanner(EarningsController ctl) => Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(FinanceDesignSystem.mainRadius),
),
child: Row(
children: [
const Icon(Icons.history_rounded, color: Colors.orange, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
'${ctl.errorMessage} — هذه أرقام ${ctl.lastUpdatedLabel}',
style: const TextStyle(color: Colors.orange, fontSize: 12),
),
),
TextButton(
onPressed: () => ctl.load(force: true),
child: const Text('تحديث', style: TextStyle(fontSize: 12)),
),
],
),
);
/// ‏بطاقة اليوم — الرقم الذي فُتحت الشاشة من أجله.
Widget _todayCard(EarningsSummary s, EarningsController ctl) {
final t = s.today;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: FinanceDesignSystem.balanceGradient,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
boxShadow: [FinanceDesignSystem.softShadow],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('أرباح اليوم',
style: TextStyle(color: Colors.white70, fontSize: 13)),
const Spacer(),
Text(ctl.lastUpdatedLabel,
style: const TextStyle(color: Colors.white54, fontSize: 11)),
],
),
const SizedBox(height: 6),
Text('${t.net.toStringAsFixed(2)} ${CurrencyHelper.currency}',
style: const TextStyle(
color: Colors.white,
fontSize: 34,
fontWeight: FontWeight.bold)),
const SizedBox(height: 14),
Row(
children: [
_miniStat(Icons.local_taxi_rounded, '${t.trips}', 'رحلة'),
_miniStat(Icons.schedule_rounded,
t.hours.toStringAsFixed(1), 'ساعة'),
_miniStat(Icons.route_rounded,
t.distance.toStringAsFixed(0), 'كم'),
],
),
// ‏تركيبة المبلغ ظاهرة: إجمالي ← عمولة ← صافي. السائق الذي يرى
// ‏التركيبة يفهم، والذي يرى الصافي وحده يخمّن ويشكّ.
if (t.gross > 0) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_breakdownBit('دفع الركاب', t.gross),
_breakdownBit('عمولة المنصة', t.commission, dim: true),
_breakdownBit('لك', t.net, bold: true),
],
),
),
],
],
),
);
}
Widget _miniStat(IconData icon, String value, String label) => Expanded(
child: Row(
children: [
Icon(icon, color: Colors.white70, size: 16),
const SizedBox(width: 6),
Text(value,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700)),
const SizedBox(width: 3),
Text(label,
style: const TextStyle(color: Colors.white54, fontSize: 11)),
],
),
);
Widget _breakdownBit(String label, double value,
{bool dim = false, bool bold = false}) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6), fontSize: 10)),
const SizedBox(height: 2),
Text(value.toStringAsFixed(2),
style: TextStyle(
color: dim
? Colors.white.withValues(alpha: 0.7)
: Colors.white,
fontSize: bold ? 15 : 13,
fontWeight: bold ? FontWeight.bold : FontWeight.w500)),
],
);
/// ‏الاقتطاعات بجانب الأرباح التي اقتُطعت منها. عرضها في شاشة المنتج
/// ‏وحدها كان سيجعل الخصم مفاجأةً — وهي أخطر ما يصيب منتجاً ائتمانياً.
Widget _deductionsCard(Deductions d) => Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
border: Border.all(color: FinanceDesignSystem.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.account_balance_wallet_rounded,
size: 18, color: FinanceDesignSystem.accentBlue),
const SizedBox(width: 8),
Text('الاقتطاعات', style: FinanceDesignSystem.headingStyle),
const Spacer(),
if (d.today > 0)
Text('اليوم ${d.today.toStringAsFixed(2)}',
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: 12)),
],
),
const SizedBox(height: 10),
...d.items.map((i) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Expanded(
child: Text(i.name,
style: TextStyle(
color: FinanceDesignSystem.primaryDark,
fontSize: 13))),
Text(
'متبقٍّ ${i.outstanding.toStringAsFixed(2)} ${CurrencyHelper.currency}',
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: 12)),
],
),
)),
if (d.outstanding > 0) ...[
const SizedBox(height: 6),
Text(
'يُقتطع تدريجياً من أرباحك بسقف يومي، ولا يُخصم منك في يوم بلا دخل.',
style: TextStyle(
color: FinanceDesignSystem.textSecondary, fontSize: 11),
),
],
],
),
);
Widget _weekCard(EarningsSummary s, EarningsController ctl) {
final maxNet = s.daily.fold<double>(
0, (m, d) => d.net > m ? d.net : m);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
border: Border.all(color: FinanceDesignSystem.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('آخر ٧ أيام', style: FinanceDesignSystem.headingStyle),
const Spacer(),
if (s.trendPercent != null) _trendChip(s.trendPercent!),
],
),
const SizedBox(height: 4),
Text(
'${s.week.net.toStringAsFixed(2)} ${CurrencyHelper.currency} · ${s.week.trips} رحلة',
style: TextStyle(
color: FinanceDesignSystem.textSecondary, fontSize: 13)),
const SizedBox(height: 16),
SizedBox(
height: 110,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: s.daily.map((d) {
final selected = ctl.selectedDay == d.day;
final ratio = maxNet > 0 ? d.net / maxNet : 0.0;
return Expanded(
child: GestureDetector(
// ‏نقر العمود يعرض رحلات ذلك اليوم. الرسم البياني الذي
// ‏لا يُنقر عليه زينة؛ والسائق يسأل عن يومٍ بعينه.
onTap: () => ctl.loadTrips(day: selected ? null : d.day),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(d.net > 0 ? d.net.toStringAsFixed(0) : '',
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: 9)),
const SizedBox(height: 4),
Container(
height: 60 * (ratio == 0 ? 0.03 : ratio),
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: selected
? FinanceDesignSystem.accentBlue
: FinanceDesignSystem.accentBlue
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 6),
Text(_dayLabel(d.day),
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: 10)),
],
),
),
);
}).toList(),
),
),
],
),
);
}
/// ‏الشهر — يحلّ محل الرسم الشهري في الشاشة القديمة.
///
/// ‏أرقام لا رسم بياني: الرسم الشهري كان ثلاثين عموداً ضيّقاً لا يُقرأ
/// ‏منها شيء على شاشة هاتف. السائق يسأل عن مجموع شهره ومعدّله اليومي،
/// ‏لا عن شكل توزيعه.
Widget _monthCard(EarningsSummary s) {
final m = s.month;
final avgPerDay = DateTime.now().day > 0 ? m.net / DateTime.now().day : 0.0;
final perHour = m.hours > 0 ? m.net / m.hours : 0.0;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
border: Border.all(color: FinanceDesignSystem.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('هذا الشهر', style: FinanceDesignSystem.headingStyle),
const SizedBox(height: 12),
Row(
children: [
_monthStat('الصافي', m.net.toStringAsFixed(0)),
_monthStat('رحلة', '${m.trips}'),
_monthStat('ساعة', m.hours.toStringAsFixed(0)),
],
),
const SizedBox(height: 12),
Row(
children: [
// ‏معدّل الساعة هو الرقم الذي يقرّر به السائق متى يعمل —
// ‏وأنفع من أي مجموع، لأن المجموع يكافئ الساعات لا الكفاءة.
_monthStat('معدّل الساعة', perHour.toStringAsFixed(1)),
_monthStat('معدّل اليوم', avgPerDay.toStringAsFixed(1)),
_monthStat('العمولة', m.commission.toStringAsFixed(0)),
],
),
],
),
);
}
Widget _monthStat(String label, String value) => Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(value,
style: TextStyle(
color: FinanceDesignSystem.primaryDark,
fontSize: 17,
fontWeight: FontWeight.bold)),
const SizedBox(height: 2),
Text(label,
style: TextStyle(
color: FinanceDesignSystem.textSecondary, fontSize: 11)),
],
),
);
Widget _trendChip(double trend) {
final up = trend >= 0;
final color =
up ? FinanceDesignSystem.successGreen : FinanceDesignSystem.dangerRed;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(up ? Icons.trending_up_rounded : Icons.trending_down_rounded,
size: 14, color: color),
const SizedBox(width: 4),
// ‏المقارنة بأسبوعه هو لا بالسائقين الآخرين.
Text('${trend.abs().toStringAsFixed(0)}٪ عن الأسبوع الماضي',
style: TextStyle(color: color, fontSize: 11)),
],
),
);
}
String _dayLabel(String iso) {
final d = DateTime.tryParse(iso);
if (d == null) return '';
const names = ['اثن', 'ثلا', 'أرب', 'خمي', 'جمع', 'سبت', 'أحد'];
return names[d.weekday - 1];
}
Widget _tripsSection(EarningsController ctl) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
ctl.selectedDay == null
? 'رحلات الأسبوع'
: 'رحلات ${ctl.selectedDay}',
style: FinanceDesignSystem.headingStyle),
const Spacer(),
if (ctl.selectedDay != null)
TextButton(
onPressed: () => ctl.loadTrips(),
child: const Text('كل الأسبوع',
style: TextStyle(fontSize: 12))),
],
),
const SizedBox(height: 8),
if (ctl.isLoadingTrips)
const Padding(
padding: EdgeInsets.all(20),
child: Center(child: CircularProgressIndicator()),
)
else if (ctl.trips.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Center(
child: Text('لا رحلات في هذه الفترة',
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: 13)),
),
)
else
...ctl.trips.map(_tripTile),
],
);
/// ‏تفصيل الرحلة الواحدة — الإجابة على السؤال الأول عند كل سائق:
/// ‏«لماذا أخذت هذا المبلغ من هذه الرحلة؟»
Widget _tripTile(TripEarning t) {
final b = t.breakdown;
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(FinanceDesignSystem.mainRadius),
border: Border.all(color: FinanceDesignSystem.borderColor),
),
child: Theme(
data: ThemeData(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 14),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
title: Row(
children: [
Expanded(
child: Text(
'${t.from} ← ${t.to}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: FinanceDesignSystem.primaryDark,
fontSize: 13,
fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 8),
Text('${b.net.toStringAsFixed(2)} ${CurrencyHelper.currency}',
style: TextStyle(
color: FinanceDesignSystem.successGreen,
fontWeight: FontWeight.bold,
fontSize: 14)),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Text(
'${_timeLabel(t.at)} · ${t.distanceKm.toStringAsFixed(1)} كم · ${t.minutes} د',
style: TextStyle(
color: FinanceDesignSystem.textSecondary, fontSize: 11),
),
if (b.zeroCommission) ...[
const SizedBox(width: 6),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: FinanceDesignSystem.successGreen
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text('عمولة معفاة',
style: TextStyle(
color: FinanceDesignSystem.successGreen,
fontSize: 9)),
),
],
],
),
),
children: [
_row('ما دفعه الراكب', b.gross),
if (b.bonus > 0) _row('مكافأة', b.bonus, positive: true),
_row('عمولة المنصة', b.commission, negative: true),
Divider(color: FinanceDesignSystem.borderColor, height: 16),
_row('صافي لك', b.net, bold: true),
const SizedBox(height: 6),
Row(
children: [
Icon(
t.paymentMethod.toLowerCase().contains('cash')
? Icons.money_rounded
: Icons.credit_card_rounded,
size: 13,
color: FinanceDesignSystem.textMuted),
const SizedBox(width: 5),
Text(t.paymentMethod,
style: TextStyle(
color: FinanceDesignSystem.textMuted, fontSize: 11)),
const Spacer(),
Text('#${t.rideId}',
style: TextStyle(
color: FinanceDesignSystem.textMuted, fontSize: 11)),
],
),
],
),
),
);
}
Widget _row(String label, double value,
{bool bold = false, bool negative = false, bool positive = false}) =>
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(label,
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: bold ? 13 : 12)),
),
Text(
'${negative ? '− ' : positive ? '+ ' : ''}${value.toStringAsFixed(2)}',
style: TextStyle(
color: negative
? FinanceDesignSystem.dangerRed
: positive
? FinanceDesignSystem.successGreen
: FinanceDesignSystem.primaryDark,
fontSize: bold ? 14 : 12,
fontWeight: bold ? FontWeight.bold : FontWeight.w500,
),
),
],
),
);
String _timeLabel(String at) {
final d = DateTime.tryParse(at);
if (d == null) return '';
final h = d.hour.toString().padLeft(2, '0');
final m = d.minute.toString().padLeft(2, '0');
return '$h:$m';
}
}
@@ -1,522 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:siro_driver/constant/finance_design_system.dart';
import 'package:siro_driver/controller/gamification/gamification_controller.dart';
import 'package:siro_driver/controller/home/statistics/statistics_controller.dart';
import 'widgets/stat_summary_card.dart';
import 'widgets/daily_goal_widget.dart';
import 'widgets/level_progress_widget.dart';
import 'widgets/today_chart_widget.dart';
import 'widgets/weekly_chart_widget.dart';
import 'widgets/monthly_chart_widget.dart';
class StatisticsDashboard extends StatelessWidget {
StatisticsDashboard({super.key});
final GamificationController gamController =
Get.put(GamificationController());
final StatisticsController statsController = Get.put(StatisticsController());
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: FinanceDesignSystem.backgroundColor,
body: GetBuilder<GamificationController>(
builder: (gc) {
return GetBuilder<StatisticsController>(
builder: (sc) {
if (gc.isLoading || sc.isLoading) {
return Center(
child: CircularProgressIndicator(
color: FinanceDesignSystem.primaryDark),
);
}
return CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
// ═══════ App Bar ═══════
SliverAppBar(
expandedHeight: 200,
pinned: true,
stretch: true,
backgroundColor: Get.isDarkMode
? FinanceDesignSystem.primaryDark
: const Color(0xFF0A0E21),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded,
color: Colors.white, size: 20),
onPressed: () => Get.back(),
),
actions: [
IconButton(
icon: const Icon(Icons.refresh_rounded,
color: Colors.white),
onPressed: () {
gc.fetchGamificationData(force: true);
sc.reloadData(force: true);
},
),
],
flexibleSpace: FlexibleSpaceBar(
centerTitle: true,
title: Text(
'Statistics'.tr,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
background: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: BoxDecoration(
gradient: FinanceDesignSystem.balanceGradient,
),
),
// أيقونة زخرفية
Positioned(
right: -40,
top: -20,
child: Icon(
Icons.bar_chart_rounded,
size: 200,
color: Colors.white.withOpacity(0.04),
),
),
Positioned(
left: -30,
bottom: 20,
child: Icon(
Icons.emoji_events_rounded,
size: 120,
color: Colors.white.withOpacity(0.03),
),
),
// بطاقة المستوى في الهيدر
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 40),
Text(
gc.currentLevel.emoji,
style: const TextStyle(fontSize: 40),
),
const SizedBox(height: 4),
Text(
Get.locale?.languageCode == 'ar'
? gc.currentLevel.nameAr
: gc.currentLevel.nameEn,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'${gc.unlockedCount}/${gc.totalAchievements} ${'Achievements'.tr}',
style: TextStyle(
color: Colors.white.withOpacity(0.5),
fontSize: 12,
),
),
],
),
),
],
),
),
),
// ═══════ المحتوى ═══════
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 40),
sliver: SliverList(
delegate: SliverChildListDelegate([
// 1. بطاقات الإحصائيات الأربعة
_buildSummaryCards(gc),
const SizedBox(
height: FinanceDesignSystem.verticalSectionPadding),
// 2. الهدف اليومي
DailyGoalWidget(controller: gc),
const SizedBox(
height: FinanceDesignSystem.verticalSectionPadding),
// 3. تقدم المستوى
LevelProgressWidget(controller: gc),
const SizedBox(
height: FinanceDesignSystem.verticalSectionPadding),
// 4. إحصائيات اليوم
TodayChartWidget(),
const SizedBox(
height: FinanceDesignSystem.verticalSectionPadding),
// 5. الرسم البياني الأسبوعي
const WeeklyChartWidget(),
const SizedBox(
height: FinanceDesignSystem.verticalSectionPadding),
// 6. التقرير الشهري
const MonthlyChartWidget(),
const SizedBox(
height: FinanceDesignSystem.verticalSectionPadding),
// 7. تقييم سلوك القيادة
_buildBehaviorSection(gc),
const SizedBox(
height: FinanceDesignSystem.verticalSectionPadding),
// 8. الإنجازات
_buildAchievementsSection(gc),
]),
),
),
],
);
},
);
},
),
);
}
// ═══════ بطاقات الإحصائيات ═══════
Widget _buildSummaryCards(GamificationController gc) {
return GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
StatSummaryCard(
icon: Icons.local_taxi_rounded,
label: 'Total Trips'.tr,
value: gc.totalTrips.toString(),
color: FinanceDesignSystem.accentBlue,
),
StatSummaryCard(
icon: Icons.star_rounded,
label: 'Rating'.tr,
value: gc.averageRating.toStringAsFixed(1),
color: const Color(0xFFFFD700),
),
StatSummaryCard(
icon: Icons.whatshot_rounded,
label: 'Day Streak'.tr,
value: '${gc.consecutiveDays}',
color: const Color(0xFFFF5722),
),
StatSummaryCard(
icon: Icons.people_rounded,
label: 'Referrals'.tr,
value: gc.totalReferrals.toString(),
color: const Color(0xFF9C27B0),
),
],
);
}
// ═══════ قسم تقييم السلوك ═══════
Widget _buildBehaviorSection(GamificationController gc) {
bool isExcellent = gc.behaviorScore >= 90;
bool isGood = gc.behaviorScore >= 75 && gc.behaviorScore < 90;
Color statusColor = isExcellent
? Colors.green
: isGood
? Colors.orange
: Colors.red;
String statusText = isExcellent
? 'Excellent'.tr
: isGood
? 'Good'.tr
: 'Needs Improvement'.tr;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(24),
boxShadow: const [
BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4))
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.shield_rounded,
color: FinanceDesignSystem.accentBlue),
const SizedBox(width: 8),
Text('Driving Behavior'.tr,
style: FinanceDesignSystem.headingStyle),
],
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_behaviorStatItem(
title: 'Score'.tr,
value: '${gc.behaviorScore}%',
icon: Icons.speed_rounded,
color: FinanceDesignSystem.accentBlue,
),
_behaviorStatItem(
title: 'Max Speed'.tr,
value: '${gc.maxSpeed.toStringAsFixed(0)} km/h',
icon: Icons.directions_car_rounded,
color: Colors.orange,
),
_behaviorStatItem(
title: 'Hard Brakes'.tr,
value: '${gc.hardBrakes}',
icon: Icons.warning_rounded,
color: Colors.red,
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: gc.behaviorScore / 100,
backgroundColor: FinanceDesignSystem.backgroundColor,
valueColor: AlwaysStoppedAnimation<Color>(statusColor),
minHeight: 8,
),
),
],
),
);
}
Widget _behaviorStatItem(
{required String title,
required String value,
required IconData icon,
required Color color}) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(height: 8),
Text(value,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: FinanceDesignSystem.primaryDark)),
Text(title,
style: TextStyle(
fontSize: 12, color: FinanceDesignSystem.textSecondary)),
],
);
}
// ═══════ قسم الإنجازات ═══════
Widget _buildAchievementsSection(GamificationController gc) {
final unlockedAch = gc.achievements.where((a) => a.isUnlocked).toList();
final lockedAch = gc.achievements.where((a) => !a.isUnlocked).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Achievements'.tr, style: FinanceDesignSystem.headingStyle),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: FinanceDesignSystem.accentBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${gc.unlockedCount}/${gc.totalAchievements}',
style: TextStyle(
color: FinanceDesignSystem.accentBlue,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 16),
// الإنجازات المفتوحة
if (unlockedAch.isNotEmpty) ...[
...unlockedAch.map((a) => _buildAchievementCard(a, true)),
const SizedBox(height: 16),
],
// الإنجازات المقفلة
...lockedAch.map((a) => _buildAchievementCard(a, false)),
],
);
}
Widget _buildAchievementCard(Achievement ach, bool unlocked) {
final isAr = Get.locale?.languageCode == 'ar';
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: unlocked ? Colors.white : Colors.grey.shade50,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
border: unlocked
? Border.all(color: ach.color.withOpacity(0.3), width: 1.5)
: null,
boxShadow: unlocked
? [
BoxShadow(
color: ach.color.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
]
: null,
),
child: Row(
children: [
// أيقونة الإنجاز
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color:
unlocked ? ach.color.withOpacity(0.15) : Colors.grey.shade200,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
ach.icon,
color: unlocked ? ach.color : Colors.grey.shade400,
size: 26,
),
),
const SizedBox(width: 14),
// المعلومات
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isAr ? ach.titleAr : ach.titleEn,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: unlocked
? FinanceDesignSystem.primaryDark
: Colors.grey.shade500,
),
),
const SizedBox(height: 2),
Text(
isAr ? ach.descriptionAr : ach.descriptionEn,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade500,
),
),
if (!unlocked) ...[
const SizedBox(height: 8),
// شريط التقدم
Stack(
children: [
Container(
height: 6,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(3),
),
),
FractionallySizedBox(
widthFactor: ach.progress,
child: Container(
height: 6,
decoration: BoxDecoration(
color: ach.color.withOpacity(0.6),
borderRadius: BorderRadius.circular(3),
),
),
),
],
),
const SizedBox(height: 4),
Text(
'${ach.currentProgress}/${ach.target}',
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade400,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
// أيقونة الحالة
if (unlocked)
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: ach.color.withOpacity(0.15),
shape: BoxShape.circle,
),
child: Icon(
Icons.check_rounded,
color: ach.color,
size: 18,
),
)
else
Icon(
Icons.lock_outline_rounded,
color: Colors.grey.shade300,
size: 20,
),
],
),
);
}
}
@@ -0,0 +1,311 @@
// behavior_and_achievements.dart — سلوك القيادة والإنجازات
//
// ‏نُقلا من `statistics_dashboard.dart` (حيث كانا دالتين خاصّتين) إلى
// ‏ودجتين مستقلّتين عند دمج شاشتَي الأرباح والإحصائيات في شاشة واحدة.
// ‏نسخهما كان سيعني نسختين تفترقان عند أول تعديل.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../../constant/finance_design_system.dart';
import '../../../../controller/gamification/gamification_controller.dart';
class BehaviorSection extends StatelessWidget {
final GamificationController gc;
const BehaviorSection({super.key, required this.gc});
@override
Widget build(BuildContext context) {
final bool isExcellent = gc.behaviorScore >= 90;
final bool isGood = gc.behaviorScore >= 75 && gc.behaviorScore < 90;
final Color statusColor = isExcellent
? Colors.green
: isGood
? Colors.orange
: Colors.red;
final String statusText = isExcellent
? 'Excellent'.tr
: isGood
? 'Good'.tr
: 'Needs Improvement'.tr;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(24),
boxShadow: const [
BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4))
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.shield_rounded,
color: FinanceDesignSystem.accentBlue),
const SizedBox(width: 8),
Text('Driving Behavior'.tr,
style: FinanceDesignSystem.headingStyle),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_statItem(
title: 'Score'.tr,
value: '${gc.behaviorScore}%',
icon: Icons.speed_rounded,
color: FinanceDesignSystem.accentBlue,
),
_statItem(
title: 'Max Speed'.tr,
value: '${gc.maxSpeed.toStringAsFixed(0)} km/h',
icon: Icons.directions_car_rounded,
color: Colors.orange,
),
_statItem(
title: 'Hard Brakes'.tr,
value: '${gc.hardBrakes}',
icon: Icons.warning_rounded,
color: Colors.red,
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: gc.behaviorScore / 100,
backgroundColor: FinanceDesignSystem.backgroundColor,
valueColor: AlwaysStoppedAnimation<Color>(statusColor),
minHeight: 8,
),
),
],
),
);
}
Widget _statItem({
required String title,
required String value,
required IconData icon,
required Color color,
}) =>
Column(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(height: 8),
Text(value,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: FinanceDesignSystem.primaryDark)),
Text(title,
style: TextStyle(
fontSize: 12, color: FinanceDesignSystem.textSecondary)),
],
);
}
class AchievementsSection extends StatelessWidget {
final GamificationController gc;
const AchievementsSection({super.key, required this.gc});
@override
Widget build(BuildContext context) {
final unlocked = gc.achievements.where((a) => a.isUnlocked).toList();
final locked = gc.achievements.where((a) => !a.isUnlocked).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Achievements'.tr, style: FinanceDesignSystem.headingStyle),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: FinanceDesignSystem.accentBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${gc.unlockedCount}/${gc.totalAchievements}',
style: TextStyle(
color: FinanceDesignSystem.accentBlue,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 16),
if (unlocked.isNotEmpty) ...[
...unlocked.map((a) => _AchievementCard(ach: a, unlocked: true)),
const SizedBox(height: 16),
],
...locked.map((a) => _AchievementCard(ach: a, unlocked: false)),
],
);
}
}
class _AchievementCard extends StatelessWidget {
final Achievement ach;
final bool unlocked;
const _AchievementCard({required this.ach, required this.unlocked});
@override
Widget build(BuildContext context) {
final isAr = Get.locale?.languageCode == 'ar';
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
// ‏كان اللون هنا Colors.white ثابتاً — يختفي النص عليه في الوضع
// ‏الداكن. نستعمل لون البطاقة من نظام التصميم.
color: unlocked
? FinanceDesignSystem.cardColor
: FinanceDesignSystem.cardColor.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
border: unlocked
? Border.all(color: ach.color.withValues(alpha: 0.3), width: 1.5)
: Border.all(color: FinanceDesignSystem.borderColor),
boxShadow: unlocked
? [
BoxShadow(
color: ach.color.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
]
: null,
),
child: Row(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: unlocked
? ach.color.withValues(alpha: 0.15)
: FinanceDesignSystem.textMuted.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
ach.icon,
color: unlocked ? ach.color : FinanceDesignSystem.textMuted,
size: 26,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isAr ? ach.titleAr : ach.titleEn,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: unlocked
? FinanceDesignSystem.primaryDark
: FinanceDesignSystem.textSecondary,
),
),
const SizedBox(height: 2),
Text(
isAr ? ach.descriptionAr : ach.descriptionEn,
style: TextStyle(
fontSize: 11,
color: FinanceDesignSystem.textSecondary),
),
if (!unlocked) ...[
const SizedBox(height: 8),
Stack(
children: [
Container(
height: 6,
decoration: BoxDecoration(
color: FinanceDesignSystem.textMuted
.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(3),
),
),
FractionallySizedBox(
widthFactor: ach.progress,
child: Container(
height: 6,
decoration: BoxDecoration(
color: ach.color.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(3),
),
),
),
],
),
const SizedBox(height: 4),
Text(
'${ach.currentProgress}/${ach.target}',
style: TextStyle(
fontSize: 10,
color: FinanceDesignSystem.textMuted,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
if (unlocked)
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: ach.color.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.check_rounded, color: ach.color, size: 18),
)
else
Icon(Icons.lock_outline_rounded,
color: FinanceDesignSystem.textMuted, size: 20),
],
),
);
}
}
@@ -1,160 +0,0 @@
import 'package:siro_driver/constant/currency.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../../constant/finance_design_system.dart';
import '../../../../controller/home/statistics/statistics_controller.dart';
class MonthlyChartWidget extends StatelessWidget {
const MonthlyChartWidget({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<StatisticsController>(
builder: (sc) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 4))
],
),
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Monthly Report'.tr,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: FinanceDesignSystem.primaryDark)),
const SizedBox(height: 16),
// Summary Row
Row(children: [
_summaryTile(
'Total Earnings'.tr,
'${sc.monthlyTotalEarnings.toStringAsFixed(0)} ${CurrencyHelper.currency}',
FinanceDesignSystem.successGreen),
const SizedBox(width: 12),
_summaryTile('Total Trips'.tr, '${sc.monthlyTotalTrips}',
FinanceDesignSystem.accentBlue),
const SizedBox(width: 12),
_summaryTile(
'Best Day'.tr, '${sc.bestDay}', const Color(0xFFFFD700)),
]),
const SizedBox(height: 20),
// Monthly Earnings Line Chart
SizedBox(
height: 200,
child: sc.monthlyEarnings.isEmpty
? Center(
child: Text('No data yet'.tr,
style: TextStyle(color: Colors.grey.shade400)))
: LineChart(LineChartData(
lineTouchData: LineTouchData(
enabled: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots
.map((s) => LineTooltipItem(
'${s.y.toStringAsFixed(0)} ${CurrencyHelper.currency}',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12),
))
.toList(),
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (v) => FlLine(
color: FinanceDesignSystem.borderColor,
strokeWidth: 1),
),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 5,
getTitlesWidget: (v, m) => Padding(
padding: const EdgeInsets.only(top: 8),
child: Text('${v.toInt()}',
style: TextStyle(
fontSize: 10,
color: FinanceDesignSystem.textSecondary)),
),
)),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: sc.monthlyEarnings
.map((e) =>
FlSpot(e.day.toDouble(), e.pricePerDay))
.toList(),
isCurved: true,
curveSmoothness: 0.3,
color: FinanceDesignSystem.accentBlue,
barWidth: 3,
dotData: FlDotData(
show: true,
getDotPainter: (s, p, d, i) => FlDotCirclePainter(
radius: 3,
color: FinanceDesignSystem.accentBlue,
strokeWidth: 1,
strokeColor: Colors.white),
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
FinanceDesignSystem.accentBlue.withOpacity(0.2),
FinanceDesignSystem.accentBlue.withOpacity(0.0)
],
),
),
),
],
)),
),
]),
);
},
);
}
Widget _summaryTile(String label, String value, Color color) {
return Expanded(
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(12)),
child: Column(children: [
Text(value,
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w800, color: color)),
const SizedBox(height: 2),
Text(label,
style: TextStyle(
fontSize: 9, color: FinanceDesignSystem.textSecondary),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis),
]),
),
);
}
}
@@ -1,86 +0,0 @@
import 'package:siro_driver/constant/currency.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:siro_driver/controller/home/captin/home_captain_controller.dart';
import '../../../../constant/finance_design_system.dart';
class TodayChartWidget extends StatelessWidget {
TodayChartWidget({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<HomeCaptainController>(
builder: (hc) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 4)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Today Overview'.tr,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: FinanceDesignSystem.primaryDark)),
const SizedBox(height: 4),
Text('Summary of your daily activity'.tr,
style: TextStyle(
fontSize: 11, color: FinanceDesignSystem.textSecondary)),
const SizedBox(height: 20),
_buildRow(
Icons.monetization_on_rounded,
'Earnings'.tr,
'${hc.totalMoneyToday} ${CurrencyHelper.currency}',
FinanceDesignSystem.successGreen),
const Divider(height: 24),
_buildRow(Icons.local_taxi_rounded, 'Rides'.tr, hc.countRideToday,
FinanceDesignSystem.accentBlue),
const Divider(height: 24),
Obx(() => _buildRow(Icons.timer_rounded, 'Online Duration'.tr,
hc.totalDurationDisplay.value, const Color(0xFFFF9800))),
const Divider(height: 24),
_buildRow(Icons.cancel_outlined, 'Refused'.tr, hc.countRefuse,
FinanceDesignSystem.dangerRed),
],
),
);
},
);
}
Widget _buildRow(IconData icon, String label, String value, Color color) {
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(10)),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Text(label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: FinanceDesignSystem.primaryDark))),
Text(value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: FinanceDesignSystem.primaryDark,
fontFamily: 'digit')),
],
);
}
}
@@ -1,173 +0,0 @@
import 'package:siro_driver/constant/currency.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../../constant/finance_design_system.dart';
import '../../../../controller/home/statistics/statistics_controller.dart';
class WeeklyChartWidget extends StatelessWidget {
const WeeklyChartWidget({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<StatisticsController>(
builder: (sc) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 4))
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text('Weekly Earnings'.tr,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: FinanceDesignSystem.primaryDark)),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: FinanceDesignSystem.successGreen.withOpacity(0.1),
borderRadius: BorderRadius.circular(12)),
child: Text(
'${sc.weeklyEarnings.toStringAsFixed(0)} ${CurrencyHelper.currency}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: FinanceDesignSystem.successGreen)),
),
]),
const SizedBox(height: 8),
Row(children: [
_miniStat(Icons.local_taxi_rounded, '${sc.weeklyTrips}',
'Rides'.tr, FinanceDesignSystem.accentBlue),
const SizedBox(width: 16),
_miniStat(
Icons.timer_rounded,
'${sc.weeklyHours.toStringAsFixed(1)}h',
'Hours'.tr,
const Color(0xFFFF9800)),
]),
const SizedBox(height: 20),
SizedBox(
height: 180,
child: sc.weeklyStats.isEmpty
? Center(
child: Text('No data yet'.tr,
style: TextStyle(color: Colors.grey.shade400)))
: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: _getMaxY(sc.weeklyStats),
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, gi, rod, ri) =>
BarTooltipItem(
'${rod.toY.toStringAsFixed(0)} ${CurrencyHelper.currency}',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12),
),
),
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (v, m) {
final idx = v.toInt();
if (idx >= 0 &&
idx < sc.weeklyStats.length) {
return Padding(
padding:
const EdgeInsets.only(top: 8),
child: Text(
sc.weeklyStats[idx].dayName.tr,
style: TextStyle(
fontSize: 10,
color: FinanceDesignSystem
.textSecondary)));
}
return const SizedBox.shrink();
})),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
gridData: const FlGridData(show: false),
barGroups: List.generate(sc.weeklyStats.length, (i) {
final stat = sc.weeklyStats[i];
final isToday = i == sc.weeklyStats.length - 1;
return BarChartGroupData(x: i, barRods: [
BarChartRodData(
toY: stat.earnings,
width: 20,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(6)),
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: isToday
? [
FinanceDesignSystem.accentBlue,
const Color(0xFF82B1FF)
]
: [
FinanceDesignSystem.primaryDark
.withOpacity(0.6),
FinanceDesignSystem.primaryDark
.withOpacity(0.3)
],
),
),
]);
}),
),
),
),
],
),
);
},
);
}
Widget _miniStat(IconData icon, String value, String label, Color color) {
return Row(children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 4),
Text(value,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: FinanceDesignSystem.primaryDark)),
const SizedBox(width: 4),
Text(label,
style: TextStyle(
fontSize: 11, color: FinanceDesignSystem.textSecondary)),
]);
}
double _getMaxY(List<DayStat> stats) {
if (stats.isEmpty) return 100;
final max = stats.map((s) => s.earnings).reduce((a, b) => a > b ? a : b);
return max <= 0 ? 100 : max * 1.2;
}
}