Update: 2026-08-08 12:58:16
This commit is contained in:
@@ -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 لا في هذه القاعدة، والخصم
|
||||
* المباشر من هنا كان سيعني كتابةً عابرة للحدود بلا معاملة موحّدة —
|
||||
* أي خطر رصيد ناقص بلا قيد يقابله، أو قيد بلا خصم. فنقيّد استحقاقاً
|
||||
* هنا، وتقرأه التسوية.
|
||||
* هنا، ويقرأه محرك التسوية `cron_obligation_settlement.php`.
|
||||
*
|
||||
* تحديث (٢٠٢٦-٠٨-٠٨): صار هذا الملف **مُصدِر استحقاق** في محرك
|
||||
* الالتزامات العام، فيكتب في `obligation_ledger` لا في
|
||||
* `insurance_premium_ledger`. سبب النقل أن الدفتر القديم لم يكن يقرأه
|
||||
* أحد إطلاقاً — «تقرأه التسوية» كان وعداً بلا منفّذ، وكل قسط قُيّد منذ
|
||||
* الإطلاق بقي معلّقاً بلا تحصيل. جداول التأمين تبقى كما هي شاهداً
|
||||
* تاريخياً، وقد رُحّلت بياناتها في
|
||||
* `migrations/2026_08_08_obligations_engine.sql`.
|
||||
*
|
||||
* الحماية من الاحتساب المزدوج طبقتان:
|
||||
* ١. `last_charged_on` في الوثيقة — نفلتر به قبل العمل
|
||||
* ٢. مفتاح فريد (policy_id, charge_date) في الدفتر — يحسم السباق
|
||||
* ١. `last_charged_on` في الالتزام — نفلتر به قبل العمل
|
||||
* ٢. مفتاح فريد (obligation_id, charge_date) في الدفتر — يحسم السباق
|
||||
* لو عملت نسختان من الكرون معاً
|
||||
*
|
||||
* الأولى وحدها لا تكفي: بين القراءة والكتابة نافذة، والمفتاح الفريد
|
||||
@@ -39,19 +47,24 @@ $charged = 0;
|
||||
$skipped = 0;
|
||||
|
||||
try {
|
||||
// المصدر الآن الالتزامات لا الوثائق. القيد على `kind = 'recurring'`
|
||||
// لا على المنتج التأميني تحديداً: أي منتج دوري يُضاف لاحقاً يُقيَّد
|
||||
// بالمنطق نفسه بلا سطر جديد هنا.
|
||||
$st = $con->query("
|
||||
SELECT p.id, p.driver_id, p.last_charged_on,
|
||||
pl.premium, pl.currency, pl.billing_cycle
|
||||
FROM driver_insurance_policies p
|
||||
JOIN insurance_plans pl ON pl.id = p.plan_id
|
||||
WHERE p.status = 'active'
|
||||
ORDER BY p.id ASC
|
||||
SELECT o.id, o.driver_id, o.last_charged_on, o.cycle_amount,
|
||||
pr.code AS product_code, pr.currency, pr.billing_cycle
|
||||
FROM driver_obligations o
|
||||
JOIN obligation_products pr ON pr.id = o.product_id
|
||||
WHERE o.status = 'active'
|
||||
AND pr.kind = 'recurring'
|
||||
AND pr.billing_cycle <> 'none'
|
||||
ORDER BY o.id ASC
|
||||
");
|
||||
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $policy) {
|
||||
$chargeDate = $policy['billing_cycle'] === 'monthly' ? $monthStart : $today;
|
||||
|
||||
// الفلترة الرخيصة أولاً — تجنّبنا محاولة إدراج لكل وثيقة كل يوم.
|
||||
// الفلترة الرخيصة أولاً — تجنّبنا محاولة إدراج لكل التزام كل يوم.
|
||||
if ($policy['last_charged_on'] === $chargeDate) {
|
||||
$skipped++;
|
||||
continue;
|
||||
@@ -61,16 +74,17 @@ try {
|
||||
$con->beginTransaction();
|
||||
|
||||
$con->prepare("
|
||||
INSERT INTO insurance_premium_ledger
|
||||
(policy_id, driver_id, charge_date, amount, currency, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending')
|
||||
INSERT INTO obligation_ledger
|
||||
(obligation_id, driver_id, product_code, charge_date,
|
||||
amount, amount_collected, amount_remaining, currency, status)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?, 'pending')
|
||||
")->execute([
|
||||
$policy['id'], $policy['driver_id'], $chargeDate,
|
||||
$policy['premium'], $policy['currency'],
|
||||
$policy['id'], $policy['driver_id'], $policy['product_code'], $chargeDate,
|
||||
$policy['cycle_amount'], $policy['cycle_amount'], $policy['currency'],
|
||||
]);
|
||||
|
||||
$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']]);
|
||||
|
||||
$con->commit();
|
||||
@@ -82,11 +96,11 @@ try {
|
||||
// هذه ليست حالة خطأ — هي بالضبط ما صُمّم المفتاح لمنعه.
|
||||
if ($e->getCode() === '23000') {
|
||||
$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']]);
|
||||
$skipped++;
|
||||
} 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__ . '/eligibility.php';
|
||||
require_once __DIR__ . '/../obligations/functions.php';
|
||||
|
||||
$driverId = $user_id ?? '';
|
||||
if (empty($driverId) || ($role ?? '') !== 'driver') {
|
||||
@@ -22,7 +23,12 @@ if (!$policy) {
|
||||
|
||||
$reason = filterRequest('reason') ?: 'بطلب السائق';
|
||||
|
||||
// الوثيقة والالتزام يُغلقان معاً. التزام يبقى نشطاً بعد إلغاء وثيقته
|
||||
// يعني قسطاً يُقيَّد كل ليلة على سائقٍ بلا تغطية — عكس الثغرة السابقة
|
||||
// تماماً، وأسوأ منها: هناك مال يضيع على الشركة، وهنا مال يُؤخذ ظلماً.
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
// الشرط على driver_id إلى جانب المعرّف: الوثيقة أتت من استعلام
|
||||
// مقيَّد بالسائق أصلاً، لكن الشرط هنا يجعل الاستعلام آمناً بذاته
|
||||
// لو أعيد استعماله يوماً في سياق آخر.
|
||||
@@ -34,24 +40,22 @@ try {
|
||||
$st->execute([mb_substr($reason, 0, 255), $policy['id'], $driverId]);
|
||||
|
||||
if ($st->rowCount() === 0) {
|
||||
$con->rollBack();
|
||||
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());
|
||||
jsonError('Server error');
|
||||
}
|
||||
|
||||
$pending = 0.0;
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
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());
|
||||
}
|
||||
// المستحق يُقرأ من الدفتر الموحّد لا من دفتر التأمين القديم: القديم
|
||||
// مجمَّد بعد الترحيل، وقراءته تعني رقماً لا يتغيّر مهما سدّد السائق.
|
||||
$pending = obligationPendingTotal($con, $driverId, $policy['code']);
|
||||
|
||||
error_log("[assurance] أُلغيت وثيقة #{$policy['id']} للسائق $driverId");
|
||||
|
||||
|
||||
@@ -32,28 +32,35 @@ $summary = ['pending_total' => 0.0, 'settled_total' => 0.0,
|
||||
'currency' => $policy['currency']];
|
||||
$entries = [];
|
||||
|
||||
// المصدر هو الدفتر الموحّد لا دفتر التأمين القديم (المجمَّد بعد ترحيل
|
||||
// 2026_08_08). المفتاح صار (السائق + رمز المنتج) بدل رقم الوثيقة:
|
||||
// الدفتر الموحّد لا يعرف الوثائق، يعرف التزامات.
|
||||
try {
|
||||
// المستحق من `amount_remaining` والمحصَّل من `amount_collected`:
|
||||
// قيدٌ حُصِّل نصفه يظهر في الرقمين معاً بحصّته الصحيحة، بينما جمع
|
||||
// `amount` حسب الحالة كان سيعدّه كاملاً في أحدهما ويكذب في كليهما.
|
||||
$st = $con->prepare("
|
||||
SELECT status, SUM(amount) AS total
|
||||
FROM insurance_premium_ledger
|
||||
WHERE policy_id = ?
|
||||
GROUP BY status
|
||||
SELECT COALESCE(SUM(amount_remaining), 0) AS pending_total,
|
||||
COALESCE(SUM(amount_collected), 0) AS settled_total
|
||||
FROM obligation_ledger
|
||||
WHERE driver_id = ? AND product_code = ?
|
||||
");
|
||||
$st->execute([$policy['id']]);
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
if ($row['status'] === 'pending') $summary['pending_total'] = (float) $row['total'];
|
||||
if ($row['status'] === 'settled') $summary['settled_total'] = (float) $row['total'];
|
||||
$st->execute([$driverId, $policy['code']]);
|
||||
if ($row = $st->fetch(PDO::FETCH_ASSOC)) {
|
||||
$summary['pending_total'] = (float) $row['pending_total'];
|
||||
$summary['settled_total'] = (float) $row['settled_total'];
|
||||
}
|
||||
|
||||
// آخر ثلاثين قيداً: كافية لشهر يومي، وتمنع رداً ينمو بلا حد.
|
||||
$st = $con->prepare("
|
||||
SELECT charge_date, amount, currency, status, note
|
||||
FROM insurance_premium_ledger
|
||||
WHERE policy_id = ?
|
||||
SELECT charge_date, amount, amount_collected, amount_remaining,
|
||||
currency, status, note
|
||||
FROM obligation_ledger
|
||||
WHERE driver_id = ? AND product_code = ?
|
||||
ORDER BY charge_date DESC
|
||||
LIMIT 30
|
||||
");
|
||||
$st->execute([$policy['id']]);
|
||||
$st->execute([$driverId, $policy['code']]);
|
||||
$entries = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[assurance/get] تعذّرت قراءة الدفتر: ' . $e->getMessage());
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/eligibility.php';
|
||||
require_once __DIR__ . '/../obligations/functions.php';
|
||||
|
||||
// الهوية من الـJWT لا من الطلب — الأهلية معلومة عن السائق نفسه،
|
||||
// وقراءتها بمعرّف مُرسَل تكشف أرقام غيره.
|
||||
@@ -37,10 +38,29 @@ try {
|
||||
|
||||
$current = assuranceActivePolicy($con, $driverId);
|
||||
|
||||
// نداء واحد للمحفظة لكل الشاشة: الملخّص عن السائق لا عن الخطة.
|
||||
// بدونه كانت الشاشة تعرض «مؤهَّل» ثم يُرفض الطلب عند الضغط — وهو أسوأ
|
||||
// من الرفض المبكر: يعد السائق بشيء ثم يسحبه.
|
||||
$incomeSummary = obligationWalletIncomeSummary($driverId);
|
||||
|
||||
$out = [];
|
||||
foreach ($plans as $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[] = [
|
||||
'id' => (int) $plan['id'],
|
||||
'code' => $plan['code'],
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/eligibility.php';
|
||||
require_once __DIR__ . '/../obligations/functions.php';
|
||||
|
||||
$driverId = $user_id ?? '';
|
||||
if (empty($driverId) || ($role ?? '') !== 'driver') {
|
||||
@@ -49,7 +50,30 @@ if (!$check['eligible']) {
|
||||
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 {
|
||||
$con->beginTransaction();
|
||||
|
||||
$con->prepare("
|
||||
INSERT INTO driver_insurance_policies
|
||||
(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']]);
|
||||
|
||||
$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());
|
||||
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>
|
||||
Reference in New Issue
Block a user