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

This commit is contained in:
Hamza-Ayed
2026-08-08 12:58:17 +03:00
parent 611b5e616d
commit fae0e4a38a
55 changed files with 5771 additions and 1820 deletions
+89
View File
@@ -0,0 +1,89 @@
<?php
// Admin/fuel/drawdowns.php — حركة القسائم وملخّص الائتمان
// POST (اختياري): station_id, status, driver_id, limit
//
// ‏الشاشة التي تُجيب: كم أقرضنا؟ كم استُرد؟ وأي محطة تعمل؟
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
$status = filterRequest('status');
$driverId = filterRequest('driver_id');
$limit = (int) (filterRequest('limit', 'int') ?: 100);
$limit = max(1, min($limit, 500));
$where = [];
$params = [];
if ($stationId > 0) {
$where[] = 'v.station_id = ?';
$params[] = $stationId;
}
if (!empty($status)) {
$where[] = 'v.status = ?';
$params[] = $status;
}
if (!empty($driverId)) {
$where[] = 'v.driver_id = ?';
$params[] = $driverId;
}
$clause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
try {
$st = $con->prepare("
SELECT v.id, v.driver_id, v.code, v.amount_authorized, v.amount_redeemed,
v.currency, v.status, v.expires_at, v.redeemed_at, v.created_at,
s.name_ar AS station_name
FROM fuel_vouchers v
LEFT JOIN fuel_stations s ON s.id = v.station_id
$clause
ORDER BY v.id DESC
LIMIT $limit
");
$st->execute($params);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
// ‏الملخّص على كامل المنتج لا على الصفحة المعروضة: رقمٌ يتغيّر بتغيّر
// ‏الفلتر يُقرأ كأنه حقيقة عن المنصة وهو ليس كذلك.
$summary = $con->query("
SELECT
COALESCE(SUM(amount), 0) AS lent_total,
COALESCE(SUM(amount_collected), 0) AS collected_total,
COALESCE(SUM(amount_remaining), 0) AS outstanding_total,
COUNT(DISTINCT driver_id) AS drivers_count
FROM obligation_ledger
WHERE product_code = 'fuel_wallet'
")->fetch(PDO::FETCH_ASSOC) ?: [];
} catch (PDOException $e) {
error_log('[admin/fuel/drawdowns] ' . $e->getMessage());
jsonError('Server error', 500);
}
$vouchers = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'driver_id' => $r['driver_id'],
'code' => $r['code'],
'amount_authorized' => (float) $r['amount_authorized'],
'amount_redeemed' => (float) $r['amount_redeemed'],
'currency' => $r['currency'],
'status' => $r['status'],
'station' => $r['station_name'],
'expires_at' => $r['expires_at'],
'redeemed_at' => $r['redeemed_at'],
'created_at' => $r['created_at'],
], $rows);
jsonSuccess([
'vouchers' => $vouchers,
'summary' => [
'lent_total' => (float) ($summary['lent_total'] ?? 0),
'collected_total' => (float) ($summary['collected_total'] ?? 0),
'outstanding_total' => (float) ($summary['outstanding_total'] ?? 0),
'drivers_count' => (int) ($summary['drivers_count'] ?? 0),
],
], 'success');
+90
View File
@@ -0,0 +1,90 @@
<?php
// Admin/fuel/list.php — قائمة محطات الوقود الشريكة
// POST (اختياري): search, city, only_active
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$search = filterRequest('search');
$city = filterRequest('city');
$onlyActive = filterRequest('only_active') === '1';
$where = [];
$params = [];
if (!empty($search)) {
$where[] = '(s.name_ar LIKE ? OR s.chain_name LIKE ? OR s.address LIKE ?)';
$like = '%' . $search . '%';
array_push($params, $like, $like, $like);
}
if (!empty($city)) {
$where[] = 's.city = ?';
$params[] = $city;
}
if ($onlyActive) {
$where[] = 's.is_active = 1';
}
$clause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
try {
// ‏إحصاء الصرف مع كل محطة: لوحة بلا أرقام لا تخبر أحداً أي محطة
// ‏تعمل فعلاً وأيها اسمٌ في جدول. `redeemed` وحدها — القسائم الملغاة
// ‏والمنتهية لا تقول شيئاً عن أداء المحطة.
$st = $con->prepare("
SELECT s.*,
COUNT(v.id) AS redeem_count,
COALESCE(SUM(v.amount_redeemed), 0) AS redeem_total,
MAX(v.redeemed_at) AS last_redeem_at
FROM fuel_stations s
LEFT JOIN fuel_vouchers v
ON v.station_id = s.id AND v.status = 'redeemed'
$clause
GROUP BY s.id
ORDER BY s.is_active DESC, s.city ASC, s.name_ar ASC
LIMIT 200
");
$st->execute($params);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[admin/fuel/list] ' . $e->getMessage());
jsonError('Server error', 500);
}
$stations = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'name_ar' => $r['name_ar'],
'chain_name' => $r['chain_name'],
'city' => $r['city'],
'address' => $r['address'],
'lat' => $r['lat'] !== null ? (float) $r['lat'] : null,
'lng' => $r['lng'] !== null ? (float) $r['lng'] : null,
'phone' => $r['phone'],
'discount_percent' => (float) $r['discount_percent'],
'is_active' => (int) $r['is_active'] === 1,
'created_at' => $r['created_at'],
'redeem_count' => (int) $r['redeem_count'],
'redeem_total' => (float) $r['redeem_total'],
'last_redeem_at' => $r['last_redeem_at'],
// ‏المفتاح لا يخرج أبداً — لا هنا ولا في أي نقطة أخرى. مخزَّن مُهشَّراً
// ‏أصلاً، ويُعرض مرة واحدة فقط لحظة إنشائه أو تدويره.
], $rows);
// ‏حالة المنتج تُرسل مع القائمة لا في نقطة منفصلة: الشاشة تحتاج أن تعرض
// ‏«مُجهَّز وغير مُشغَّل» بوضوح، وطلبان لبناء لافتة واحدة إسراف.
$productActive = false;
try {
$st = $con->prepare("SELECT is_active FROM obligation_products WHERE code = 'fuel_wallet' LIMIT 1");
$st->execute();
$productActive = (int) $st->fetchColumn() === 1;
} catch (PDOException $e) {
error_log('[admin/fuel/list] تعذّرت قراءة حالة المنتج: ' . $e->getMessage());
}
jsonSuccess([
'stations' => $stations,
'product_active' => $productActive,
], 'success');
+44
View File
@@ -0,0 +1,44 @@
<?php
// Admin/fuel/rotate_key.php — توليد مفتاح جديد لمحطة
// POST: station_id
//
// ‏يُستعمل عند فقدان المحطة مفتاحها أو الشك في تسريبه. المفتاح القديم
// ‏يتوقّف فوراً — وهذا مقصود: التدوير إجراء أمني، وإبقاء القديم يعمل
// ‏«حتى تنتقل المحطة» يُفرغه من معناه.
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
if ($stationId <= 0) {
jsonError('station_id مطلوب');
}
try {
$st = $con->prepare("SELECT name_ar FROM fuel_stations WHERE id = ? LIMIT 1");
$st->execute([$stationId]);
$name = $st->fetchColumn();
if ($name === false) {
jsonError('المحطة غير موجودة', 404);
}
$plainKey = bin2hex(random_bytes(24));
$con->prepare("UPDATE fuel_stations SET api_key_hash = ? WHERE id = ?")
->execute([hash('sha256', $plainKey), $stationId]);
error_log("[admin/fuel] دُوِّر مفتاح المحطة #$stationId بواسطة $user_id");
jsonSuccess([
'station_id' => $stationId,
'station' => $name,
'api_key' => $plainKey,
], 'المفتاح القديم توقّف. انسخ الجديد الآن — لن يظهر مرة أخرى.');
} catch (PDOException $e) {
error_log('[admin/fuel/rotate_key] ' . $e->getMessage());
jsonError('Server error', 500);
}
+88
View File
@@ -0,0 +1,88 @@
<?php
// Admin/fuel/save.php — إضافة محطة أو تعديلها
// POST: station_id (اختياري للتعديل), name_ar, chain_name, city, address,
// lat, lng, phone, discount_percent, is_active
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
$nameAr = filterRequest('name_ar');
if (empty($nameAr)) {
jsonError('اسم المحطة مطلوب');
}
$discount = (float) (filterRequest('discount_percent', 'numeric') ?: 0);
if ($discount < 0 || $discount > 100) {
jsonError('نسبة الخصم يجب أن تكون بين 0 و 100');
}
$lat = filterRequest('lat', 'numeric');
$lng = filterRequest('lng', 'numeric');
$fields = [
'name_ar' => $nameAr,
'chain_name' => filterRequest('chain_name'),
'city' => filterRequest('city'),
'address' => filterRequest('address'),
'lat' => $lat !== null ? (float) $lat : null,
'lng' => $lng !== null ? (float) $lng : null,
'phone' => filterRequest('phone'),
'discount_percent' => $discount,
'is_active' => filterRequest('is_active') === '0' ? 0 : 1,
];
try {
if ($stationId > 0) {
$sets = implode(', ', array_map(static fn($k) => "`$k` = ?", array_keys($fields)));
$st = $con->prepare("UPDATE fuel_stations SET $sets WHERE id = ?");
$st->execute([...array_values($fields), $stationId]);
if ($st->rowCount() === 0) {
// ‏rowCount صفر قد يعني «غير موجودة» وقد يعني «حُفظت بلا تغيير».
// ‏نفرّق بينهما بقراءة صريحة: إخبار المستخدم أن المحطة غير
// ‏موجودة بينما هي موجودة يدفعه لإنشاء نسخة ثانية منها.
$chk = $con->prepare("SELECT 1 FROM fuel_stations WHERE id = ? LIMIT 1");
$chk->execute([$stationId]);
if (!$chk->fetchColumn()) {
jsonError('المحطة غير موجودة', 404);
}
}
error_log("[admin/fuel] عُدّلت المحطة #$stationId بواسطة $user_id");
jsonSuccess(['station_id' => $stationId, 'created' => false], 'تم حفظ التعديلات');
}
// ── إنشاء جديد ──
// ‏المفتاح يُولَّد هنا ولا يُطلب من المستخدم: مفتاح يختاره إنسان يكون
// ‏قصيراً ومتوقَّعاً، وهذا المفتاح يأذن بصرف مال.
$plainKey = bin2hex(random_bytes(24));
$cols = array_keys($fields);
$st = $con->prepare(
'INSERT INTO fuel_stations (`' . implode('`, `', $cols) . '`, `api_key_hash`) '
. 'VALUES (' . implode(', ', array_fill(0, count($cols), '?')) . ', ?)'
);
$st->execute([...array_values($fields), hash('sha256', $plainKey)]);
$stationId = (int) $con->lastInsertId();
error_log("[admin/fuel] أُنشئت المحطة #$stationId بواسطة $user_id");
jsonSuccess([
'station_id' => $stationId,
'created' => true,
// ‏المرة **الوحيدة** التي يظهر فيها المفتاح. مخزَّن مُهشَّراً، فلا
// ‏سبيل لاستعادته لاحقاً — من يفقده يدوّر مفتاحاً جديداً.
'api_key' => $plainKey,
], 'أُنشئت المحطة. انسخ المفتاح الآن — لن يظهر مرة أخرى.');
} catch (PDOException $e) {
error_log('[admin/fuel/save] ' . $e->getMessage());
jsonError('Server error', 500);
}
+53
View File
@@ -0,0 +1,53 @@
<?php
// Admin/fuel/toggle_product.php — تشغيل محفظة الوقود أو إيقافها
// POST: is_active (1 | 0)
//
// ‏قرار المالك: تُبنى الشاشات وتُجهَّز الأمور، ولا يُشغَّل المنتج إلا بعد
// ‏إتمام الاتفاق مع سلسلة المحطات. هذه هي نقطة ذلك التبديل.
//
// ‏ما الذي يتغيّر فعلاً؟ المنتج المُطفأ لا يظهر في `obligationProduct`،
// ‏فلا يستطيع سائق تفعيل محفظته ولا إصدار قسيمة. أما الالتزامات القائمة
// ‏ودفترها فلا تتأثر — الإطفاء يغلق الباب ولا يمحو ما خلفه.
require_once __DIR__ . '/../../connect.php';
// ‏super_admin وحده. تشغيل المنتج يفتح باب إقراض حقيقي بمال الشركة،
// ‏وهو قرار مالك لا قرار مناوب على اللوحة.
if ($role !== 'super_admin') {
jsonError('Unauthorized: Super admin access required', 403);
}
$isActive = filterRequest('is_active') === '1' ? 1 : 0;
try {
// ‏التشغيل بلا محطة واحدة فعّالة يعني سائقاً يفعّل محفظته، يطلب
// ‏قسيمة، ثم لا يجد أين يصرفها. الرفض هنا أرحم من اكتشافه عند مضخة.
if ($isActive === 1) {
$st = $con->query("SELECT COUNT(*) FROM fuel_stations WHERE is_active = 1");
if ((int) $st->fetchColumn() === 0) {
jsonError('لا توجد محطة فعّالة واحدة. أضف محطة قبل تشغيل المنتج.');
}
}
$st = $con->prepare("UPDATE obligation_products SET is_active = ? WHERE code = 'fuel_wallet'");
$st->execute([$isActive]);
if ($st->rowCount() === 0) {
$chk = $con->query("SELECT 1 FROM obligation_products WHERE code = 'fuel_wallet' LIMIT 1");
if (!$chk->fetchColumn()) {
jsonError('منتج محفظة الوقود غير موجود — نفّذ الترحيل أولاً', 404);
}
}
error_log('[admin/fuel] ' . ($isActive ? 'شُغّلت' : 'أُوقفت') . " محفظة الوقود بواسطة $user_id");
jsonSuccess(
['product_active' => $isActive === 1],
$isActive === 1
? 'شُغّلت محفظة الوقود — صارت متاحة للسائقين المؤهَّلين'
: 'أُوقفت محفظة الوقود — لا تفعيل جديد ولا قسائم جديدة'
);
} catch (PDOException $e) {
error_log('[admin/fuel/toggle_product] ' . $e->getMessage());
jsonError('Server error', 500);
}