Update: 2026-08-08 12:58:16
This commit is contained in:
@@ -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.*
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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('تعذّر تفعيل الوثيقة حالياً');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
], 'أُلغيت القسيمة وعاد المبلغ إلى سقفك');
|
||||||
@@ -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'],
|
||||||
|
], 'تم تفعيل محفظة الوقود');
|
||||||
@@ -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('تعذّر توليد رمز قسيمة فريد');
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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'],
|
||||||
|
], 'تم الصرف');
|
||||||
@@ -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),
|
||||||
|
], 'أعطِ هذا الرمز لعامل المحطة');
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -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>
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user