diff --git a/.DS_Store b/.DS_Store index b2dd4fd9..934e7e29 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index bce53ca6..861b1b19 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,4 @@ GoogleService-Info.plist semgrep_*.json nuclei_results.txt -# سجل لوحة المفاتيح — محلي للسيرفر، لا يُدفع -backend/tools/panel/panel_audit.log *.env.bak.* diff --git a/backend/Admin/fuel/drawdowns.php b/backend/Admin/fuel/drawdowns.php new file mode 100644 index 00000000..1f9a59f7 --- /dev/null +++ b/backend/Admin/fuel/drawdowns.php @@ -0,0 +1,89 @@ + 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'); diff --git a/backend/Admin/fuel/list.php b/backend/Admin/fuel/list.php new file mode 100644 index 00000000..3f944a6c --- /dev/null +++ b/backend/Admin/fuel/list.php @@ -0,0 +1,90 @@ +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'); diff --git a/backend/Admin/fuel/rotate_key.php b/backend/Admin/fuel/rotate_key.php new file mode 100644 index 00000000..8fda8801 --- /dev/null +++ b/backend/Admin/fuel/rotate_key.php @@ -0,0 +1,44 @@ +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); +} diff --git a/backend/Admin/fuel/save.php b/backend/Admin/fuel/save.php new file mode 100644 index 00000000..cff2a9d8 --- /dev/null +++ b/backend/Admin/fuel/save.php @@ -0,0 +1,88 @@ + 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); +} diff --git a/backend/Admin/fuel/toggle_product.php b/backend/Admin/fuel/toggle_product.php new file mode 100644 index 00000000..ffea9b1d --- /dev/null +++ b/backend/Admin/fuel/toggle_product.php @@ -0,0 +1,53 @@ +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); +} diff --git a/backend/bot/cron_insurance_premiums.php b/backend/bot/cron_insurance_premiums.php index 1db1fd07..741d897e 100644 --- a/backend/bot/cron_insurance_premiums.php +++ b/backend/bot/cron_insurance_premiums.php @@ -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()); } } } diff --git a/backend/bot/cron_obligation_settlement.php b/backend/bot/cron_obligation_settlement.php new file mode 100644 index 00000000..65b0e27b --- /dev/null +++ b/backend/bot/cron_obligation_settlement.php @@ -0,0 +1,199 @@ +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 +); diff --git a/backend/driver_assurance/cancel.php b/backend/driver_assurance/cancel.php index e389fa6c..3234be6f 100644 --- a/backend/driver_assurance/cancel.php +++ b/backend/driver_assurance/cancel.php @@ -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"); diff --git a/backend/driver_assurance/get.php b/backend/driver_assurance/get.php index fc3f5f55..489f0943 100644 --- a/backend/driver_assurance/get.php +++ b/backend/driver_assurance/get.php @@ -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()); diff --git a/backend/driver_assurance/plans.php b/backend/driver_assurance/plans.php index e23a7212..826887e9 100644 --- a/backend/driver_assurance/plans.php +++ b/backend/driver_assurance/plans.php @@ -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'], diff --git a/backend/driver_assurance/subscribe.php b/backend/driver_assurance/subscribe.php index f31934f2..350babf8 100644 --- a/backend/driver_assurance/subscribe.php +++ b/backend/driver_assurance/subscribe.php @@ -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('تعذّر تفعيل الوثيقة حالياً'); } diff --git a/backend/fuel/cancel_voucher.php b/backend/fuel/cancel_voucher.php new file mode 100644 index 00000000..ab4a3950 --- /dev/null +++ b/backend/fuel/cancel_voucher.php @@ -0,0 +1,36 @@ +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, +], 'أُلغيت القسيمة وعاد المبلغ إلى سقفك'); diff --git a/backend/fuel/enroll.php b/backend/fuel/enroll.php new file mode 100644 index 00000000..3932bc08 --- /dev/null +++ b/backend/fuel/enroll.php @@ -0,0 +1,77 @@ +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'], +], 'تم تفعيل محفظة الوقود'); diff --git a/backend/fuel/functions.php b/backend/fuel/functions.php new file mode 100644 index 00000000..dc931011 --- /dev/null +++ b/backend/fuel/functions.php @@ -0,0 +1,113 @@ +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('تعذّر توليد رمز قسيمة فريد'); +} diff --git a/backend/fuel/get.php b/backend/fuel/get.php new file mode 100644 index 00000000..e26196a2 --- /dev/null +++ b/backend/fuel/get.php @@ -0,0 +1,80 @@ + 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'); diff --git a/backend/fuel/redeem.php b/backend/fuel/redeem.php new file mode 100644 index 00000000..43b1a1d7 --- /dev/null +++ b/backend/fuel/redeem.php @@ -0,0 +1,152 @@ +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'], +], 'تم الصرف'); diff --git a/backend/fuel/request_voucher.php b/backend/fuel/request_voucher.php new file mode 100644 index 00000000..fa0e6b33 --- /dev/null +++ b/backend/fuel/request_voucher.php @@ -0,0 +1,88 @@ +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), +], 'أعطِ هذا الرمز لعامل المحطة'); diff --git a/backend/fuel/stations.php b/backend/fuel/stations.php new file mode 100644 index 00000000..7427f10e --- /dev/null +++ b/backend/fuel/stations.php @@ -0,0 +1,65 @@ +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'); diff --git a/backend/migrations/2026_08_08_obligations_engine.sql b/backend/migrations/2026_08_08_obligations_engine.sql new file mode 100644 index 00000000..f34efa88 --- /dev/null +++ b/backend/migrations/2026_08_08_obligations_engine.sql @@ -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` +); diff --git a/backend/migrations/2026_08_09_obligation_credit_gate.sql b/backend/migrations/2026_08_09_obligation_credit_gate.sql new file mode 100644 index 00000000..471df0f5 --- /dev/null +++ b/backend/migrations/2026_08_09_obligation_credit_gate.sql @@ -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; diff --git a/backend/migrations/2026_08_10_fuel_wallet.sql b/backend/migrations/2026_08_10_fuel_wallet.sql new file mode 100644 index 00000000..193347b2 --- /dev/null +++ b/backend/migrations/2026_08_10_fuel_wallet.sql @@ -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' +); diff --git a/backend/obligations/functions.php b/backend/obligations/functions.php new file mode 100644 index 00000000..147ba296 --- /dev/null +++ b/backend/obligations/functions.php @@ -0,0 +1,234 @@ + 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(); +} diff --git a/backend/ride/earnings/summary.php b/backend/ride/earnings/summary.php new file mode 100644 index 00000000..c03cb13b --- /dev/null +++ b/backend/ride/earnings/summary.php @@ -0,0 +1,210 @@ +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'); diff --git a/backend/ride/earnings/trips.php b/backend/ride/earnings/trips.php new file mode 100644 index 00000000..626fa9c5 --- /dev/null +++ b/backend/ride/earnings/trips.php @@ -0,0 +1,121 @@ += 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'); diff --git a/backend/tools/panel/index.php b/backend/tools/panel/index.php deleted file mode 100644 index b10a6099..00000000 --- a/backend/tools/panel/index.php +++ /dev/null @@ -1,526 +0,0 @@ - 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 '
';
-    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 '
'; - 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'] ?? ''; -?> - - - - - - -مفاتيح البيئة — سيرو - - - -
- - -

مفاتيح البيئة

-

وصول محلي فقط عبر نفق SSH.

- -
-
- -

عبارة مرور اللوحة

- -

- -
-
- - -

مفاتيح البيئة

-

- · - خروج -

- - - - - - - -
-

أسرار داخلية

-

طرفاها كلاهما لنا. التدوير آمن — يكفي إعادة تشغيل ما يحملها.

- - - $bytes): - $s = keyState($env, $name); - $cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?> - - - - - - - -
المفتاحالحالةبصمة
-
- - - - -
-
-
- - -
-

مرتبطة بالجلسات

-

- تدويرها يُخرج كل السائقين والركّاب من حساباتهم ويطالبهم - بتسجيل دخول جديد. ليس عطلاً، لكنه ليس شيئاً يُفعل وقت الذروة. -

- - - $bytes): - $s = keyState($env, $name); - $cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?> - - - - - - - -
المفتاحالحالةبصمة
-
- - - - -
-
-
- - -
-

كلمات مرور قواعد البيانات

-

- التدوير يجري بالترتيب الصحيح: ALTER USER ثم تحقّق باتصال - فعلي ثم كتابة .env — وإن فشلت أي خطوة تُستعاد الكلمة - القديمة فوراً. لا مساس بالبيانات ولا إعادة بناء؛ هذا - تغيير في mysql.user لا غير. -

- - - $map): - $s = keyState($env, $name); - $cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?> - - - - - - - - -
المفتاحالمستخدمالقاعدةالحالة
- -
- - - - -
- — -
-
- - -
-

مفاتيح طرف ثالث

-

لا تُولَّد — تُنسخ من لوحة المزوّد وتُلصق هنا.

- - - - - - - - - - - - -
المفتاحالحالةقيمة جديدة
- - - - -
-
- - -
-

⛔ مفاتيح مجمّدة — للعلم فقط

-

- بيانات مخزَّنة في القاعدة مشتقّة من هذه المفاتيح: أسماء الركّاب - وهواتفهم ولوحات السيارات مشفّرة بـ ENC_KEY، وأعمدة - *_bidx محسوبة بـ BLIND_INDEX_PEPPER. - تغيير أيٍّ منها لا يعيد تشفير شيء — يجعل كل صف قديم - غير قابل للقراءة إلى الأبد، ولا نسخة احتياطية تنفع لأنها هي نفسها - مشفّرة بالمفتاح القديم. تدويرها ترحيلُ بيانات لا تغييرُ إعداد، - ولذلك لا يوجد لها زر هنا ولا مسار يكتبها في هذا الملف. -

- - - - - - - - - -
المفتاحالحالةبصمة
-
- -

سجلّ التغييرات: backend/tools/panel/panel_audit.log

- - -
- - diff --git a/docker/crontab.production b/docker/crontab.production index 5cf33337..2b3c81e5 100644 --- a/docker/crontab.production +++ b/docker/crontab.production @@ -82,13 +82,25 @@ DEX=/usr/bin/docker compose -f $SIRO/docker-compose.yml exec -T php php */1 * * * * $DEX /var/www/backend/bot/cron_sms_requests.php >> /var/log/sms_requests.log 2>&1 # ============================================================ -# التأمين للسائق +# التزامات السائق (تأمين، وقود، صيانة) # ============================================================ -# يقيّد قسط كل وثيقة نشطة بلغت دورتها. لا يخصم من رصيد أحد — الرصيد -# في payment_server، وهذا دفتر استحقاق تقرأه التسوية. +# مُصدِر الاستحقاق: يقيّد استحقاق كل التزام دوري بلغت دورته في +# obligation_ledger. لا يخصم من رصيد أحد — الرصيد في payment_server. # مرة يومياً بعد منتصف الليل: القيد اليومي مرتبط بالتاريخ لا بالساعة. 15 0 * * * $DEX /var/www/backend/bot/cron_insurance_premiums.php >> /var/log/insurance.log 2>&1 +# محرك التسوية: الجهة الوحيدة التي تخصم فعلياً من محفظة السائق. +# +# لماذا 23:30 لا بعد المُصدِر مباشرة؟ +# لأن السقف اليومي نسبة من أرباح *اليوم*، وهي تُقرأ من صفوف محفظة +# السائق المؤرخة بتاريخ اليوم. تشغيله الساعة 00:45 يعني قراءة يومٍ +# عمره خمس وأربعون دقيقة — أرباحه صفر، فالسقف صفر، فلا يُخصم شيء +# أبداً. المحرك كان سيعمل كل ليلة بلا خطأ واحد وبلا تحصيل قرش. +# +# 23:30 تقرأ يوم عمل مكتملاً تقريباً. النصف ساعة المتبقية ليست ضياعاً: +# ما لم يُحصَّل يبقى في الدفتر ويُلاحَق غداً. +30 23 * * * $DEX /var/www/backend/bot/cron_obligation_settlement.php >> /var/log/obligations.log 2>&1 + # ============================================================ # الإشعارات والتسويق # ============================================================ diff --git a/docs/01_overview/SIRO_FEATURES_AND_POLICIES_AR.md b/docs/01_overview/SIRO_FEATURES_AND_POLICIES_AR.md new file mode 100644 index 00000000..457ec1f2 --- /dev/null +++ b/docs/01_overview/SIRO_FEATURES_AND_POLICIES_AR.md @@ -0,0 +1,271 @@ +# سيرو — سجلّ الإضافات وسياساتها + +> **ما هذا الملف؟** شرحٌ بلغة غير تقنية لكل ميزة نضيفها: ما هي، لماذا أضفناها، كيف تعمل، وما قواعدها الرقمية. +> +> **لمن؟** للمالك ليتابع بلا قراءة كود، ولخدمة العملاء لتجيب السائق والراكب، ولعرضه على المستثمرين. +> +> **قاعدة التحديث:** كل ميزة جديدة تُضاف هنا فور إنجازها. الملف مرتّب من الأحدث إلى الأقدم. +> +> آخر تحديث: 2026-08-10 · المصدر: [دراسة الفرص](../04_features/SIRO_OPPORTUNITY_STUDY_AR.md) + +--- + +# شاشة أرباح السائق (جديدة) + +### ما هي +شاشة واحدة تجيب السائق عن: كم كسبت اليوم؟ كم اقتُطع مني ولماذا؟ وكم أخذت من **كل رحلة** بالتفصيل؟ + +### لماذا أعدنا بناءها +ثلاثة أسباب، أولها الأخطر: + +**١) كان فشل الشبكة يظهر للسائق كـ«دخلك صفر».** عند تعذّر التحميل كان التطبيق يولّد سبعة أيام بأصفار ويعرضها كأنها أرباحه الحقيقية. السائق يرى دخل أسبوعه وقد تبخّر بسبب انقطاع إنترنت لحظي. لا يُقاس هذا بالجهد بل بالثقة. + +**٢) الأرقام كانت لا تتفق.** شاشة الرصيد تُحدَّث فوراً، وشاشة الإحصائيات كانت مخزّنة **٣ ساعات** بلا أي إشارة إلى قِدَمها. السائق يفتح شاشتين فيجد رقمين مختلفين، فلا يثق بأيّهما. + +**٣) لم يكن هناك تفصيل للرحلة.** السؤال الأول عند كل سائق في العالم — «لماذا أخذت هذا المبلغ من هذه الرحلة؟» — لم يكن له جواب في التطبيق. وهذا مصدر أغلب شكاوى «الحساب غلط». + +### كيف تعمل الآن + +شاشة **واحدة** اسمها «أرباحي» حلّت محل شاشتين منفصلتين (الأرباح والإحصائيات). الترتيب فيها مقصود: **المال أولاً، ثم التقدّم**. + +| الترتيب | القسم | +|---|---| +| ١ | أرباح اليوم + تركيبة المبلغ | +| ٢ | الاقتطاعات (تأمين، وقود) | +| ٣ | آخر ٧ أيام — رسم بياني **يُنقر** لعرض رحلات أي يوم | +| ٤ | هذا الشهر — الصافي، معدّل الساعة، معدّل اليوم | +| ٥ | الرحلات بتفصيل كل واحدة | +| ٦ | تقدّمي: هدف اليوم، المستوى، سلوك القيادة، الإنجازات | + +كان التلعيب والمستويات في شاشة منفصلة تسبق الأرباح، فيمرّر السائق ثلاث شاشات ليصل إلى ما فتح التطبيق من أجله. + +| كان | صار | +|---|---| +| شاشتان بأرقام لا تتفق | شاشة واحدة بمصدر واحد | +| أربعة نداءات تصل في لحظات مختلفة | نداء واحد بختم زمني واحد | +| فشل التحميل = أصفار | «تعذّر التحميل» + آخر رقم صادق معلَّم بوقته | +| كاش ٣ ساعات صامت | دقيقتان، وأي رقم قديم يظهر بلافتة «هذه أرقام قبل كذا» | +| يبدأ بتقرير شهري | يبدأ بـ**أرباح اليوم** | +| الصافي فقط | دفع الراكب ← عمولة المنصة ← صافيك | +| لا شيء عن الاقتطاعات | التأمين والوقود ظاهران بجانب الأرباح | + +**تعريف مهم للجميع:** «أرباحي» ≠ «رصيدي». الأول عن العمل (الرحلات)، والثاني عن المال المتاح بعد الشحن والسحب والاقتطاعات. خلطهما هو ما أنتج الرقمين المتناقضين، وقد فُصلا الآن: الأرباح في شاشة الأرباح، والرصيد في شاشة المحفظة. + +### قرارات منتج +- **العمولة تُعرض صراحةً** لا تُطرح بصمت. من يرى التركيبة يفهم، ومن يرى الصافي وحده يخمّن ويشكّ. +- **إعفاء العمولة ٠٪** صار مرئياً كوسم على الرحلة. كنا ندفع ثمن هذه الهدية ولا نحصد أثرها لأن السائق لا يراها. +- **المقارنة بالذات لا بالآخرين**: «أعلى من أسبوعك الماضي بـ١٢٪». ترتيب السائقين يُحبط أكثر من يحتاج التحفيز — فهو من سيقع في أسفله. +- **الاقتطاعات في شاشة الأرباح إلزامياً.** خصمٌ لا يظهر بجانب الأرباح التي اقتُطع منها يُقرأ سرقةً مهما كان مشروعاً. هذا شرط قبل تشغيل محفظة الوقود لا بعده. + +### خطآن حسابيان صُحّحا +- كانت الأرباح تُحسب على حالة `Finished` وحدها، والحالة مكتوبة في قاعدة البيانات بحرفين مختلفين تاريخياً — أي أن **نحو نصف رحلات السائق كان يسقط من أرباحه المعروضة**. +- ساعات العمل كانت تُحسب بطريقة تجعل رحلة تبدأ ١١:٤٠ ليلاً وتنتهي ١٢:١٠ تُحسب بـ**سالب** ٢٣ ساعة ونصف، فتأكل ساعات يوم كامل. + +### لخدمة العملاء + +**«أرباحي اختفت / صارت صفراً»** — لم يعد ممكناً بعد هذا التحديث. إن ظهرت رسالة «تعذّر تحميل أرباحك» فهي مشكلة اتصال فقط، والشاشة تقول ذلك صراحةً: أرباحك محفوظة ولم يضع منها شيء. + +**«الرقم في شاشة الأرباح غير الرقم في المحفظة»** — طبيعي ومقصود. الأرباح = ما كسبته من الرحلات. الرصيد = المتاح فعلاً بعد الشحن والسحب والاقتطاعات. + +**«لماذا أخذت هذا المبلغ من الرحلة؟»** — يفتح السائق الرحلة في شاشة أرباحي فيرى: ما دفعه الراكب، المكافأة إن وُجدت، عمولة المنصة، ثم صافيه. + +**«خُصم مني ولا أعرف لماذا»** — بطاقة «الاقتطاعات» في شاشة الأرباح تعرض كل التزام باسمه والمتبقي عليه. + +--- + +# محور اقتصاد السائق + +> **الفكرة الجامعة:** مشكلة السائق في أسواقنا ليست نسبة العمولة، بل **رأس المال**: الوقود يومياً، والصيانة فجأة، والسيارة نفسها. من يحلّ له هذه المشكلة يربطه بالمنصة لسنوات — وهذا أعمق خندق تنافسي متاح لنا. +> +> بنينا هذا المحور على ثلاث مراحل: محرك عام يدير أي التزام مالي على السائق، ثم بوابة تحمي المنصة من الإقراض بلا استرداد، ثم أول منتج فعلي فوقهما: محفظة الوقود. + +--- + +## ١) محرك الالتزامات — الأساس المشترك + +### ما هو +نظام واحد يدير أي مبلغ مستحق على السائق للمنصة أو لشريك: قسط تأمين، دَين وقود، قسط صيانة، وقرض مركبة مستقبلاً. + +### لماذا بنيناه +كنا أمام خيارين: بناء نظام تحصيل منفصل لكل منتج، أو نظام واحد يخدمها جميعاً. اخترنا الثاني لسببين: + +**الأول — سبب مباشر:** التأمين كان يسجّل الأقساط على السائقين منذ إطلاقه، **ولم يكن هناك ما يحصّلها**. الأقساط تتراكم على الورق فقط. اكتُشف هذا عند التخطيط لمحفظة الوقود. + +**الثاني — سبب استراتيجي:** كل منتج مالي جديد كان سيحتاج نسخة من أخطر منطق في المنصة، وهو الذي يلمس مال السائق. أربع نسخ = أربعة أضعاف احتمال الخطأ. + +### كيف يعمل — بثلاث خطوات +1. **التسجيل:** المنتج يسجّل «على السائق كذا» (قسط اليوم، قيمة الوقود الذي أخذه). +2. **الدفتر:** كل هذه المبالغ تدخل سجلاً واحداً دائماً، لا يُمحى منه شيء. +3. **التحصيل:** برنامج واحد يعمل كل ليلة الساعة ١١:٣٠ مساءً، يمرّ على الدفتر ويخصم من أرباح السائق. + +**نقطة مهمة:** لا يوجد في المنصة كلها إلا جهة واحدة تخصم من رصيد السائق. أي منتج جديد يسجّل مبلغاً فقط ولا يلمس المال. + +### السياسات الرقمية + +| القاعدة | القيمة | لماذا | +|---|---|---| +| سقف الخصم اليومي | نسبة من **أرباح اليوم** لا مبلغ ثابت | حتى لا يجد السائق محفظته صفراً في يوم ضعيف | +| أرضية الرصيد | لا يُدفع السائق إلى السالب أبداً | الرصيد السالب يوقفه عن العمل، فيتعذّر السداد أصلاً | +| المتبقي | يُرحَّل لليوم التالي، **لا يُسقَط** | الدَّين لا يضيع، لكنه لا يُجبى بالقوة | +| ترتيب الأولوية | التأمين ← الوقود ← الصيانة | انقطاع التأمين يُفقد السائق تغطيته؛ تأخّر قسط صيانة يوماً لا يضرّ أحداً | +| السقف مشترك | ثلاثة منتجات لا تأخذ ثلاثة أسقف | وإلا اقتُطع ٧٥٪ من يوم السائق | + +### لخدمة العملاء + +**«لماذا خُصم مني مبلغ اليوم؟»** — عليك التزام مسجّل (تأمين أو وقود). الخصم نسبة من أرباح اليوم لا مبلغ ثابت. التفاصيل كاملة في شاشة المنتج داخل التطبيق. + +**«خُصم مني أقل من قيمة القسط»** — هذا مقصود. النظام لا يأخذ أكثر من النسبة المسموحة من دخل اليوم. الباقي يُلاحَق غداً بلا أي غرامة تأخير. + +**«لم يُخصم مني شيء اليوم»** — لم تدخل أرباح إلى محفظتك اليوم، أو دخلت بمبلغ صغير. لا شيء مطلوب من السائق. + +**«ألغيت اشتراكي، هل يسقط ما عليّ؟»** — لا. الإلغاء يوقف الأقساط **القادمة** ولا يمسّ الماضي. أيام التغطية التي استفاد منها السائق مستحقة. + +--- + +## ٢) بوابة الائتمان — من يستحق أن نموّله + +### ما هي +شرط إضافي قبل منح السائق أي التزام مالي: **يجب أن تمرّ أرباحه بمحفظة سيرو**. + +### لماذا أضفناها +سقف الخصم نسبة من الأرباح المارّة بالمحفظة. سائق يحصّل نقداً ولا يمرّ ماله بالمحفظة يظهر بأرباح صفر كل يوم — فلا يُخصم منه شيء **أبداً**. + +هذا محتمَل في التأمين (قسط صغير، خسارة محدودة). وهو غير محتمَل في الوقود: هناك نسلّم السائق قيمةً فعلية في خزّان سيارته مقابل وعد بالسداد من قناة لا يمرّ بها ماله. **هذه هبة لا ائتمان.** + +### كيف تعمل +قبل تفعيل أي منتج، يقيس النظام كم **يوماً** دخل فيه مالٌ إلى محفظة السائق خلال آخر ٣٠ يوماً. + +**المقياس أيام لا مبلغ** — والسبب أن السؤال ليس «كم يكسب؟» بل «هل قناة التحصيل تعمل؟». دخل واحد كبير قد يكون استرداداً أو تسوية حادثة؛ اثنا عشر يوماً متفرّقاً يعني قناة حيّة. + +### السياسات الرقمية + +| المنتج | أيام الدخل المطلوبة (من ٣٠) | لماذا هذه العتبة | +|---|---|---| +| التأمين | ٨ أيام | مشروط أصلاً بـ٢٠٠ رحلة وتقييم ٤.٥ — التشديد فوقها يفرغه من غرضه | +| محفظة الوقود | ١٢ يوماً | قيمة فعلية تُسلَّم، والاسترداد الوحيد من أرباح تمرّ بالمحفظة | + +**قواعد إضافية:** +- تعذّر التحقق = **رفض** لا قبول. تأجيل منح ائتمان أهون من منحه لمن لا يُسترد منه. +- الشرط على الاشتراكات **الجديدة** فقط. لا يُسحب التزام قائم من سائق بسبب شرط سُنّ بعده. +- العتبات قابلة للتغيير بتعديل رقم في قاعدة البيانات، بلا إصدار جديد من التطبيق. + +### لخدمة العملاء + +**«لماذا رُفض طلبي رغم أن رحلاتي كافية؟»** — الشرط ليس عدد الرحلات وحده، بل أن تمرّ الأرباح بالمحفظة. الرسالة في التطبيق تذكر العدد الحالي والمطلوب بالضبط. + +**«كيف أستوفي الشرط؟»** — بالعمل عبر المحفظة بدل النقد. الشرط يُعاد قياسه تلقائياً كل مرة، فلا حاجة لطلب مراجعة. + +**«رُفض طلبي برسالة تعذّر التحقق»** — عطل مؤقت. يُعاد المحاولة بعد قليل. ليس رفضاً نهائياً. + +--- + +## ٣) محفظة الوقود — أول منتج فعلي + +### ما هي +السائق يتزوّد وقوداً من محطة شريكة **بلا نقد**، والقيمة تُقيَّد ديناً عليه يُخصم من أرباحه تدريجياً. + +### لماذا +الوقود هو مشكلة السائق اليومية الأولى، ونقص السيولة صباحاً يمنعه من العمل. حلّ هذه المشكلة أثره أقوى من أي زيادة في نسبته — لأننا نعالج **توقيت** المال لا مقداره. وهو أيضاً ما يجعل مغادرته إلى منافس قراراً مكلفاً. + +### كيف تعمل — دورة كاملة +1. السائق يفعّل المحفظة مرة واحدة (بشرط الأهلية وبوابة الائتمان). +2. عند الحاجة، يطلب من التطبيق قسيمة بمبلغ محدّد. +3. يحصل على **رمز من ١٠ أرقام** صالح ٤٥ دقيقة. +4. يعطي الرمز لعامل المحطة، والمحطة تؤكّد المبلغ المصروف فعلاً. +5. عند التأكيد — **وليس قبله** — يُسجَّل الدَّين على السائق. +6. محرك التسوية يحصّله ليلاً بنسبة ١٥٪ من أرباح اليوم. +7. كلما سدّد، عاد سقفه متاحاً — كبطاقة ائتمان متجدّدة. + +**لماذا قسيمة مسبقة لا خصم عند المضخة؟** لأن عامل المحطة لا يملك وسيلة للتحقق من هوية السائق ولا من سقفه. القسيمة تنقل القرار إلى التطبيق حيث الهوية مؤكَّدة والسقف معلوم، ولا يبقى على المحطة إلا تأكيد رقم. + +### السياسات الرقمية + +| القاعدة | القيمة | لماذا | +|---|---|---| +| السقف المبدئي | ٣٠ (بعملة البلد) | موحّد للجميع مبدئياً؛ التدرّج بحسب السداد قرار لاحق يحتاج بيانات | +| نسبة الخصم اليومي | ١٥٪ من أرباح اليوم | أقل من التأمين (٢٥٪) لأن دَين الوقود أكبر، وخنق السائق يعيده محتاجاً للدَّين | +| صلاحية القسيمة | ٤٥ دقيقة | القسيمة تحجز من السقف؛ المفتوحة إلى الأبد تمنع صاحبها من التزوّد | +| قسائم مفتوحة | واحدة فقط في كل وقت | رمزان في يد السائق = صرف المبلغ الخطأ | +| المبلغ المصروف | لا يتجاوز المأذون | تجاوزه دَين لم يوافق عليه السائق | +| المصروف أقل من المأذون | مسموح وطبيعي | يطلب ٢٠ ويتزوّد بـ١٥ — الدَّين على ١٥ | + +**حماية الأموال المدمجة:** +- كل محطة لها مفتاح خاص مخزَّن مشفَّراً. تسريب مفتاح محطة يكشف تلك المحطة وحدها ويُبطَل فوراً. +- صرف القسيمة مرة واحدة قطعاً. الضغط المكرّر على جهاز المحطة أو محاولة صرف مزدوج لا تُنتج ديناً مضاعفاً. +- السائق لا يستطيع تأكيد الصرف بنفسه — هو الطرف المستفيد، وتأكيده يعني أن ينفي ما استلمه. + +### لخدمة العملاء + +**«الرمز لا يعمل عند المحطة»** — تحقّق من ثلاثة: هل مضت ٤٥ دقيقة؟ هل صُرف سابقاً؟ هل المحطة ضمن الشبكة الشريكة؟ رسالة جهاز المحطة تحدّد السبب بالضبط. + +**«ألغيت القسيمة، متى يعود سقفي؟»** — فوراً. + +**«سقفي المتاح أقل من ٣٠ رغم أني لم أستخدم شيئاً»** — إمّا عليك دَين سابق لم يُسدَّد بالكامل، أو لديك قسيمة سارية تحجز مبلغاً. الشاشة تعرض الأربعة أرقام منفصلة: السقف، المستحق، المحجوز، المتاح. + +**«صُرف مبلغ أقل مما طلبت»** — طبيعي. الدَّين على ما صُرف فعلاً لا ما طُلب. + +**«لماذا سقف الوقود ١٥٪ والتأمين ٢٥٪؟»** — دَين الوقود أكبر من قسط يومي. اقتطاع ربع الدخل لسداده يترك السائق بلا ما يكفي ليعمل غداً، فيعود محتاجاً وقوداً بالدَّين — وتلك حلقة لا تُغلق. + +--- + +## ٤) التأمين على السائق (سابق — أُعيد ربطه) + +### ما هو +تغطية تأمينية من شريك مرخّص، بقسط يومي أو شهري يُخصم من أرباح السائق. + +### الحالة +كان مبنياً وتُسجَّل أقساطه، **بلا أي تحصيل**. بعد بناء محرك الالتزامات صار يعمل ضمنه بالكامل: الأقساط المتراكمة سابقاً صارت قابلة للتحصيل، والاشتراكات الجديدة تدخل النظام تلقائياً. + +### السياسات الرقمية + +| القاعدة | القيمة | +|---|---| +| الحد الأدنى للرحلات المكتملة | ٢٠٠ | +| الحد الأدنى للتقييم | ٤.٥ | +| الحد الأدنى لعمر الحساب | ٣٠ يوماً | +| أيام الدخل بالمحفظة | ٨ من ٣٠ | +| نسبة الخصم اليومي | ٢٥٪ من أرباح اليوم | +| وثيقة نشطة لكل سائق | واحدة | + +**قرار المنتج:** التأمين ليس خدمة تُمنح لكل من سجّل، بل **مكافأة استمرار**. منحه لمن قد يختفي بعد أسبوع يحوّله من أداة ولاء إلى نزيف. والشروط معروضة في التطبيق لمن لم يستوفها بعد — مع تقدّمه نحوها — لأن الشرط الظاهر هدفٌ يُسعى إليه، والمخفي مجرد رفض. + +### لخدمة العملاء + +**«تبديل الخطة»** — لا يتم بصمت. إلغاء صريح ثم اشتراك جديد، ليبقى في السجل أثر لكل تغيير مالي. + +**«ألغيت وثيقتي وبقي عليّ مبلغ»** — نعم، أقساط أيام التغطية التي مضت. الإلغاء لا يمحو الماضي. + +--- + +# ما قبل هذا المحور + +بنود أخرى من دراسة الفرص أُنجزت أو بدأت قبل هذا العمل، ولم تُوثَّق هنا بعد بالتفصيل نفسه: + +- **الحجز المسبق** — يعمل +- **الطلب عبر SMS** (للمناطق بلا إنترنت) — البنية قائمة +- **محرك الإسناد بالدفعات** — مبني ويمكن تشغيله بمفتاح إعداد +- **تعويض السائق عن عدم حضور الراكب** — يعمل +- **زر السلامة (SOS)** — مبني، وتنقصه غرفة عمليات تستقبله +- **مواصلاتي (النقل الجامعي والمؤسسي)** — الكود شبه مكتمل، وفيه ثغرات معروفة + +*تُوثَّق تباعاً بالتنسيق نفسه.* + +--- + +# لم يُنجز بعد من دراسة الفرص + +سيرو للأعمال · الطرود والمشاوير · اشتراك الراكب · إغلاق التسرّب خارج المنصة · طبقة المعالم الشعبية · الصيانة بالتقسيط · تمويل المركبات + +**تنبيه قائم:** أي تمويل حقيقي يمرّ عبر شريك مرخّص، لا من ميزانية سيرو مباشرة — هذا الخط يقترب من التمويل المنظَّم قانونياً. + +--- + +# ملاحظات للعرض على المستثمرين + +**ما يميّز هذا المحور:** المنافسون يتنافسون على نسبة العمولة — وهي حرب أسعار تنتهي بخسارة الجميع. نحن نعالج **رأس مال السائق** ومشكلة توقيت السيولة، وهي مشكلة لا يحلّها خفض العمولة إطلاقاً. + +**الأصل الحقيقي المتكوّن:** بيانات دخل السائق المؤكَّدة والمارّة بمحفظتنا. هي التي تجعلنا أقدر من أي بنك على تقييم جدارته الائتمانية — وهي المنتج الذي يُعرض على شريك التمويل لاحقاً. القرض ليس الهدف؛ البيانات هي. + +**الأثر على البقاء:** السائق الذي يعتمد على محفظة الوقود يومياً ينتقل من «مورّد» إلى «شريك بحساب مفتوح». مغادرته لمنافس تعني تسوية حساب، لا مجرد حذف تطبيق. + +**صدقٌ في العرض:** المحفظة أُطلقت بمنتج واحد (الوقود) وسقف محافظ (٣٠)، لأن التوسّع في الائتمان قبل رؤية سلوك السداد الفعلي مخاطرة لا داعي لها. التوسّع قرار بيانات لا طموح. diff --git a/docs/04_features/OBLIGATIONS_ENGINE_DESIGN.md b/docs/04_features/OBLIGATIONS_ENGINE_DESIGN.md new file mode 100644 index 00000000..57f675be --- /dev/null +++ b/docs/04_features/OBLIGATIONS_ENGINE_DESIGN.md @@ -0,0 +1,178 @@ +# محرك الالتزامات العام — تصميم وتنفيذ + +> بند 3.4 من [دراسة الفرص](SIRO_OPPORTUNITY_STUDY_AR.md) — اقتصاد السائق: الوقود، الصيانة، والتمويل. +> تاريخ التنفيذ: 2026-08-08. الحالة: الخطوتان ١ و٢ منفَّذتان (الجداول + التسوية). + +--- + +## لماذا الآن — ثغرة قائمة لا تحسين مستقبلي + +`cron_insurance_premiums.php` يقيّد أقساط التأمين منذ إطلاقه، وتعليقه يقول إن الدفتر «تقرأه التسوية». **لم تكن التسوية موجودة.** لا مرجع واحد لجدول `insurance_premium_ledger` في المشروع كله خارج الكرون والـmigration. + +النتيجة العملية: كل قسط تأمين قُيّد حتى اليوم بقي بحالة `pending`، ولم يُحصَّل منه شيء. + +الخيار كان: كتابة تسوية خاصة بالتأمين، أو تعميم النموذج مرة واحدة. والوقود والصيانة والتمويل — البنود الثلاثة التالية في اقتصاد السائق — كلها نفس الشكل: التزام دوري أو مقسّط يُخصم من أرباح السائق. كتابة تسوية لكل منها تعني أربع نسخ من أخطر منطق في المنصة: الذي يلمس مال السائق. + +--- + +## البنية + +فصل ثلاث مسؤوليات كان الكود يخلطها: + +| المسؤولية | الملف | ماذا يفعل | +|---|---|---| +| مُصدِر الاستحقاق | `bot/cron_insurance_premiums.php` وما يليه | يقيّد «على السائق كذا». لا يلمس مالاً | +| الدفتر الموحّد | `obligation_ledger` | سجل دائم واحد مهما كان المصدر | +| محرك التسوية | `bot/cron_obligation_settlement.php` | **الجهة الوحيدة** التي تخصم من الرصيد | + +منتج جديد = صفّ في `obligation_products` + مُصدِر استحقاق صغير. لا يلمس التسوية ولا الرصيد إطلاقاً. + +### الجداول + +| الجديد | يعمّم | +|---|---| +| `obligation_products` | `insurance_plans` | +| `driver_obligations` | `driver_insurance_policies` | +| `obligation_ledger` | `insurance_premium_ledger` | +| `obligation_settlements` (قاعدة المحفظة) | — جديد كلياً | + +ثلاثة فروق جوهرية عن نموذج التأمين: + +**`kind`** يحدّد سلوك الاستحقاق: `recurring` يتكرّر بلا نهاية (تأمين)، `installment` له أصل ينتهي بسداده (صيانة، تمويل)، `drawdown` يُقيَّد عند السحب لا على جدول (وقود). + +**`amount_collected` / `amount_remaining`** في الدفتر. القسط يُدفع كاملاً أو لا يُدفع، أما الوقود والصيانة فتحصيلهما جزئي بطبعه: قيدٌ بقيمة عشرة قد يُحصَّل على ثلاثة أيام. بلا هذين العمودين لا يمكن تمثيل ذلك إلا بتفتيت القيد، فيضيع أثر الاستحقاق الأصلي. + +**`priority`** لترتيب المزاحمة: التأمين (١٠) قبل الوقود (٢٠) قبل الصيانة (٣٠). انقطاع التأمين يُلغي وثيقة ويفقد السائق تغطيته؛ تأخّر قسط صيانة يوماً لا يكلّف أحداً شيئاً. + +--- + +## سقف الخصم اليومي — القرار الذي يقرّر نجاح الميزة + +الخصم نسبة من **أرباح اليوم** لا مبلغ ثابت، بسقف على مستوى المنتج (`daily_cap_percent`، افتراضي ٢٥٪) وأرضية للرصيد (`OBLIGATION_MIN_BALANCE`، افتراضي صفر). + +السبب: سائق عليه قسط تأمين وقرض صيانة في يوم ضعيف سيفتح التطبيق ويجد صفراً — ويهجر المنصة. المتبقي يُرحَّل، لا يُسقَط. + +الأساس هو دخل اليوم لا الرصيد المتراكم: الخصم من رصيد قديم يفاجئ سائقاً لم يعمل اليوم، والخصم من دخل حاضر يبقى محسوساً كاقتطاع من كسبٍ وقع للتوّ. + +**السقف مشترك بين المنتجات:** نقطة الخصم تطرح ما اقتُطع اليوم من كل المنتجات قبل حساب المتاح. بدون هذا الطرح تصير ثلاثة منتجات بسقف ٢٥٪ تأخذ ٧٥٪ من يوم السائق. + +--- + +## بوابة الائتمان — لا التزام إلا لمن تمرّ أرباحه بالمحفظة + +قرار المالك (2026-08-09). سببه المباشر أن سقف الخصم نسبة من الدخل المارّ بالمحفظة: **سائق الكاش الذي يحصّل نقداً سقفه صفر كل يوم، فلا يُحصَّل منه شيء أبداً.** منحه وقوداً أو صيانة بالدَّين تسليمُ قيمةٍ فعلية مقابل قناة سداد لا وجود لها — أي هبة لا ائتمان. + +الشرط مخزَّن مع المنتج (`requires_wallet_income`, `min_wallet_days_30d`, `min_wallet_income_30d`) على غرار شروط الرحلات والتقييم القائمة، لأن العتبة تختلف بطبيعة المنتج. + +**المقياس أيام لا مبلغ.** السؤال ليس «كم يكسب؟» بل «هل قناة التحصيل تعمل؟». دخل واحد كبير قد يكون استرداداً أو تسوية حادثة؛ اثنا عشر يوماً متفرّقاً قناةٌ حيّة. + +**الفشل مغلق.** تعذُّر الوصول إلى سيرفر المحفظة يعني رفضاً لا قبولاً. عطلٌ شبكي عابر يؤجّل منح ائتمان، بينما القبول عند الشك يمنحه لمن قد لا يُسترد منه — والخطأ الثاني وحده يكلّف مالاً. + +**الفحص في طبقتين.** نقطة الاستدعاء تفحص لتعرض للسائق سبباً مفهوماً وما ينقصه؛ و`obligationOpen` تفحص ثانيةً عند الكتابة كحارس أخير. النقطة قد تنسى، أو يُضاف منتج جديد بمسار جديد يُغفلها — والفحص عند الكتابة وحده هو الذي لا يمكن الالتفاف عليه بالسهو. + +**التأمين يخضع للبوابة بعتبة متساهلة** (ثمانية أيام، بلا شرط مبلغ): القسط الذي تتحمّله الشركة ثم تسترده ائتمانٌ بالمعنى نفسه، لكن التأمين مشروط أصلاً بمئتي رحلة وتقييم جيد، وتشديد بوابة ثانية فوقها يفرغ المنتج من غرضه. تغيير العتبة صفٌّ واحد في `obligation_products`. + +الشرط يمسّ الاشتراكات **الجديدة** وحدها. الالتزامات القائمة لا تُفحص ثانيةً: سحب تغطية من سائق مؤمَّن اليوم بسبب شرط سُنّ اليوم عقوبة بأثر رجعي. + +--- + +## الحد بين الخادمين + +الرصيد في `payment_server`، القيد في القاعدة الرئيسية. لا كتابة عابرة للحدود. + +### لماذا نقطة خصم جديدة بدل `add_s2s_reward.php` بمبلغ سالب + +**`add_s2s_reward.php` لا يضمن عدم التكرار.** لا مفتاح فريد على `driverWallet.paymentID`، ولا فحص تكرار فيها إلا لتحديات `daily_/weekly_`. تعليق `food/admin/courier_settlement.php` يقول إن `paymentID` «يمنع ازدواج الصرف عند إعادة المحاولة» — وهذا غير صحيح، لا شيء يفرضه. + +هذا مقبول تقريباً للإيداعات (إيداع مكرّر يُسترد)، وغير مقبول إطلاقاً للخصومات: محرك التسوية يعيد المحاولة بطبعه. + +ولا يمكن ببساطة إضافة مفتاح فريد على `driverWallet.paymentID`: الجدول يحمل سنوات من صفوف قد تحمل معرّفات مكرّرة أصلاً. فالضمان بُني خارجه، في `obligation_settlements`. + +### المرجع مبنيّ على عدّاد المحاولات لا على التاريخ + +`settlement_ref = obl_{ledgerId}_{attempts}` + +`attempts` لا يزيد إلا بعد تحديث الدفتر بنجاح. فنداء نجح في المحفظة وضاع ردّه في الطريق يُعاد لاحقاً — ولو بعد أيام — بالمرجع نفسه، فتُرجع المحفظة خصمه الأول بدل تنفيذ خصم ثانٍ. + +مرجعٌ مبنيّ على التاريخ (`obl_{id}_{YYYYMMDD}`، وهو ما كُتب أولاً) كان سيصل في اليوم التالي بمرجع جديد ويخصم المبلغ مرتين في هذه الحالة بالضبط. + +--- + +## التوقيت + +| الوقت | المهمة | +|---|---| +| 00:15 | مُصدِر الاستحقاق — يقيّد أقساط اليوم | +| 23:30 | محرك التسوية — يحصّل من أرباح اليوم | + +التسوية **لا** تعمل بعد المُصدِر مباشرة. السقف نسبة من أرباح اليوم، وتشغيلها الساعة 00:45 يعني قراءة يومٍ عمره خمس وأربعون دقيقة: أرباحه صفر، فالسقف صفر، فلا يُخصم شيء أبداً. كان المحرك سيعمل كل ليلة بلا خطأ واحد وبلا تحصيل قرش. + +23:30 تقرأ يوم عمل مكتملاً تقريباً. النصف ساعة المتبقية ليست ضياعاً: ما لم يُحصَّل يبقى في الدفتر ويُلاحَق غداً. + +**لا خصم داخل `finish_ride_updates.php`.** المسار حرج والسائق ينتظر ردّه؛ نداء شبكي إلى سيرفر المحفظة داخله يعني تعليق شاشته على بطء طرف ثالث، وفشلاً في التسوية يظهر له كفشل في إنهاء رحلته. + +--- + +## ما نُفِّذ + +| الملف | الدور | +|---|---| +| `backend/migrations/2026_08_08_obligations_engine.sql` | الجداول الثلاثة + ترحيل التأمين | +| `backend/migrations/2026_08_09_obligation_credit_gate.sql` | أعمدة بوابة الائتمان | +| `payment_server/v2/main/ride/driverWallet/income_summary_s2s.php` | ملخّص الدخل المارّ بالمحفظة | +| `payment_server/migrations/2026_08_08_obligation_settlements.sql` | سجل الخصومات (ضمان عدم التكرار) | +| `payment_server/v2/main/ride/driverWallet/deduct_s2s_obligation.php` | نقطة الخصم — سقف + عدم تكرار | +| `backend/bot/cron_obligation_settlement.php` | محرك التسوية | +| `backend/obligations/functions.php` | فتح/إغلاق التزام وقراءة المستحق | +| `backend/bot/cron_insurance_premiums.php` | صار مُصدِر استحقاق عاماً | +| `backend/driver_assurance/{subscribe,cancel,get,plans}.php` | ربط بالدفتر الموحّد + بوابة الائتمان | +| `docker/crontab.production` | جدولة التسوية | + +### الترحيل + +جداول التأمين **تبقى كما هي** — لا تُحذف ولا تُعدَّل، وتصير شاهداً تاريخياً. بياناتها تُنقل إلى النموذج العام، والقيود المعلّقة (وهي مال حقيقي تراكم بلا تحصيل) تصير مرئية لمحرك التسوية. + +القيود القديمة بحالة `failed` تعود `pending`: الفشل السابق لم يكن قراراً بل غياب محرك، وحجبها الآن يعني إسقاط مال مستحق فعلاً. + +قسم الترحيل يعمل أكثر من مرة بلا ضرر — كل إدراج مشروط بعدم وجود ما يقابله. + +**لماذا لم يكفِ الترحيل وحده:** `driver_assurance/subscribe.php` كان يكتب في جداوله الخاصة فقط. بقاؤه كذلك كان يعني أن كل اشتراك **جديد** لا يظهر في الدفتر الموحّد — أي لا يُقيَّد له قسط ولا يُحصَّل منه شيء. الترحيل يعالج الماضي؛ ربط `subscribe/cancel` يمنع تكرار الثغرة مستقبلاً. + +--- + +## حدود معروفة — تُحسم قبل الخطوة ٤ + +**سائق الكاش محجوب لا محصَّل منه.** بوابة الائتمان تمنع منحه التزاماً أصلاً، وهذا هو القرار المتّخذ. لكنها تعالج الدخول لا الخروج: سائق كان دخله يمرّ بالمحفظة ثم تحوّل إلى الكاش بعد فتح التزامه يبقى التزامه قائماً بسقف صفر — يتراكم في الدفتر بلا تحصيل. لا يوجد بعد ما يرصد هذا التحوّل أو ينبّه عليه. + +**تعارض قيد وحساب.** لو خُصم من المحفظة ولم يُحدَّث الدفتر (فشل قاعدة بيانات بعد نجاح النداء)، يُسجَّل السطر في `error_log` بصيغة صريحة ولا تُعاد المحاولة تلقائياً. المرجع الثابت يمنع الخصم المزدوج، لكن التباين بين الدفترين يحتاج عيناً بشرية. لا توجد بعد لوحة تعرض هذه الحالات. + +**لم يُختبر مقابل قاعدة بيانات.** كل ملفات PHP اجتازت `php -l`، أما الـmigrations فلم تُنفَّذ — لا MySQL ولا Docker على جهاز التطوير هذا. يجب تشغيلها على بيئة اختبار قبل الإنتاج. + +--- + +## محفظة الوقود (الخطوة ٣ — منفَّذة) + +أول منتج من نوع `drawdown`: لا دورة فوترة، والاستحقاق يُقيَّد لحظة الصرف من المحطة. + +| الملف | الدور | +|---|---| +| `backend/migrations/2026_08_10_fuel_wallet.sql` | `source_ref` + `credit_limit` + المحطات + القسائم + المنتج | +| `backend/fuel/functions.php` | حساب السقف وتوليد الرموز | +| `backend/fuel/{enroll,get,stations,request_voucher,cancel_voucher}.php` | مسار السائق | +| `backend/fuel/redeem.php` | مُصدِر الاستحقاق — تأكيد المحطة | + +**تعديل على المخطّط اقتضاه المنتج:** المفتاح الفريد `(obligation_id, charge_date)` كان يمنع قيدين لالتزام واحد في يوم واحد — وهو المطلوب للتأمين، وكاسرٌ للوقود: سائق يتزوّد مرتين في يوم يُرفض قيده الثاني فيحصل على وقود بلا دَين. أُضيف `source_ref` إلى المفتاح، فارغاً للمنتجات الدورية (فيبقى ضمانها حرفياً) ومملوءاً برقم القسيمة للوقود. سلسلة فارغة لا `NULL`: MySQL يعتبر كل `NULL` مختلفاً، فعمود قابل للتفريغ كان سيلغي الحماية عن التأمين بصمت. + +**`credit_limit` لا `principal_amount`:** الأصل ينفد بالسداد (قرض صيانة)، وسقف الوقود متجدّد — يُسدَّد فيعود متاحاً. + +**السقف المتاح** = `credit_limit` − الدَّين القائم − القسائم السارية. حجز القسائم يُطرح كالدَّين تماماً: قسيمة قد تُصرف بعد لحظة، والسماح بإصدار قسائم تتجاوز السقف يعني سائقاً يتزوّد بضعف ما يستحق. + +--- + +## الخطوات التالية + +4. الصيانة بالتقسيط (`installment`) — المحرك يدعمها بالكامل، ينقصها منتج ومسار ورشة +5. مؤشر الجدارة الائتمانية — قراءة فقط، بلا التزام مالي، تمهيداً لشريك التمويل +6. رصد تحوّل السائق من المحفظة إلى الكاش بعد فتح التزامه (انظر الحدود المعروفة) + +تحذير الدراسة قائم: أي تمويل حقيقي يمرّ عبر شريك مرخّص، لا من ميزانية سيرو. diff --git a/payment_server/migrations/2026_08_08_obligation_settlements.sql b/payment_server/migrations/2026_08_08_obligation_settlements.sql new file mode 100644 index 00000000..1083e045 --- /dev/null +++ b/payment_server/migrations/2026_08_08_obligation_settlements.sql @@ -0,0 +1,49 @@ +-- ============================================================ +-- سجل خصومات الالتزامات — الطرف المالي من محرك الالتزامات +-- +-- ‏لماذا جدول جديد بدل إعادة استخدام `add_s2s_reward.php`؟ +-- +-- ‏لأن تلك النقطة لا تضمن عدم التكرار. لا مفتاح فريد على +-- ‏`driverWallet.paymentID`، ولا فحص تكرار فيها إلا لتحديات +-- ‏`daily_/weekly_`. تعليق `backend/food/admin/courier_settlement.php` +-- ‏يقول إن paymentID «يمنع ازدواج الصرف عند إعادة المحاولة» — وهذا غير +-- ‏صحيح، لا شيء في الكود ولا في المخطّط يفرضه. +-- +-- ‏وهذا مقبول تقريباً للإيداعات (إيداع مكرّر خطأ يُسترد)، وغير مقبول +-- ‏إطلاقاً للخصومات: محرك التسوية يعيد المحاولة بطبعه، وخصم مكرّر من +-- ‏رصيد سائق هو أسوأ عطل ممكن في المنصة — يفقد ثقته ولا يعود. +-- +-- ‏ولا يمكن ببساطة إضافة مفتاح فريد على `driverWallet.paymentID`: الجدول +-- ‏يحمل سنوات من صفوف قد تحمل معرّفات مكرّرة أصلاً، وإضافة القيد قد تفشل +-- ‏أو تكسر مسارات دفع قائمة. فالضمان يُبنى هنا، خارجه. +-- +-- ‏العقد: صفّ واحد لكل `settlement_ref`. محاولة ثانية بنفس المرجع لا +-- ‏تخصم شيئاً وتُرجع نتيجة المحاولة الأولى كما هي. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `obligation_settlements` ( + `id` INT NOT NULL AUTO_INCREMENT, + + -- ‏المرجع الذي يبنيه محرك التسوية: obl_{ledgerId}_{attempts} + -- ‏مبنيّ على عدّاد المحاولات لا على التاريخ، لأن العدّاد لا يزيد إلا + -- ‏بعد تحديث دفتر الالتزامات بنجاح: نداء نجح هنا وضاع ردّه في الطريق + -- ‏يُعاد لاحقاً بالمرجع نفسه فيستردّ نتيجته الأولى. مرجعٌ مبنيّ على + -- ‏التاريخ كان سيصل غداً بمرجع جديد ويخصم المبلغ مرتين. + `settlement_ref` VARCHAR(120) NOT NULL, + + `driver_id` VARCHAR(100) NOT NULL, + `product_code` VARCHAR(60) NOT NULL, + + `requested_amount` DECIMAL(12,3) NOT NULL COMMENT 'ما طلبه المحرك', + `deducted_amount` DECIMAL(12,3) NOT NULL COMMENT 'ما خُصم فعلاً بعد السقف', + + -- ‏لقطة الأساس الذي حُسب عليه السقف. بدونها لا يمكن لاحقاً شرح + -- ‏«لماذا خُصم ثلاثة لا خمسة؟» — والسائق سيسأل. + `day_earnings` DECIMAL(12,3) NOT NULL DEFAULT 0, + `cap_percent` DECIMAL(5,2) NOT NULL DEFAULT 0, + + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_settlement_ref` (`settlement_ref`), + KEY `idx_driver_date` (`driver_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/payment_server/v2/main/ride/driverWallet/deduct_s2s_obligation.php b/payment_server/v2/main/ride/driverWallet/deduct_s2s_obligation.php new file mode 100644 index 00000000..4459a75e --- /dev/null +++ b/payment_server/v2/main/ride/driverWallet/deduct_s2s_obligation.php @@ -0,0 +1,198 @@ + 100) { + printFailure('cap_percent must be between 0 and 100'); + exit; +} + +/** + * ‏يُرجع محاولة سابقة بنفس المرجع إن وُجدت. + * + * ‏هذه الدالة هي كل ضمان عدم التكرار: تُستدعى قبل أي عمل، وتُستدعى + * ‏ثانيةً عند خرق المفتاح الفريد — لأن بين الفحص والكتابة نافذة، ونسختان + * ‏من الكرون قد تعملان معاً. + */ +function obligationPriorAttempt(PDO $con, string $ref): ?array +{ + $st = $con->prepare(" + SELECT deducted_amount, requested_amount, day_earnings, cap_percent + FROM obligation_settlements WHERE settlement_ref = ? LIMIT 1 + "); + $st->execute([$ref]); + $row = $st->fetch(PDO::FETCH_ASSOC); + return $row ?: null; +} + +function obligationReplay(array $prior, string $ref): void +{ + printSuccess([ + 'settlement_ref' => $ref, + 'deducted' => (float) $prior['deducted_amount'], + 'requested' => (float) $prior['requested_amount'], + 'day_earnings' => (float) $prior['day_earnings'], + 'cap_percent' => (float) $prior['cap_percent'], + 'replayed' => true, + ]); +} + +try { + if ($prior = obligationPriorAttempt($con, $settlementRef)) { + obligationReplay($prior, $settlementRef); + exit; + } + + // ‏الرصيد الحالي: driverWallet دفتر حركات لا رصيداً مخزّناً — الرصيد + // ‏هو مجموع صفوفه، والخصم صفّ بمبلغ سالب. + $st = $con->prepare("SELECT COALESCE(SUM(amount), 0) FROM driverWallet WHERE driverID = ?"); + $st->execute([$driverID]); + $balance = (float) $st->fetchColumn(); + + // ‏أرباح اليوم = الصفوف الموجبة فقط. الأساس هو ما دخل اليوم لا الرصيد + // ‏المتراكم: الخصم من رصيد قديم يفاجئ سائقاً لم يعمل اليوم، والخصم من + // ‏دخل اليوم يبقى محسوساً كاقتطاع من كسبٍ حاضر. + $st = $con->prepare(" + SELECT COALESCE(SUM(amount), 0) FROM driverWallet + WHERE driverID = ? AND amount > 0 AND DATE(dateCreated) = CURDATE() + "); + $st->execute([$driverID]); + $dayEarnings = (float) $st->fetchColumn(); + + // ‏ما اقتطعه المحرك اليوم من كل المنتجات. بدون هذا الطرح يصير السقف + // ‏لكل منتج على حدة: ثلاثة منتجات بسقف ٢٥٪ تأخذ ٧٥٪ من يوم السائق. + $st = $con->prepare(" + SELECT COALESCE(SUM(deducted_amount), 0) FROM obligation_settlements + WHERE driver_id = ? AND DATE(created_at) = CURDATE() + "); + $st->execute([$driverID]); + $takenToday = (float) $st->fetchColumn(); + + $allowance = round(($dayEarnings * $capPercent / 100) - $takenToday, 3); + + // ‏أرضية الرصيد: لا يدفع المحرك سائقاً إلى السالب مهما استحقّ عليه. + // ‏الدَّين المرحَّل خيار السائق يواصل به العمل؛ الرصيد السالب المفاجئ + // ‏يوقفه عن العمل، فيتعذّر السداد أصلاً. + $minBalance = (float) (getenv('OBLIGATION_MIN_BALANCE') ?: 0); + $headroom = round($balance - $minBalance, 3); + + $deducted = min($amount, $allowance, $headroom); + if ($deducted < 0) { + $deducted = 0.0; + } + $deducted = round($deducted, 3); + + $con->beginTransaction(); + + // ‏السجل يُكتب دائماً — حتى حين لا يُخصم شيء. صفر مسجَّل يعني «حاولنا + // ‏اليوم ولم يكن هناك ما يُخصم منه»، وهو ما يمنع ملاحقة السائق مراراً + // ‏في اليوم نفسه ويشرح لاحقاً لماذا تأخّر التحصيل. + $con->prepare(" + INSERT INTO obligation_settlements + (settlement_ref, driver_id, product_code, requested_amount, + deducted_amount, day_earnings, cap_percent) + VALUES (?, ?, ?, ?, ?, ?, ?) + ")->execute([ + $settlementRef, $driverID, $productCode, + $amount, $deducted, $dayEarnings, $capPercent, + ]); + + if ($deducted > 0) { + // ‏paymentMethod عمود varchar(20) — لا يتّسع لاسم المنتج. التفصيل + // ‏يعيش في paymentID (وهو settlement_ref) وفي جدول التسويات. + $con->prepare(" + INSERT INTO driverWallet (driverID, paymentID, amount, paymentMethod) + VALUES (?, ?, ?, 'obligation') + ")->execute([$driverID, $settlementRef, -$deducted]); + } + + $con->commit(); + + printSuccess([ + 'settlement_ref' => $settlementRef, + 'deducted' => $deducted, + 'requested' => $amount, + 'day_earnings' => $dayEarnings, + 'cap_percent' => $capPercent, + 'balance_before' => $balance, + 'replayed' => false, + ]); +} catch (PDOException $e) { + if ($con->inTransaction()) { + $con->rollBack(); + } + + // ‏23000 = خرق المفتاح الفريد على settlement_ref: نسخة أخرى سبقتنا + // ‏بالمرجع نفسه. ليست حالة خطأ — هي بالضبط ما صُمّم المفتاح لمنعه. + if ($e->getCode() === '23000' && ($prior = obligationPriorAttempt($con, $settlementRef))) { + obligationReplay($prior, $settlementRef); + exit; + } + + error_log('[obligation/deduct] ' . $e->getMessage()); + http_response_code(500); + printFailure('Settlement failed'); +} catch (Exception $e) { + if ($con->inTransaction()) { + $con->rollBack(); + } + error_log('[obligation/deduct] ' . $e->getMessage()); + http_response_code(500); + printFailure('Settlement failed'); +} diff --git a/payment_server/v2/main/ride/driverWallet/income_summary_s2s.php b/payment_server/v2/main/ride/driverWallet/income_summary_s2s.php new file mode 100644 index 00000000..ebc074b1 --- /dev/null +++ b/payment_server/v2/main/ride/driverWallet/income_summary_s2s.php @@ -0,0 +1,77 @@ + 180) { + $windowDays = 30; +} + +try { + // ‏الصفوف الموجبة وحدها. السالبة خصومات (عمولة، التزامات) وعدّها + // ‏هنا كان سيجعل سائقاً يُخصم منه كثيراً يبدو ذا قناة نشطة. + // + // ‏وباستثناء صفوف الالتزامات صراحةً: 'obligation' مرجعها هذا المحرك + // ‏نفسه، ولو ظهرت يوماً بمبلغ موجب (تصحيح، استرداد) لصارت البوابة + // ‏تقيس أثر نفسها. + $st = $con->prepare(" + SELECT + COUNT(DISTINCT DATE(dateCreated)) AS days_with_income, + COALESCE(SUM(amount), 0) AS total_income, + MAX(DATE(dateCreated)) AS last_income_date + FROM driverWallet + WHERE driverID = ? + AND amount > 0 + AND paymentMethod <> 'obligation' + AND dateCreated >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + "); + $st->execute([$driverID, $windowDays]); + $row = $st->fetch(PDO::FETCH_ASSOC) ?: []; + + printSuccess([ + 'driver_id' => $driverID, + 'window_days' => $windowDays, + 'days_with_income' => (int) ($row['days_with_income'] ?? 0), + 'total_income' => (float) ($row['total_income'] ?? 0), + 'last_income_date' => $row['last_income_date'] ?? null, + ]); +} catch (PDOException $e) { + error_log('[obligation/income_summary] ' . $e->getMessage()); + http_response_code(500); + printFailure('Failed to read income summary'); +} diff --git a/siro_admin/lib/controller/fuel/fuel_admin_controller.dart b/siro_admin/lib/controller/fuel/fuel_admin_controller.dart new file mode 100644 index 00000000..87694383 --- /dev/null +++ b/siro_admin/lib/controller/fuel/fuel_admin_controller.dart @@ -0,0 +1,129 @@ +// fuel_admin_controller.dart — تحكم شاشات محفظة الوقود (لوحة إدارة سيرو) +import 'package:get/get.dart'; + +import 'fuel_admin_models.dart'; +import 'fuel_admin_service.dart'; + +class FuelAdminController extends GetxController { + bool isLoading = false; + List stations = []; + + /// ‏حالة المنتج — لا حالة الشاشة. الشاشة تعمل دائماً؛ المنتج قد يكون + /// ‏مُجهَّزاً وغير مُشغَّل حتى يتم الاتفاق مع سلسلة المحطات. + bool productActive = false; + + String searchQuery = ''; + bool isSaving = false; + + bool isLoadingDrawdowns = false; + FuelDrawdownsPage? drawdowns; + + String? lastError; + + @override + void onInit() { + super.onInit(); + fetchStations(); + } + + Future fetchStations() async { + isLoading = true; + update(); + + final res = await FuelAdminService.listStations(search: searchQuery); + if (res.success && res.data != null) { + stations = res.data!.stations; + productActive = res.data!.productActive; + lastError = null; + } else { + lastError = res.message; + } + + isLoading = false; + update(); + } + + /// ‏يُرجع المفتاح الجديد عند الإنشاء، أو null عند التعديل/الفشل. + /// + /// ‏المفتاح يظهر مرة واحدة فقط — مخزَّن مُهشَّراً على الخادم ولا سبيل + /// ‏لاستعادته. الشاشة مسؤولة عن عرضه فوراً قبل أي انتقال. + Future saveStation({ + int? stationId, + required String nameAr, + String? chainName, + String? city, + String? address, + double? lat, + double? lng, + String? phone, + required double discountPercent, + required bool isActive, + }) async { + isSaving = true; + update(); + + final res = await FuelAdminService.saveStation( + stationId: stationId, + nameAr: nameAr, + chainName: chainName, + city: city, + address: address, + lat: lat, + lng: lng, + phone: phone, + discountPercent: discountPercent, + isActive: isActive, + ); + + isSaving = false; + lastError = res.success ? null : res.message; + update(); + + if (!res.success) return null; + + await fetchStations(); + return res.data?['api_key']?.toString(); + } + + Future rotateKey(int stationId) async { + final res = await FuelAdminService.rotateKey(stationId); + if (!res.success) { + lastError = res.message; + update(); + return null; + } + return res.data?['api_key']?.toString(); + } + + /// ‏تشغيل المنتج أو إيقافه. الخادم يرفض التشغيل بلا محطة فعّالة واحدة — + /// ‏سائق يفعّل محفظته ثم لا يجد أين يصرف قسيمته تجربةٌ تُفقد الثقة. + Future toggleProduct(bool value) async { + final res = await FuelAdminService.toggleProduct(value); + if (res.success) { + productActive = res.data ?? value; + lastError = null; + update(); + return true; + } + lastError = res.message; + update(); + return false; + } + + Future fetchDrawdowns({int? stationId, String? status}) async { + isLoadingDrawdowns = true; + update(); + + final res = + await FuelAdminService.listDrawdowns(stationId: stationId, status: status); + if (res.success) { + drawdowns = res.data; + lastError = null; + } else { + lastError = res.message; + } + + isLoadingDrawdowns = false; + update(); + } +} diff --git a/siro_admin/lib/controller/fuel/fuel_admin_models.dart b/siro_admin/lib/controller/fuel/fuel_admin_models.dart new file mode 100644 index 00000000..ac59633b --- /dev/null +++ b/siro_admin/lib/controller/fuel/fuel_admin_models.dart @@ -0,0 +1,146 @@ +// fuel_admin_models.dart — نماذج بيانات محفظة الوقود (لوحة إدارة سيرو) + +class FuelStation { + final int id; + final String nameAr; + final String? chainName; + final String? city; + final String? address; + final double? lat; + final double? lng; + final String? phone; + final double discountPercent; + final bool isActive; + + /// ‏أرقام الأداء: محطة بلا صرف اسمٌ في جدول لا شريك يعمل. + final int redeemCount; + final double redeemTotal; + final String? lastRedeemAt; + + FuelStation({ + required this.id, + required this.nameAr, + required this.discountPercent, + required this.isActive, + this.chainName, + this.city, + this.address, + this.lat, + this.lng, + this.phone, + this.redeemCount = 0, + this.redeemTotal = 0, + this.lastRedeemAt, + }); + + factory FuelStation.fromJson(Map j) => FuelStation( + id: int.tryParse(j['id'].toString()) ?? 0, + nameAr: j['name_ar']?.toString() ?? '', + chainName: j['chain_name']?.toString(), + city: j['city']?.toString(), + address: j['address']?.toString(), + lat: double.tryParse(j['lat']?.toString() ?? ''), + lng: double.tryParse(j['lng']?.toString() ?? ''), + phone: j['phone']?.toString(), + discountPercent: + double.tryParse(j['discount_percent']?.toString() ?? '0') ?? 0, + isActive: j['is_active'] == true, + redeemCount: int.tryParse(j['redeem_count']?.toString() ?? '0') ?? 0, + redeemTotal: double.tryParse(j['redeem_total']?.toString() ?? '0') ?? 0, + lastRedeemAt: j['last_redeem_at']?.toString(), + ); +} + +class FuelStationsPage { + final List stations; + + /// ‏حالة المنتج نفسه. قرار المالك: تُجهَّز الشاشات ولا يُشغَّل المنتج إلا + /// ‏بعد إتمام الاتفاق مع سلسلة المحطات — فالشاشة تعمل كاملةً بينما + /// ‏المنتج مُطفأ، والفرق يجب أن يكون ظاهراً لا مخفياً. + final bool productActive; + + FuelStationsPage({required this.stations, required this.productActive}); + + factory FuelStationsPage.fromJson(Map j) => FuelStationsPage( + stations: (j['stations'] is List) + ? (j['stations'] as List) + .map((s) => FuelStation.fromJson(Map.from(s))) + .toList() + : [], + productActive: j['product_active'] == true, + ); +} + +class FuelVoucher { + final int id; + final String driverId; + final String code; + final double amountAuthorized; + final double amountRedeemed; + final String currency; + final String status; + final String? station; + final String? redeemedAt; + final String? createdAt; + + FuelVoucher({ + required this.id, + required this.driverId, + required this.code, + required this.amountAuthorized, + required this.amountRedeemed, + required this.currency, + required this.status, + this.station, + this.redeemedAt, + this.createdAt, + }); + + factory FuelVoucher.fromJson(Map j) => FuelVoucher( + id: int.tryParse(j['id'].toString()) ?? 0, + driverId: j['driver_id']?.toString() ?? '', + code: j['code']?.toString() ?? '', + amountAuthorized: + double.tryParse(j['amount_authorized']?.toString() ?? '0') ?? 0, + amountRedeemed: + double.tryParse(j['amount_redeemed']?.toString() ?? '0') ?? 0, + currency: j['currency']?.toString() ?? '', + status: j['status']?.toString() ?? '', + station: j['station']?.toString(), + redeemedAt: j['redeemed_at']?.toString(), + createdAt: j['created_at']?.toString(), + ); +} + +class FuelDrawdownsPage { + final List vouchers; + final double lentTotal; + final double collectedTotal; + final double outstandingTotal; + final int driversCount; + + FuelDrawdownsPage({ + required this.vouchers, + required this.lentTotal, + required this.collectedTotal, + required this.outstandingTotal, + required this.driversCount, + }); + + factory FuelDrawdownsPage.fromJson(Map j) { + final s = Map.from(j['summary'] ?? {}); + return FuelDrawdownsPage( + vouchers: (j['vouchers'] is List) + ? (j['vouchers'] as List) + .map((v) => FuelVoucher.fromJson(Map.from(v))) + .toList() + : [], + lentTotal: double.tryParse(s['lent_total']?.toString() ?? '0') ?? 0, + collectedTotal: + double.tryParse(s['collected_total']?.toString() ?? '0') ?? 0, + outstandingTotal: + double.tryParse(s['outstanding_total']?.toString() ?? '0') ?? 0, + driversCount: int.tryParse(s['drivers_count']?.toString() ?? '0') ?? 0, + ); + } +} diff --git a/siro_admin/lib/controller/fuel/fuel_admin_service.dart b/siro_admin/lib/controller/fuel/fuel_admin_service.dart new file mode 100644 index 00000000..bba3ab78 --- /dev/null +++ b/siro_admin/lib/controller/fuel/fuel_admin_service.dart @@ -0,0 +1,127 @@ +// fuel_admin_service.dart — طبقة الاتصال بـ backend/Admin/fuel +// +// ‏محفظة الوقود: السائق يتزوّد بلا نقد، والقيمة تُقيَّد ديناً يُخصم من +// ‏أرباحه. هذه الشاشة تدير المحطات الشريكة وتشغّل المنتج أو توقفه. +import '../functions/crud.dart'; +import '../../constant/links.dart'; +import 'fuel_admin_models.dart'; + +class FuelApiResult { + final bool success; + final T? data; + final String message; + FuelApiResult(this.success, this.data, this.message); +} + +class FuelAdminService { + static String get _base => '${AppLink.server}/Admin/fuel'; + + static String _errMsg(dynamic res) { + if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت'; + if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً'; + if (res is Map && res['message'] is String) return res['message']; + return 'حدث خطأ، حاول مجدداً'; + } + + static Future> listStations({ + String? search, + String? city, + bool onlyActive = false, + }) async { + final payload = {}; + if (search != null && search.isNotEmpty) payload['search'] = search; + if (city != null && city.isNotEmpty) payload['city'] = city; + if (onlyActive) payload['only_active'] = '1'; + + final res = await CRUD().post(link: '$_base/list.php', payload: payload); + + if (res is Map && res['status'] == 'success' && res['message'] is Map) { + return FuelApiResult( + true, + FuelStationsPage.fromJson(Map.from(res['message'])), + 'ok', + ); + } + return FuelApiResult(false, null, _errMsg(res)); + } + + /// ‏يُرجع `api_key` عند الإنشاء فقط — مخزَّن مُهشَّراً على الخادم، فلا + /// ‏سبيل لعرضه ثانيةً. الشاشة مسؤولة عن إظهاره فوراً وبوضوح. + static Future>> saveStation({ + int? stationId, + required String nameAr, + String? chainName, + String? city, + String? address, + double? lat, + double? lng, + String? phone, + required double discountPercent, + required bool isActive, + }) async { + final res = await CRUD().post(link: '$_base/save.php', payload: { + if (stationId != null) 'station_id': stationId.toString(), + 'name_ar': nameAr, + if (chainName != null && chainName.isNotEmpty) 'chain_name': chainName, + if (city != null && city.isNotEmpty) 'city': city, + if (address != null && address.isNotEmpty) 'address': address, + if (lat != null) 'lat': lat.toString(), + if (lng != null) 'lng': lng.toString(), + if (phone != null && phone.isNotEmpty) 'phone': phone, + 'discount_percent': discountPercent.toString(), + 'is_active': isActive ? '1' : '0', + }); + + if (res is Map && res['status'] == 'success') { + return FuelApiResult( + true, + Map.from(res['message'] ?? {}), + res['message'] is Map ? 'ok' : (res['message']?.toString() ?? 'ok'), + ); + } + return FuelApiResult(false, null, _errMsg(res)); + } + + static Future>> rotateKey(int stationId) async { + final res = await CRUD().post( + link: '$_base/rotate_key.php', + payload: {'station_id': stationId.toString()}, + ); + if (res is Map && res['status'] == 'success' && res['message'] is Map) { + return FuelApiResult(true, Map.from(res['message']), 'ok'); + } + return FuelApiResult(false, null, _errMsg(res)); + } + + static Future> toggleProduct(bool isActive) async { + final res = await CRUD().post( + link: '$_base/toggle_product.php', + payload: {'is_active': isActive ? '1' : '0'}, + ); + if (res is Map && res['status'] == 'success' && res['message'] is Map) { + return FuelApiResult( + true, res['message']['product_active'] == true, 'ok'); + } + return FuelApiResult(false, null, _errMsg(res)); + } + + static Future> listDrawdowns({ + int? stationId, + String? status, + }) async { + final payload = {}; + if (stationId != null) payload['station_id'] = stationId.toString(); + if (status != null && status.isNotEmpty) payload['status'] = status; + + final res = await CRUD().post(link: '$_base/drawdowns.php', payload: payload); + + if (res is Map && res['status'] == 'success' && res['message'] is Map) { + return FuelApiResult( + true, + FuelDrawdownsPage.fromJson(Map.from(res['message'])), + 'ok', + ); + } + return FuelApiResult(false, null, _errMsg(res)); + } +} diff --git a/siro_admin/lib/views/admin/admin_home_page.dart b/siro_admin/lib/views/admin/admin_home_page.dart index 069de1ba..8727870a 100644 --- a/siro_admin/lib/views/admin/admin_home_page.dart +++ b/siro_admin/lib/views/admin/admin_home_page.dart @@ -42,6 +42,7 @@ import 'analytics/live_analytics_page.dart'; import 'package:siro_admin/views/widgets/responsive_layout.dart'; import 'package:siro_admin/views/widgets/web_sidebar.dart'; import '../transit/org_list_page.dart'; +import 'fuel/fuel_stations_page.dart'; import 'package:siro_admin/views/widgets/glass_container.dart'; class AdminHomePage extends StatefulWidget { @@ -1352,6 +1353,13 @@ class _AdminHomePageState extends State () => Get.to(() => const TransitOrgListPage())), ], ), + ActionCategory( + title: 'اقتصاد السائق', + items: [ + ActionItem('محطات الوقود', Icons.local_gas_station_rounded, const Color(0xFF10B981), + () => Get.to(() => const FuelStationsPageView())), + ], + ), ActionCategory( title: 'إدارة النظام الجديد', items: [ diff --git a/siro_admin/lib/views/admin/fuel/fuel_drawdowns_page.dart b/siro_admin/lib/views/admin/fuel/fuel_drawdowns_page.dart new file mode 100644 index 00000000..05a5345c --- /dev/null +++ b/siro_admin/lib/views/admin/fuel/fuel_drawdowns_page.dart @@ -0,0 +1,174 @@ +// fuel_drawdowns_page.dart — حركة القسائم وملخّص الائتمان +// +// ‏الشاشة التي تُجيب: كم أقرضنا؟ كم استُرد؟ وكم بقي في الشارع؟ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../controller/fuel/fuel_admin_controller.dart'; +import '../../../controller/fuel/fuel_admin_models.dart'; + +class FuelDrawdownsPageView extends StatefulWidget { + const FuelDrawdownsPageView({super.key}); + + @override + State createState() => _FuelDrawdownsPageViewState(); +} + +class _FuelDrawdownsPageViewState extends State { + String? _status; + + @override + void initState() { + super.initState(); + Get.find().fetchDrawdowns(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return GetBuilder( + builder: (c) => Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + foregroundColor: cs.onSurface, + title: const Text('حركة القسائم'), + ), + body: Column( + children: [ + if (c.drawdowns != null) _summary(c.drawdowns!, cs), + _filters(c, cs), + Expanded( + child: c.isLoadingDrawdowns + ? Center(child: CircularProgressIndicator(color: cs.primary)) + : (c.drawdowns?.vouchers.isEmpty ?? true) + ? Center( + child: Text('لا توجد قسائم', + style: TextStyle(color: cs.onSurfaceVariant))) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: c.drawdowns!.vouchers.length, + itemBuilder: (_, i) => + _voucherCard(c.drawdowns!.vouchers[i], cs), + ), + ), + ], + ), + ), + ); + } + + /// ‏الملخّص على كامل المنتج لا على القائمة المعروضة: رقم يتغيّر بتغيّر + /// ‏الفلتر يُقرأ كأنه حقيقة عن المنصة وهو ليس كذلك. + Widget _summary(FuelDrawdownsPage d, ColorScheme cs) => Container( + margin: const EdgeInsets.fromLTRB(12, 12, 12, 4), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + _stat('أُقرض', d.lentTotal.toString(), cs.onSurface, cs), + _stat('استُرد', d.collectedTotal.toString(), cs.primary, cs), + _stat('قائم', d.outstandingTotal.toString(), cs.tertiary, cs), + _stat('سائق', d.driversCount.toString(), cs.onSurfaceVariant, cs), + ], + ), + ); + + Widget _stat(String label, String value, Color color, ColorScheme cs) => + Expanded( + child: Column( + children: [ + Text(value, + style: TextStyle( + color: color, fontSize: 16, fontWeight: FontWeight.w700)), + const SizedBox(height: 2), + Text(label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11)), + ], + ), + ); + + Widget _filters(FuelAdminController c, ColorScheme cs) { + const options = { + null: 'الكل', + 'issued': 'سارية', + 'redeemed': 'مصروفة', + 'expired': 'منتهية', + 'cancelled': 'ملغاة', + }; + + return SizedBox( + height: 46, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + children: options.entries.map((e) { + final selected = _status == e.key; + return Padding( + padding: const EdgeInsets.only(left: 8), + child: ChoiceChip( + label: Text(e.value), + selected: selected, + onSelected: (_) { + setState(() => _status = e.key); + c.fetchDrawdowns(status: e.key); + }, + ), + ); + }).toList(), + ), + ); + } + + Widget _voucherCard(FuelVoucher v, ColorScheme cs) { + final (color, label) = switch (v.status) { + 'redeemed' => (cs.primary, 'مصروفة'), + 'issued' => (cs.tertiary, 'سارية'), + 'cancelled' => (cs.error, 'ملغاة'), + _ => (cs.onSurfaceVariant, 'منتهية'), + }; + + // ‏المصروف لا المأذون: السائق قد يطلب عشرين ويتزوّد بخمسة عشر، + // ‏والدَّين على ما صُرف. عرض المأذون هنا يضخّم الأرقام في عين القارئ. + final amount = v.status == 'redeemed' ? v.amountRedeemed : v.amountAuthorized; + + return Card( + color: cs.surfaceContainerHighest, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + title: Row( + children: [ + Text('$amount ${v.currency}', + style: TextStyle( + color: cs.onSurface, fontWeight: FontWeight.bold)), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text(label, style: TextStyle(color: color, fontSize: 11)), + ), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + [ + 'سائق ${v.driverId}', + if (v.station != null && v.station!.isNotEmpty) v.station!, + v.redeemedAt ?? v.createdAt ?? '', + ].where((s) => s.isNotEmpty).join(' · '), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + ), + ), + ), + ); + } +} diff --git a/siro_admin/lib/views/admin/fuel/fuel_station_form.dart b/siro_admin/lib/views/admin/fuel/fuel_station_form.dart new file mode 100644 index 00000000..56fb732f --- /dev/null +++ b/siro_admin/lib/views/admin/fuel/fuel_station_form.dart @@ -0,0 +1,212 @@ +// fuel_station_form.dart — إضافة محطة أو تعديلها +// +// ‏يُرجع مفتاح المحطة الجديد عبر Get.back عند الإنشاء، وnull عند التعديل. +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../controller/fuel/fuel_admin_controller.dart'; +import '../../../controller/fuel/fuel_admin_models.dart'; + +class FuelStationForm extends StatefulWidget { + final FuelStation? station; + const FuelStationForm({super.key, this.station}); + + @override + State createState() => _FuelStationFormState(); +} + +class _FuelStationFormState extends State { + final _formKey = GlobalKey(); + + late final TextEditingController _name; + late final TextEditingController _chain; + late final TextEditingController _city; + late final TextEditingController _address; + late final TextEditingController _phone; + late final TextEditingController _lat; + late final TextEditingController _lng; + late final TextEditingController _discount; + + late bool _isActive; + + bool get _isEdit => widget.station != null; + + @override + void initState() { + super.initState(); + final s = widget.station; + _name = TextEditingController(text: s?.nameAr ?? ''); + _chain = TextEditingController(text: s?.chainName ?? ''); + _city = TextEditingController(text: s?.city ?? ''); + _address = TextEditingController(text: s?.address ?? ''); + _phone = TextEditingController(text: s?.phone ?? ''); + _lat = TextEditingController(text: s?.lat?.toString() ?? ''); + _lng = TextEditingController(text: s?.lng?.toString() ?? ''); + _discount = TextEditingController(text: (s?.discountPercent ?? 0).toString()); + _isActive = s?.isActive ?? true; + } + + @override + void dispose() { + for (final c in [_name, _chain, _city, _address, _phone, _lat, _lng, _discount]) { + c.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final c = Get.find(); + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + foregroundColor: cs.onSurface, + title: Text(_isEdit ? 'تعديل محطة' : 'محطة جديدة'), + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _field(cs, _name, 'اسم المحطة *', required: true), + _field(cs, _chain, 'السلسلة (إن كانت فرعاً)'), + _field(cs, _city, 'المدينة'), + _field(cs, _address, 'العنوان'), + _field(cs, _phone, 'الهاتف', keyboard: TextInputType.phone), + Row( + children: [ + Expanded( + child: _field(cs, _lat, 'خط العرض', + keyboard: TextInputType.number)), + const SizedBox(width: 10), + Expanded( + child: _field(cs, _lng, 'خط الطول', + keyboard: TextInputType.number)), + ], + ), + _field( + cs, + _discount, + 'نسبة خصم الشريك (٪)', + keyboard: TextInputType.number, + validator: (v) { + final d = double.tryParse(v ?? ''); + if (d == null) return 'أدخل رقماً'; + if (d < 0 || d > 100) return 'بين 0 و 100'; + return null; + }, + ), + // ‏الخصم يُسجَّل مع كل سحبة لا يُقرأ لاحقاً من هنا: النسبة + // ‏تتغيّر بالتفاوض، وحساب تسويات الماضي بنسبة اليوم تزوير. + Padding( + padding: const EdgeInsets.only(right: 4, bottom: 12), + child: Text( + 'يُسجَّل مع كل عملية صرف. تعديله لا يغيّر عمليات سابقة.', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + ), + ), + SwitchListTile( + value: _isActive, + activeThumbColor: cs.primary, + contentPadding: EdgeInsets.zero, + title: Text('محطة فعّالة', style: TextStyle(color: cs.onSurface)), + subtitle: Text( + _isActive + ? 'تظهر للسائقين وتستطيع تأكيد القسائم' + : 'مخفيّة عن السائقين ولا تستطيع تأكيد أي قسيمة', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + ), + onChanged: (v) => setState(() => _isActive = v), + ), + const SizedBox(height: 20), + SizedBox( + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: cs.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + onPressed: c.isSaving ? null : () => _submit(c), + child: c.isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : Text(_isEdit ? 'حفظ التعديلات' : 'إنشاء المحطة', + style: TextStyle(color: cs.onPrimary)), + ), + ), + if (!_isEdit) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + 'سيُولَّد مفتاح خاص بالمحطة عند الإنشاء، ويظهر مرة واحدة فقط.', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ); + } + + Widget _field( + ColorScheme cs, + TextEditingController ctrl, + String label, { + bool required = false, + TextInputType? keyboard, + String? Function(String?)? validator, + }) => + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextFormField( + controller: ctrl, + keyboardType: keyboard, + style: TextStyle(color: cs.onSurface), + validator: validator ?? + (required + ? (v) => (v == null || v.trim().isEmpty) ? 'مطلوب' : null + : null), + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: cs.onSurfaceVariant), + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ); + + Future _submit(FuelAdminController c) async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + final key = await c.saveStation( + stationId: widget.station?.id, + nameAr: _name.text.trim(), + chainName: _chain.text.trim(), + city: _city.text.trim(), + address: _address.text.trim(), + lat: double.tryParse(_lat.text.trim()), + lng: double.tryParse(_lng.text.trim()), + phone: _phone.text.trim(), + discountPercent: double.tryParse(_discount.text.trim()) ?? 0, + isActive: _isActive, + ); + + if (c.lastError != null) { + Get.snackbar('تعذّر الحفظ', c.lastError!); + return; + } + + Get.back(result: key); + } +} diff --git a/siro_admin/lib/views/admin/fuel/fuel_stations_page.dart b/siro_admin/lib/views/admin/fuel/fuel_stations_page.dart new file mode 100644 index 00000000..5aea7c8a --- /dev/null +++ b/siro_admin/lib/views/admin/fuel/fuel_stations_page.dart @@ -0,0 +1,402 @@ +// fuel_stations_page.dart — إدارة محطات الوقود الشريكة +// +// ‏الشاشة تعمل كاملةً حتى والمنتج مُطفأ: قرار المالك أن تُجهَّز الأمور +// ‏وتُبنى الشاشات، ولا يُشغَّل المنتج إلا بعد إتمام الاتفاق مع سلسلة +// ‏المحطات. لذلك اللافتة العلوية تقول الحالة صراحةً بدل إخفائها. +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; + +import '../../../controller/fuel/fuel_admin_controller.dart'; +import '../../../controller/fuel/fuel_admin_models.dart'; +import 'fuel_drawdowns_page.dart'; +import 'fuel_station_form.dart'; + +class FuelStationsPageView extends StatefulWidget { + const FuelStationsPageView({super.key}); + + @override + State createState() => _FuelStationsPageViewState(); +} + +class _FuelStationsPageViewState extends State { + final _searchCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + Get.put(FuelAdminController()); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return GetBuilder( + builder: (c) => Scaffold( + backgroundColor: cs.surface, + body: Column( + children: [ + Container( + padding: const EdgeInsets.fromLTRB(16, 50, 16, 12), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: cs.outline), + ), + child: Icon(Icons.arrow_back_ios_new_rounded, + color: cs.onSurfaceVariant, size: 16), + ), + ), + const SizedBox(width: 12), + Text('محفظة الوقود — المحطات', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w700)), + const Spacer(), + IconButton( + icon: Icon(Icons.receipt_long_rounded, color: cs.tertiary), + tooltip: 'حركة القسائم', + onPressed: () => Get.to(() => const FuelDrawdownsPageView()), + ), + IconButton( + icon: Icon(Icons.add, color: cs.primary), + tooltip: 'إضافة محطة', + onPressed: () => _openForm(c, null), + ), + ], + ), + ), + _productBanner(c, cs), + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + controller: _searchCtrl, + style: TextStyle(color: cs.onSurface), + onSubmitted: (v) { + c.searchQuery = v; + c.fetchStations(); + }, + decoration: InputDecoration( + hintText: 'ابحث باسم المحطة أو السلسلة أو العنوان...', + hintStyle: TextStyle(color: cs.onSurfaceVariant), + prefixIcon: Icon(Icons.search, color: cs.onSurfaceVariant), + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + Expanded( + child: c.isLoading + ? Center(child: CircularProgressIndicator(color: cs.primary)) + : c.stations.isEmpty + ? _emptyState(cs) + : RefreshIndicator( + onRefresh: c.fetchStations, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: c.stations.length, + itemBuilder: (_, i) => + _stationCard(c, c.stations[i], cs), + ), + ), + ), + ], + ), + ), + ); + } + + /// ‏لافتة الحالة. «مُجهَّز وغير مُشغَّل» حالة مقصودة لا عطل — وإخفاؤها + /// ‏يجعل من يعرض المنصة يظنّ المنتج حياً، أو يظنّه معطوباً. + Widget _productBanner(FuelAdminController c, ColorScheme cs) { + final active = c.productActive; + final color = active ? cs.primary : cs.tertiary; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + Icon(active ? Icons.check_circle_rounded : Icons.pause_circle_rounded, + color: color, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(active ? 'المنتج مُشغَّل' : 'مُجهَّز — غير مُشغَّل', + style: TextStyle( + color: color, fontWeight: FontWeight.w700, fontSize: 14)), + const SizedBox(height: 2), + Text( + active + ? 'السائقون المؤهَّلون يستطيعون التفعيل وإصدار القسائم' + : 'الشاشة تعمل للتجهيز والعرض. لا تفعيل ولا قسائم للسائقين.', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + ), + ], + ), + ), + Switch( + value: active, + activeThumbColor: cs.primary, + onChanged: (v) => _confirmToggle(c, v), + ), + ], + ), + ); + } + + /// ‏تأكيد قبل التشغيل لا بعده. التشغيل يفتح باب إقراض حقيقي بمال + /// ‏الشركة، ومفتاح يُقلب بالخطأ في عرضٍ أمام مستثمر ليس أمراً هيّناً. + Future _confirmToggle(FuelAdminController c, bool value) async { + final cs = Theme.of(context).colorScheme; + + final ok = await Get.dialog( + AlertDialog( + backgroundColor: cs.surfaceContainerHighest, + title: Text(value ? 'تشغيل محفظة الوقود؟' : 'إيقاف محفظة الوقود؟', + style: TextStyle(color: cs.onSurface)), + content: Text( + value + ? 'سيتمكّن السائقون المؤهَّلون من التزوّد بالدَّين فوراً. لا تُشغّله قبل إتمام الاتفاق مع المحطات.' + : 'سيتوقّف التفعيل وإصدار القسائم. الالتزامات القائمة ودفترها لا تتأثر.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('إلغاء')), + TextButton( + onPressed: () => Get.back(result: true), + child: Text(value ? 'تشغيل' : 'إيقاف', + style: TextStyle(color: value ? cs.primary : cs.error)), + ), + ], + ), + ); + + if (ok != true) return; + + final done = await c.toggleProduct(value); + if (!done && c.lastError != null) { + Get.snackbar('تعذّر التنفيذ', c.lastError!, + backgroundColor: cs.error.withValues(alpha: 0.15)); + } + } + + Widget _emptyState(ColorScheme cs) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.local_gas_station_rounded, + size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 12), + Text('لا توجد محطات بعد', + style: TextStyle(color: cs.onSurface, fontSize: 16)), + const SizedBox(height: 6), + Text('أضف أول محطة شريكة لتجهيز المنتج', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)), + ], + ), + ); + + Widget _stationCard(FuelAdminController c, FuelStation s, ColorScheme cs) { + final statusColor = s.isActive ? cs.primary : cs.onSurfaceVariant; + + return Card( + color: cs.surfaceContainerHighest, + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + title: Row( + children: [ + Expanded( + child: Text(s.nameAr, + style: TextStyle( + color: cs.onSurface, fontWeight: FontWeight.bold)), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text(s.isActive ? 'فعّالة' : 'موقوفة', + style: TextStyle(color: statusColor, fontSize: 11)), + ), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + [ + if (s.chainName != null && s.chainName!.isNotEmpty) s.chainName!, + if (s.city != null && s.city!.isNotEmpty) s.city!, + ].join(' · '), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + const SizedBox(height: 6), + Row( + children: [ + Icon(Icons.percent_rounded, size: 13, color: cs.tertiary), + const SizedBox(width: 2), + Text('خصم ${s.discountPercent}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)), + const SizedBox(width: 12), + Icon(Icons.local_gas_station, size: 13, color: cs.onSurfaceVariant), + const SizedBox(width: 2), + Text('${s.redeemCount} صرف', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)), + const SizedBox(width: 12), + Text('${s.redeemTotal}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)), + ], + ), + ], + ), + ), + trailing: PopupMenuButton( + color: cs.surfaceContainerHighest, + icon: Icon(Icons.more_vert, color: cs.onSurfaceVariant, size: 18), + onSelected: (v) async { + if (v == 'edit') { + _openForm(c, s); + } else if (v == 'rotate') { + await _rotateKey(c, s); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'edit', + child: Text('تعديل', style: TextStyle(color: cs.onSurface))), + PopupMenuItem( + value: 'rotate', + child: Text('توليد مفتاح جديد', + style: TextStyle(color: cs.error))), + ], + ), + onTap: () => _openForm(c, s), + ), + ); + } + + Future _openForm(FuelAdminController c, FuelStation? station) async { + final apiKey = await Get.to( + () => FuelStationForm(station: station), + ); + if (apiKey != null && apiKey.isNotEmpty) { + await _showKey(apiKey, station?.nameAr ?? 'المحطة الجديدة'); + } + } + + Future _rotateKey(FuelAdminController c, FuelStation s) async { + final cs = Theme.of(context).colorScheme; + + final ok = await Get.dialog( + AlertDialog( + backgroundColor: cs.surfaceContainerHighest, + title: Text('توليد مفتاح جديد؟', style: TextStyle(color: cs.onSurface)), + content: Text( + 'المفتاح الحالي لـ«${s.nameAr}» سيتوقّف فوراً، ولن تستطيع المحطة تأكيد أي قسيمة حتى تستلم الجديد.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('إلغاء')), + TextButton( + onPressed: () => Get.back(result: true), + child: Text('توليد', style: TextStyle(color: cs.error))), + ], + ), + ); + + if (ok != true) return; + + final key = await c.rotateKey(s.id); + if (key != null) { + await _showKey(key, s.nameAr); + } else if (c.lastError != null) { + Get.snackbar('تعذّر التوليد', c.lastError!, + backgroundColor: cs.error.withValues(alpha: 0.15)); + } + } + + /// ‏المفتاح يظهر مرة واحدة — مخزَّن مُهشَّراً على الخادم فلا سبيل + /// ‏لاستعادته. الحوار لا يُغلق باللمس خارجه: إغلاقه بالخطأ قبل النسخ + /// ‏يعني محطة بلا مفتاح ودورة توليد جديدة. + Future _showKey(String key, String stationName) async { + final cs = Theme.of(context).colorScheme; + + await Get.dialog( + barrierDismissible: false, + AlertDialog( + backgroundColor: cs.surfaceContainerHighest, + title: Text('مفتاح $stationName', + style: TextStyle(color: cs.onSurface, fontSize: 16)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: cs.outline), + ), + child: SelectableText(key, + style: TextStyle( + color: cs.onSurface, fontSize: 12, fontFamily: 'monospace')), + ), + const SizedBox(height: 10), + Text( + 'انسخه الآن وسلّمه للمحطة. لن يظهر مرة أخرى — المفتاح مخزَّن مشفّراً ولا يمكن استرجاعه.', + style: TextStyle(color: cs.error, fontSize: 11), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: key)); + Get.snackbar('نُسخ', 'المفتاح في الحافظة'); + }, + child: Text('نسخ', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () => Get.back(), child: const Text('تم')), + ], + ), + ); + } +} diff --git a/siro_admin/lib/views/widgets/web_sidebar.dart b/siro_admin/lib/views/widgets/web_sidebar.dart index a918e074..4b183c27 100644 --- a/siro_admin/lib/views/widgets/web_sidebar.dart +++ b/siro_admin/lib/views/widgets/web_sidebar.dart @@ -6,6 +6,7 @@ import 'package:siro_admin/views/admin/captain/captain.dart'; import 'package:siro_admin/views/admin/passenger/passenger.dart'; import 'package:siro_admin/views/admin/drivers/driver_tracker_screen.dart'; import 'package:siro_admin/views/transit/org_list_page.dart'; +import 'package:siro_admin/views/admin/fuel/fuel_stations_page.dart'; import 'package:siro_admin/views/admin/static/static.dart'; import 'package:siro_admin/controller/admin/static_controller.dart'; import 'package:siro_admin/views/admin/drivers/monitor_ride.dart'; @@ -172,6 +173,15 @@ class WebSidebar extends StatelessWidget { onTap: () => Get.to(() => const TransitOrgListPage()), ), + _buildSectionHeader(context, 'اقتصاد السائق'), + _buildNavItem( + context, + title: 'محطات الوقود', + icon: Icons.local_gas_station_rounded, + index: 140, + onTap: () => Get.to(() => const FuelStationsPageView()), + ), + _buildSectionHeader(context, 'المالية والأسعار'), _buildNavItem( context, diff --git a/siro_driver/lib/constant/links.dart b/siro_driver/lib/constant/links.dart index f56ed51e..6e7c77c7 100755 --- a/siro_driver/lib/constant/links.dart +++ b/siro_driver/lib/constant/links.dart @@ -278,6 +278,13 @@ class AppLink { static String get getDriverDetails => "$seferCairoServer/ride/driverWallet/getDriverDetails.php"; + // ================= Earnings Endpoints ================= + // نقطة واحدة لكل أرقام شاشة الأرباح بختم زمني واحد. النداءات الأربعة + // السابقة كانت تصل في لحظات مختلفة فتُنتج أرقاماً لا تتفق. + static String get getEarningsSummary => + "$endPoint/ride/earnings/summary.php"; + static String get getEarningsTrips => "$endPoint/ride/earnings/trips.php"; + // ================= Gamification Endpoints ================= static String get getWeeklyAggregate => "$endPoint/ride/gamification/getWeeklyAggregate.php"; diff --git a/siro_driver/lib/controller/home/captin/home_captain_controller.dart b/siro_driver/lib/controller/home/captin/home_captain_controller.dart index 94b28a5a..e6ded9fe 100755 --- a/siro_driver/lib/controller/home/captin/home_captain_controller.dart +++ b/siro_driver/lib/controller/home/captin/home_captain_controller.dart @@ -299,7 +299,7 @@ class HomeCaptainController extends GetxController { void goToWalletFromConnect() { Get.back(); Get.back(); - Get.to(() => WalletCaptainRefactored()); + Get.to(() => const WalletCaptainRefactored()); } void changeRideId() { diff --git a/siro_driver/lib/controller/home/statistics/earnings_controller.dart b/siro_driver/lib/controller/home/statistics/earnings_controller.dart new file mode 100644 index 00000000..ca7bbbe7 --- /dev/null +++ b/siro_driver/lib/controller/home/statistics/earnings_controller.dart @@ -0,0 +1,176 @@ +// earnings_controller.dart — مصدر واحد لأرقام شاشة الأرباح +// +// ‏كانت الأرقام تُبنى من أربعة نداءات مستقلة، فيصل كلٌّ منها في لحظة +// ‏مختلفة وتظهر أرقام لا تتفق. هنا نداء واحد بختم زمني واحد. +// +// ‏والأهم: **الفشل لا يُترجم أصفاراً**. الكود السابق كان عند فشل الشبكة +// ‏يولّد سبعة أيام بأصفار ويعرضها كأنها أرباح السائق — وهو يرى دخله وقد +// ‏تبخّر. الحالتان هنا منفصلتان: `hasError` غير «كسبتَ صفراً». +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:siro_driver/constant/links.dart'; +import 'package:siro_driver/controller/functions/crud.dart'; +import 'package:siro_driver/main.dart'; + +import 'earnings_models.dart'; + +class EarningsController extends GetxController { + EarningsSummary? summary; + List trips = []; + + bool isLoading = false; + bool isLoadingTrips = false; + + /// ‏سبب الفشل كنصّ للسائق، أو null. وجوده يعني «لا نعرف أرباحك الآن» + /// ‏لا «أرباحك صفر» — والفرق بينهما هو الفرق بين عطلٍ يُحتمل وثقةٍ تُفقد. + String? errorMessage; + + /// ‏هل المعروض من الذاكرة المؤقتة؟ الشاشة تُظهر ذلك صراحةً مع وقت + /// ‏القياس، فلا يقرأ السائق رقماً عمره ساعات على أنه لحظته. + bool isStale = false; + + DateTime? lastFetch; + + /// ‏اليوم المختار لقائمة الرحلات (null = آخر سبعة أيام). + String? selectedDay; + + static const _cacheKey = 'earnings_summary_cache'; + static const _cacheAtKey = 'earnings_summary_cached_at'; + + /// ‏دقيقتان لا ثلاث ساعات. الكاش الطويل السابق كان سبب تناقض شاشة + /// ‏الأرباح مع شاشة الرصيد؛ ومدة قصيرة تكفي لمنع النداءات المتكررة + /// ‏عند التنقّل بين الشاشات بلا أن تُقدّم للسائق ماضياً على أنه حاضر. + static const _freshFor = Duration(minutes: 2); + + @override + void onInit() { + super.onInit(); + _loadFromCache(); + load(); + } + + void _loadFromCache() { + try { + final raw = box.read(_cacheKey); + if (raw == null) return; + + summary = EarningsSummary.fromJson( + Map.from(jsonDecode(raw))); + + final at = box.read(_cacheAtKey); + if (at != null) lastFetch = DateTime.tryParse(at.toString()); + isStale = true; + update(); + } catch (e) { + debugPrint('[Earnings] تعذّرت قراءة الكاش: $e'); + } + } + + Future load({bool force = false}) async { + if (!force && lastFetch != null && !isStale) { + if (DateTime.now().difference(lastFetch!) < _freshFor) return; + } + + isLoading = summary == null; // ‏دوّار فقط حين لا يوجد ما يُعرض + errorMessage = null; + update(); + + final res = await CRUD().get(link: AppLink.getEarningsSummary); + + // ‏الرسائل مفصّلة بحسب سبب الفشل: «تحقق من اتصالك» تُصلح بفعل، + // ‏و«حدث خطأ» تترك السائق بلا حيلة. + if (res == 'no_internet') { + _fail('تعذّر الوصول للخادم — تحقّق من اتصالك'); + return; + } + if (res == 'token_expired') { + _fail('انتهت الجلسة، حاول مجدداً'); + return; + } + if (res == 'failure' || res == null) { + _fail('تعذّر تحميل أرباحك الآن'); + return; + } + + try { + final data = jsonDecode(res); + if (data['status'] != 'success' || data['message'] is! Map) { + _fail('تعذّر تحميل أرباحك الآن'); + return; + } + + summary = EarningsSummary.fromJson( + Map.from(data['message'])); + + box.write(_cacheKey, jsonEncode(data['message'])); + box.write(_cacheAtKey, DateTime.now().toIso8601String()); + + lastFetch = DateTime.now(); + isStale = false; + errorMessage = null; + } catch (e) { + debugPrint('[Earnings] تعذّر تحليل الرد: $e'); + _fail('تعذّر تحميل أرباحك الآن'); + return; + } finally { + isLoading = false; + update(); + } + + await loadTrips(); + } + + /// ‏الفشل يُبقي آخر بيانات معروفة معروضة ومعلَّمة كقديمة، ولا يستبدلها + /// ‏بأصفار. رقم قديم صادق أنفع من رقم حاضر كاذب. + void _fail(String message) { + errorMessage = message; + isLoading = false; + if (summary != null) isStale = true; + update(); + } + + Future loadTrips({String? day}) async { + selectedDay = day; + isLoadingTrips = true; + update(); + + final res = await CRUD().get( + link: AppLink.getEarningsTrips, + payload: { + if (day != null) 'date': day, + if (day == null) 'range': 'week', + }, + ); + + try { + if (res is String && res != 'failure' && res != 'no_internet' && res != 'token_expired') { + final data = jsonDecode(res); + if (data['status'] == 'success' && data['message'] is Map) { + final list = data['message']['trips']; + trips = (list is List) + ? list + .map((t) => TripEarning.fromJson(Map.from(t))) + .toList() + : []; + } + } + } catch (e) { + debugPrint('[Earnings] تعذّر تحليل الرحلات: $e'); + } + + isLoadingTrips = false; + update(); + } + + /// ‏نصّ «آخر تحديث» بصيغة يفهمها السائق بلا حساب. + String get lastUpdatedLabel { + if (lastFetch == null) return ''; + final diff = DateTime.now().difference(lastFetch!); + if (diff.inMinutes < 1) return 'الآن'; + if (diff.inMinutes < 60) return 'قبل ${diff.inMinutes} دقيقة'; + if (diff.inHours < 24) return 'قبل ${diff.inHours} ساعة'; + return 'قبل ${diff.inDays} يوم'; + } +} diff --git a/siro_driver/lib/controller/home/statistics/earnings_models.dart b/siro_driver/lib/controller/home/statistics/earnings_models.dart new file mode 100644 index 00000000..3db01bf9 --- /dev/null +++ b/siro_driver/lib/controller/home/statistics/earnings_models.dart @@ -0,0 +1,226 @@ +// earnings_models.dart — نماذج شاشة الأرباح +// +// ‏كل الأرقام تصل من نقطة واحدة (`ride/earnings/summary.php`) بختم زمني +// ‏واحد، فلا مجال لأن يتناقض رقمان في الشاشة نفسها. + +class PeriodEarnings { + final int trips; + + /// ‏صافي السائق — حصته المثبَّتة لحظة عرض السعر، أي ما وُعد به قبل القبول. + final double net; + + /// ‏إجمالي ما دفعه الركاب. + final double gross; + + /// ‏عمولة المنصة. تُعرض صراحةً لا تُطرح بصمت: السائق الذي يرى تركيبة + /// ‏المبلغ يفهم، والذي يرى الصافي وحده يخمّن ويشكّ. + final double commission; + + final double distance; + final int minutes; + + PeriodEarnings({ + required this.trips, + required this.net, + required this.gross, + required this.commission, + required this.distance, + required this.minutes, + }); + + double get hours => minutes / 60.0; + + factory PeriodEarnings.fromJson(Map j) => PeriodEarnings( + trips: int.tryParse(j['trips']?.toString() ?? '0') ?? 0, + net: double.tryParse(j['net']?.toString() ?? '0') ?? 0, + gross: double.tryParse(j['gross']?.toString() ?? '0') ?? 0, + commission: double.tryParse(j['commission']?.toString() ?? '0') ?? 0, + distance: double.tryParse(j['distance']?.toString() ?? '0') ?? 0, + minutes: int.tryParse(j['minutes']?.toString() ?? '0') ?? 0, + ); + + static PeriodEarnings empty() => PeriodEarnings( + trips: 0, net: 0, gross: 0, commission: 0, distance: 0, minutes: 0); +} + +class DayEarnings { + final String day; + final int trips; + final double net; + final int minutes; + + DayEarnings( + {required this.day, + required this.trips, + required this.net, + required this.minutes}); + + factory DayEarnings.fromJson(Map j) => DayEarnings( + day: j['day']?.toString() ?? '', + trips: int.tryParse(j['trips']?.toString() ?? '0') ?? 0, + net: double.tryParse(j['net']?.toString() ?? '0') ?? 0, + minutes: int.tryParse(j['minutes']?.toString() ?? '0') ?? 0, + ); +} + +class DeductionItem { + final String product; + final String name; + final double outstanding; + + DeductionItem( + {required this.product, required this.name, required this.outstanding}); + + factory DeductionItem.fromJson(Map j) => DeductionItem( + product: j['product']?.toString() ?? '', + name: j['name']?.toString() ?? '', + outstanding: double.tryParse(j['outstanding']?.toString() ?? '0') ?? 0, + ); +} + +/// ‏الاقتطاعات (تأمين، وقود) تُعرض بجانب الأرباح التي اقتُطعت منها. +/// ‏اقتطاعٌ لا يظهر في مكانه يُقرأ سرقةً مهما كان مشروعاً. +class Deductions { + final double today; + final double week; + final double month; + final double outstanding; + final List items; + + Deductions({ + required this.today, + required this.week, + required this.month, + required this.outstanding, + required this.items, + }); + + bool get hasAny => today > 0 || outstanding > 0; + + factory Deductions.fromJson(Map j) => Deductions( + today: double.tryParse(j['today']?.toString() ?? '0') ?? 0, + week: double.tryParse(j['week']?.toString() ?? '0') ?? 0, + month: double.tryParse(j['month']?.toString() ?? '0') ?? 0, + outstanding: double.tryParse(j['outstanding']?.toString() ?? '0') ?? 0, + items: (j['items'] is List) + ? (j['items'] as List) + .map((i) => DeductionItem.fromJson(Map.from(i))) + .toList() + : [], + ); +} + +class EarningsSummary { + final PeriodEarnings today; + final PeriodEarnings week; + final PeriodEarnings month; + final PeriodEarnings prevWeek; + + /// ‏نسبة التغيّر عن الأسبوع الماضي، أو null إن لم يكن هناك ما يُقارَن به. + /// ‏المقارنة بالذات لا بالسائقين الآخرين: ترتيبٌ يُحبط أكثر من يحتاج + /// ‏التحفيز — فهو من سيقع في أسفله. + final double? trendPercent; + + final List daily; + final DayEarnings? bestDay; + final Deductions deductions; + final String asOf; + + EarningsSummary({ + required this.today, + required this.week, + required this.month, + required this.prevWeek, + required this.daily, + required this.deductions, + required this.asOf, + this.trendPercent, + this.bestDay, + }); + + factory EarningsSummary.fromJson(Map j) => EarningsSummary( + today: PeriodEarnings.fromJson(Map.from(j['today'] ?? {})), + week: PeriodEarnings.fromJson(Map.from(j['week'] ?? {})), + month: PeriodEarnings.fromJson(Map.from(j['month'] ?? {})), + prevWeek: + PeriodEarnings.fromJson(Map.from(j['prev_week'] ?? {})), + trendPercent: j['trend_percent'] == null + ? null + : double.tryParse(j['trend_percent'].toString()), + daily: (j['daily'] is List) + ? (j['daily'] as List) + .map((d) => DayEarnings.fromJson(Map.from(d))) + .toList() + : [], + bestDay: j['best_day'] is Map + ? DayEarnings.fromJson(Map.from(j['best_day'])) + : null, + deductions: + Deductions.fromJson(Map.from(j['deductions'] ?? {})), + asOf: j['as_of']?.toString() ?? '', + ); +} + +class TripBreakdown { + final double gross; + final double commission; + final double bonus; + final double net; + + /// ‏إعفاء العمولة (سلسلة ٠٪) — هدية تدفع المنصة ثمنها ولم تكن تظهر + /// ‏للسائق في أي شاشة. نميّزها لتُعرض مكسباً لا سطراً فارغاً. + final bool zeroCommission; + + TripBreakdown({ + required this.gross, + required this.commission, + required this.bonus, + required this.net, + required this.zeroCommission, + }); + + factory TripBreakdown.fromJson(Map j) => TripBreakdown( + gross: double.tryParse(j['gross']?.toString() ?? '0') ?? 0, + commission: double.tryParse(j['commission']?.toString() ?? '0') ?? 0, + bonus: double.tryParse(j['bonus']?.toString() ?? '0') ?? 0, + net: double.tryParse(j['net']?.toString() ?? '0') ?? 0, + zeroCommission: j['zero_commission'] == true, + ); +} + +class TripEarning { + final int rideId; + final String at; + final String from; + final String to; + final String carType; + final String paymentMethod; + final double distanceKm; + final int minutes; + final TripBreakdown breakdown; + + TripEarning({ + required this.rideId, + required this.at, + required this.from, + required this.to, + required this.carType, + required this.paymentMethod, + required this.distanceKm, + required this.minutes, + required this.breakdown, + }); + + factory TripEarning.fromJson(Map j) => TripEarning( + rideId: int.tryParse(j['ride_id']?.toString() ?? '0') ?? 0, + at: j['at']?.toString() ?? '', + from: j['from']?.toString() ?? '', + to: j['to']?.toString() ?? '', + carType: j['car_type']?.toString() ?? '', + paymentMethod: j['payment_method']?.toString() ?? '', + distanceKm: double.tryParse(j['distance_km']?.toString() ?? '0') ?? 0, + minutes: int.tryParse(j['minutes']?.toString() ?? '0') ?? 0, + breakdown: + TripBreakdown.fromJson(Map.from(j['breakdown'] ?? {})), + ); +} diff --git a/siro_driver/lib/controller/home/statistics/statistics_controller.dart b/siro_driver/lib/controller/home/statistics/statistics_controller.dart deleted file mode 100644 index bef6175e..00000000 --- a/siro_driver/lib/controller/home/statistics/statistics_controller.dart +++ /dev/null @@ -1,293 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:siro_driver/constant/box_name.dart'; -import 'package:siro_driver/constant/links.dart'; -import 'package:siro_driver/controller/functions/crud.dart'; -import 'package:siro_driver/models/model/driver/rides_summary_model.dart'; -import '../../../main.dart'; - -class StatisticsController extends GetxController { - bool isLoading = false; - - // ═══ Weekly Data ═══ - List weeklyStats = []; - double weeklyEarnings = 0; - int weeklyTrips = 0; - double weeklyHours = 0; - - // ═══ Monthly Data ═══ - List monthlyEarnings = []; - List monthlyRides = []; - List monthlyDuration = []; - double monthlyTotalEarnings = 0; - int monthlyTotalTrips = 0; - double monthlyTotalHours = 0; - String bestDay = '--'; - double bestDayEarnings = 0; - - // ═══ Tab State ═══ - int selectedTab = 0; // 0=weekly, 1=monthly - - @override - void onInit() { - super.onInit(); - reloadData(); - } - - void changeTab(int tab) { - selectedTab = tab; - update(); - } - - // ═══════ جلب البيانات الأسبوعية ═══════ - Future fetchWeeklyData() async { - isLoading = true; - update(); - - try { - var res = await CRUD().get( - link: AppLink.getWeeklyAggregate, - payload: { - 'driver_id': box.read(BoxName.driverID).toString(), - }, - ); - - if (res != null && res != 'failure') { - box.write('stats_weekly_cache', res); - var data = jsonDecode(res); - if (data['message'] is List) { - weeklyStats = (data['message'] as List) - .map((e) => DayStat.fromJson(e)) - .toList(); - weeklyEarnings = weeklyStats.fold(0, (s, d) => s + d.earnings); - weeklyTrips = weeklyStats.fold(0, (s, d) => s + d.trips); - weeklyHours = weeklyStats.fold(0, (s, d) => s + d.hours); - } - } - } catch (e) { - debugPrint('❌ [Stats] Weekly fetch error: $e'); - // Fallback: generate from local data - _generateLocalWeeklyData(); - } - - isLoading = false; - update(); - } - - // ═══════ جلب البيانات الشهرية ═══════ - Future fetchMonthlyData() async { - try { - // 1. أرباح شهرية - var earningsRes = await CRUD().getWallet( - link: AppLink.getAllPaymentFromRide, - payload: {'driverID': box.read(BoxName.driverID).toString()}, - ); - if (earningsRes != null && earningsRes != 'failure') { - box.write('stats_monthly_earnings_cache', earningsRes); - var data = jsonDecode(earningsRes); - if (data['message'] is List) { - monthlyEarnings = (data['message'] as List) - .map((e) => MonthlyPriceDriverModel.fromJson(e)) - .toList(); - monthlyTotalEarnings = - monthlyEarnings.fold(0, (s, d) => s + d.pricePerDay); - - // أفضل يوم - if (monthlyEarnings.isNotEmpty) { - var best = monthlyEarnings - .reduce((a, b) => a.pricePerDay > b.pricePerDay ? a : b); - bestDay = best.day.toString(); - bestDayEarnings = best.pricePerDay; - } - } - } - - // 2. رحلات شهرية - var ridesRes = await CRUD().get( - link: AppLink.getRidesDriverByDay, - payload: {'driver_id': box.read(BoxName.driverID).toString()}, - ); - if (ridesRes != null && ridesRes != 'failure') { - box.write('stats_monthly_rides_cache', ridesRes); - var data = jsonDecode(ridesRes); - if (data['message'] is List) { - monthlyRides = (data['message'] as List) - .map((e) => MonthlyRideModel.fromJson(e)) - .toList(); - monthlyTotalTrips = monthlyRides.fold(0, (s, d) => s + d.countRide); - } - } - - // 3. ساعات شهرية - var durationRes = await CRUD().get( - link: AppLink.getTotalDriverDuration, - payload: {'driver_id': box.read(BoxName.driverID).toString()}, - ); - if (durationRes != null && durationRes != 'failure') { - box.write('stats_monthly_duration_cache', durationRes); - var data = jsonDecode(durationRes); - if (data['message'] is List) { - monthlyDuration = (data['message'] as List) - .map((e) => MonthlyDataModel.fromJson(e)) - .toList(); - monthlyTotalHours = - monthlyDuration.fold(0, (s, d) => s + d.totalDuration.toDouble()); - } - } - } catch (e) { - debugPrint('❌ [Stats] Monthly fetch error: $e'); - } - - update(); - } - - void _generateLocalWeeklyData() { - // Fallback بيانات محلية عند عدم توفر الـ API - final now = DateTime.now(); - weeklyStats = List.generate(7, (i) { - final day = now.subtract(Duration(days: 6 - i)); - return DayStat( - date: day, - dayName: _getDayName(day.weekday), - earnings: 0, - trips: 0, - hours: 0, - ); - }); - } - - String _getDayName(int weekday) { - const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - return days[weekday - 1]; - } - - bool _isFetching = false; - - Future reloadData({bool force = false}) async { - if (_isFetching) return; - _isFetching = true; - - // التحقق من التخزين المؤقت (3 ساعات) - String? lastFetchStr = box.read('stats_last_fetch'); - if (!force && lastFetchStr != null) { - try { - DateTime lastFetch = DateTime.parse(lastFetchStr); - if (DateTime.now().difference(lastFetch).inHours < 3) { - debugPrint('ℹ️ [Statistics] Loading cached statistics (last fetch: $lastFetchStr)'); - - // تحميل الكاش الأسبوعي - String? weeklyCache = box.read('stats_weekly_cache'); - if (weeklyCache != null) { - var data = jsonDecode(weeklyCache); - if (data['message'] is List) { - weeklyStats = (data['message'] as List) - .map((e) => DayStat.fromJson(e)) - .toList(); - weeklyEarnings = weeklyStats.fold(0, (s, d) => s + d.earnings); - weeklyTrips = weeklyStats.fold(0, (s, d) => s + d.trips); - weeklyHours = weeklyStats.fold(0, (s, d) => s + d.hours); - } - } - - // تحميل الكاش الشهري (الأرباح) - String? earningsCache = box.read('stats_monthly_earnings_cache'); - if (earningsCache != null) { - var data = jsonDecode(earningsCache); - if (data['message'] is List) { - monthlyEarnings = (data['message'] as List) - .map((e) => MonthlyPriceDriverModel.fromJson(e)) - .toList(); - monthlyTotalEarnings = - monthlyEarnings.fold(0, (s, d) => s + d.pricePerDay); - if (monthlyEarnings.isNotEmpty) { - var best = monthlyEarnings - .reduce((a, b) => a.pricePerDay > b.pricePerDay ? a : b); - bestDay = best.day.toString(); - bestDayEarnings = best.pricePerDay; - } - } - } - - // تحميل الكاش الشهري (الرحلات) - String? ridesCache = box.read('stats_monthly_rides_cache'); - if (ridesCache != null) { - var data = jsonDecode(ridesCache); - if (data['message'] is List) { - monthlyRides = (data['message'] as List) - .map((e) => MonthlyRideModel.fromJson(e)) - .toList(); - monthlyTotalTrips = monthlyRides.fold(0, (s, d) => s + d.countRide); - } - } - - // تحميل الكاش الشهري (المدد) - String? durationCache = box.read('stats_monthly_duration_cache'); - if (durationCache != null) { - var data = jsonDecode(durationCache); - if (data['message'] is List) { - monthlyDuration = (data['message'] as List) - .map((e) => MonthlyDataModel.fromJson(e)) - .toList(); - monthlyTotalHours = - monthlyDuration.fold(0, (s, d) => s + d.totalDuration.toDouble()); - } - } - - _isFetching = false; - update(); - return; - } - } catch (e) { - debugPrint('⚠️ [Statistics] Failed to load cached statistics: $e'); - } - } - - isLoading = true; - update(); - - try { - debugPrint('📊 [Statistics] Reloading data from server...'); - await fetchWeeklyData(); - await fetchMonthlyData(); - box.write('stats_last_fetch', DateTime.now().toIso8601String()); - debugPrint('📊 [Statistics] Data reload complete.'); - } catch (e) { - debugPrint('❌ [Statistics] Error reloading data: $e'); - } finally { - _isFetching = false; - isLoading = false; - update(); - } - } -} - -// ═══════ نموذج إحصائية اليوم ═══════ -class DayStat { - final DateTime date; - final String dayName; - final double earnings; - final int trips; - final double hours; - - DayStat({ - required this.date, - required this.dayName, - required this.earnings, - required this.trips, - required this.hours, - }); - - factory DayStat.fromJson(Map json) { - final date = - DateTime.tryParse(json['day']?.toString() ?? '') ?? DateTime.now(); - const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - return DayStat( - date: date, - dayName: dayNames[date.weekday - 1], - earnings: double.tryParse(json['earnings']?.toString() ?? '0') ?? 0, - trips: int.tryParse(json['trips']?.toString() ?? '0') ?? 0, - hours: double.tryParse(json['hours']?.toString() ?? '0') ?? 0, - ); - } -} diff --git a/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart b/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart index 4cb844a4..5f136b49 100755 --- a/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart +++ b/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart @@ -12,7 +12,7 @@ import 'package:siro_driver/main.dart'; import 'package:siro_driver/views/Rate/rate_app_page.dart'; import 'package:siro_driver/views/auth/captin/contact_us_page.dart'; import 'package:siro_driver/views/auth/captin/invite_driver_screen.dart'; -import 'package:siro_driver/views/home/statistics/statistics_dashboard.dart'; +import 'package:siro_driver/views/home/statistics/earnings_page.dart'; import 'package:siro_driver/views/gamification/challenges_page.dart'; import 'package:siro_driver/views/gamification/leaderboard_page.dart'; import 'package:siro_driver/views/home/journal/schedule_page.dart'; @@ -67,12 +67,12 @@ class AppDrawer extends StatelessWidget { title: 'Balance'.tr, icon: Icons.account_balance_wallet, color: Colors.green, - onTap: () => Get.to(() => WalletCaptainRefactored())), + onTap: () => Get.to(() => const WalletCaptainRefactored())), DrawerItem( - title: 'Statistics'.tr, - icon: Icons.bar_chart_rounded, - color: Colors.deepPurple, - onTap: () => Get.to(() => StatisticsDashboard())), + title: 'أرباحي', + icon: Icons.payments_rounded, + color: Colors.teal, + onTap: () => Get.to(() => EarningsPage())), DrawerItem( title: 'Challenges'.tr, icon: Icons.bolt_rounded, diff --git a/siro_driver/lib/views/home/Captin/home_captain/widget/left_menu_map_captain.dart b/siro_driver/lib/views/home/Captin/home_captain/widget/left_menu_map_captain.dart index 444440c4..6f75c240 100755 --- a/siro_driver/lib/views/home/Captin/home_captain/widget/left_menu_map_captain.dart +++ b/siro_driver/lib/views/home/Captin/home_captain/widget/left_menu_map_captain.dart @@ -16,7 +16,7 @@ import '../../../../../controller/functions/crud.dart'; import '../../../../../controller/home/captin/order_request_controller.dart'; import '../../../../../print.dart'; import '../../../../Rate/ride_calculate_driver.dart'; -import 'package:siro_driver/views/home/statistics/statistics_dashboard.dart'; +import 'package:siro_driver/views/home/statistics/earnings_page.dart'; // ───────────────────────────────────────────── // Design Tokens (Responsive) @@ -103,7 +103,7 @@ GetBuilder leftMainMenuCaptainIcons() { color: _T.blue, tooltip: 'Statistics'.tr, onTap: () { - Get.to(() => StatisticsDashboard()); + Get.to(() => EarningsPage()); }, onLongPress: () => box.write(BoxName.statusDriverLocation, 'off'), diff --git a/siro_driver/lib/views/home/my_wallet/walet_captain.dart b/siro_driver/lib/views/home/my_wallet/walet_captain.dart index ec53105c..fb1b38cb 100755 --- a/siro_driver/lib/views/home/my_wallet/walet_captain.dart +++ b/siro_driver/lib/views/home/my_wallet/walet_captain.dart @@ -26,14 +26,29 @@ import 'widgets/financial_summary_card.dart'; import 'widgets/promo_gamification_card.dart'; import 'widgets/transaction_preview_item.dart'; -class WalletCaptainRefactored extends StatelessWidget { - WalletCaptainRefactored({super.key}); +class WalletCaptainRefactored extends StatefulWidget { + const WalletCaptainRefactored({super.key}); + @override + State createState() => + _WalletCaptainRefactoredState(); +} + +class _WalletCaptainRefactoredState extends State { final CaptainWalletController controller = Get.put(CaptainWalletController()); @override - Widget build(BuildContext context) { + void initState() { + super.initState(); + // ‏كان التحديث يُنادى داخل build. وbuild يعمل عشرات المرات — عند كل + // ‏إعادة رسم، وكل تغيّر حالة، وكل دوران للشاشة — فكان كل واحدة منها + // ‏تُطلق نداءات شبكية. مكانه هنا: مرة واحدة عند فتح الشاشة، والباقي + // ‏بالسحب للتحديث أو بزر التحديث. controller.refreshCaptainWallet(); + } + + @override + Widget build(BuildContext context) { return Scaffold( backgroundColor: FinanceDesignSystem.backgroundColor, appBar: AppBar( diff --git a/siro_driver/lib/views/home/statistics/earnings_page.dart b/siro_driver/lib/views/home/statistics/earnings_page.dart new file mode 100644 index 00000000..ed57237b --- /dev/null +++ b/siro_driver/lib/views/home/statistics/earnings_page.dart @@ -0,0 +1,729 @@ +// earnings_page.dart — شاشة أرباح السائق +// +// ‏الترتيب مقصود ويخالف الشاشة السابقة: **اليوم أولاً**. الشاشة القديمة +// ‏كانت تبدأ بترويسة زخرفية بارتفاع ٢٠٠ ثم تبويبَي أسبوعي/شهري — والسائق +// ‏يفتح الشاشة ليعرف كم كسب اليوم، لا ليقرأ تقريراً شهرياً. +// +// ‏ثم الاقتطاعات، ثم أسبوعه، ثم تفصيل كل رحلة. +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:siro_driver/constant/currency.dart'; +import 'package:siro_driver/constant/finance_design_system.dart'; +import 'package:siro_driver/controller/gamification/gamification_controller.dart'; +import 'package:siro_driver/controller/home/statistics/earnings_controller.dart'; +import 'package:siro_driver/controller/home/statistics/earnings_models.dart'; + +import 'widgets/behavior_and_achievements.dart'; +import 'widgets/daily_goal_widget.dart'; +import 'widgets/level_progress_widget.dart'; +import 'widgets/stat_summary_card.dart'; + +class EarningsPage extends StatelessWidget { + EarningsPage({super.key}); + + // ‏Get.put هنا لا في build: الشاشة السابقة كانت تنادي التحديث داخل + // ‏build، وbuild يعمل عشرات المرات — أي نداء شبكي مع كل إعادة رسم. + final EarningsController c = Get.put(EarningsController()); + final GamificationController gam = Get.put(GamificationController()); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: FinanceDesignSystem.backgroundColor, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: true, + title: Text('أرباحي', + style: TextStyle( + fontWeight: FontWeight.bold, + color: FinanceDesignSystem.primaryDark)), + leading: IconButton( + icon: Icon(Icons.arrow_back_ios_new_rounded, + color: FinanceDesignSystem.primaryDark, size: 20), + onPressed: () => Get.back(), + ), + ), + body: GetBuilder( + builder: (ctl) { + if (ctl.isLoading && ctl.summary == null) { + return const Center(child: CircularProgressIndicator()); + } + + // ‏لا بيانات ولا كاش: نعرض الخطأ صريحاً مع زر إعادة. الشاشة + // ‏السابقة كانت تعرض سبعة أيام بأصفار في هذه الحالة — فيرى + // ‏السائق دخله وقد تبخّر، وهي أسوأ رسالة يمكن أن يتلقّاها. + if (ctl.summary == null) { + return _errorState(ctl); + } + + final s = ctl.summary!; + + // ‏الترتيب هو القرار الأهم في هذه الشاشة بعد دمج الإحصائيات + // ‏فيها: **المال أولاً، ثم التقدّم**. السائق يفتحها ليعرف كم + // ‏كسب، لا ليرى مستواه — ووضع التلعيب قبل الأرباح كان يجعله + // ‏يمرّر ثلاث شاشات ليصل إلى ما جاء من أجله. + return RefreshIndicator( + onRefresh: () async { + await ctl.load(force: true); + await gam.fetchGamificationData(force: true); + }, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: FinanceDesignSystem.horizontalPadding, + vertical: 8), + children: [ + if (ctl.errorMessage != null) _staleBanner(ctl), + _todayCard(s, ctl), + const SizedBox(height: 16), + if (s.deductions.hasAny) ...[ + _deductionsCard(s.deductions), + const SizedBox(height: 16), + ], + _weekCard(s, ctl), + const SizedBox(height: 16), + _monthCard(s), + const SizedBox(height: 16), + _tripsSection(ctl), + const SizedBox(height: 24), + _progressSection(), + const SizedBox(height: 40), + ], + ), + ); + }, + ), + ); + } + + /// ‏قسم التقدّم — ما كان شاشة «الإحصائيات» المنفصلة. + /// + /// ‏في GetBuilder خاصّ به: بيانات التلعيب تصل من نقطة أخرى وفي وقت + /// ‏آخر، وربطها بمُنشئ الأرباح كان سيعيد رسم الشاشة كلها — بأرقام + /// ‏المال وقوائم الرحلات — كلما تحدّث عدّاد إنجاز. + Widget _progressSection() => GetBuilder( + builder: (gc) { + if (gc.isLoading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('تقدّمي', style: FinanceDesignSystem.headingStyle), + const SizedBox(height: 12), + + // ‏هدف اليوم بعد الأرباح مباشرةً منطقياً — لكنه هنا لأنه + // ‏تحفيز لا معلومة: مكانه بعد أن يعرف السائق أرقامه. + DailyGoalWidget(controller: gc), + const SizedBox(height: 16), + + LevelProgressWidget(controller: gc), + const SizedBox(height: 16), + + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.5, + children: [ + StatSummaryCard( + icon: Icons.local_taxi_rounded, + label: 'Total Trips'.tr, + value: gc.totalTrips.toString(), + color: FinanceDesignSystem.accentBlue, + ), + StatSummaryCard( + icon: Icons.star_rounded, + label: 'Rating'.tr, + value: gc.averageRating.toStringAsFixed(1), + color: const Color(0xFFFFD700), + ), + StatSummaryCard( + icon: Icons.whatshot_rounded, + label: 'Day Streak'.tr, + value: '${gc.consecutiveDays}', + color: const Color(0xFFFF5722), + ), + StatSummaryCard( + icon: Icons.people_rounded, + label: 'Referrals'.tr, + value: gc.totalReferrals.toString(), + color: const Color(0xFF9C27B0), + ), + ], + ), + const SizedBox(height: 16), + + BehaviorSection(gc: gc), + const SizedBox(height: 24), + + AchievementsSection(gc: gc), + ], + ); + }, + ); + + Widget _errorState(EarningsController ctl) => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off_rounded, + size: 48, color: FinanceDesignSystem.textMuted), + const SizedBox(height: 12), + Text( + ctl.errorMessage ?? 'تعذّر تحميل أرباحك', + textAlign: TextAlign.center, + style: TextStyle( + color: FinanceDesignSystem.primaryDark, fontSize: 15), + ), + const SizedBox(height: 6), + Text( + 'أرباحك محفوظة ولم يضع منها شيء — المشكلة في التحميل فقط.', + textAlign: TextAlign.center, + style: TextStyle( + color: FinanceDesignSystem.textSecondary, fontSize: 12), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: () => ctl.load(force: true), + icon: const Icon(Icons.refresh_rounded), + label: const Text('إعادة المحاولة'), + ), + ], + ), + ), + ); + + /// ‏بيانات قديمة معروضة بعد فشل التحديث. إظهار ذلك صراحةً أصدق من + /// ‏تقديم رقمٍ عمره ساعات على أنه لحظته. + Widget _staleBanner(EarningsController ctl) => Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(FinanceDesignSystem.mainRadius), + ), + child: Row( + children: [ + const Icon(Icons.history_rounded, color: Colors.orange, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + '${ctl.errorMessage} — هذه أرقام ${ctl.lastUpdatedLabel}', + style: const TextStyle(color: Colors.orange, fontSize: 12), + ), + ), + TextButton( + onPressed: () => ctl.load(force: true), + child: const Text('تحديث', style: TextStyle(fontSize: 12)), + ), + ], + ), + ); + + /// ‏بطاقة اليوم — الرقم الذي فُتحت الشاشة من أجله. + Widget _todayCard(EarningsSummary s, EarningsController ctl) { + final t = s.today; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: FinanceDesignSystem.balanceGradient, + borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius), + boxShadow: [FinanceDesignSystem.softShadow], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('أرباح اليوم', + style: TextStyle(color: Colors.white70, fontSize: 13)), + const Spacer(), + Text(ctl.lastUpdatedLabel, + style: const TextStyle(color: Colors.white54, fontSize: 11)), + ], + ), + const SizedBox(height: 6), + Text('${t.net.toStringAsFixed(2)} ${CurrencyHelper.currency}', + style: const TextStyle( + color: Colors.white, + fontSize: 34, + fontWeight: FontWeight.bold)), + const SizedBox(height: 14), + Row( + children: [ + _miniStat(Icons.local_taxi_rounded, '${t.trips}', 'رحلة'), + _miniStat(Icons.schedule_rounded, + t.hours.toStringAsFixed(1), 'ساعة'), + _miniStat(Icons.route_rounded, + t.distance.toStringAsFixed(0), 'كم'), + ], + ), + // ‏تركيبة المبلغ ظاهرة: إجمالي ← عمولة ← صافي. السائق الذي يرى + // ‏التركيبة يفهم، والذي يرى الصافي وحده يخمّن ويشكّ. + if (t.gross > 0) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _breakdownBit('دفع الركاب', t.gross), + _breakdownBit('عمولة المنصة', t.commission, dim: true), + _breakdownBit('لك', t.net, bold: true), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _miniStat(IconData icon, String value, String label) => Expanded( + child: Row( + children: [ + Icon(icon, color: Colors.white70, size: 16), + const SizedBox(width: 6), + Text(value, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700)), + const SizedBox(width: 3), + Text(label, + style: const TextStyle(color: Colors.white54, fontSize: 11)), + ], + ), + ); + + Widget _breakdownBit(String label, double value, + {bool dim = false, bool bold = false}) => + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), fontSize: 10)), + const SizedBox(height: 2), + Text(value.toStringAsFixed(2), + style: TextStyle( + color: dim + ? Colors.white.withValues(alpha: 0.7) + : Colors.white, + fontSize: bold ? 15 : 13, + fontWeight: bold ? FontWeight.bold : FontWeight.w500)), + ], + ); + + /// ‏الاقتطاعات بجانب الأرباح التي اقتُطعت منها. عرضها في شاشة المنتج + /// ‏وحدها كان سيجعل الخصم مفاجأةً — وهي أخطر ما يصيب منتجاً ائتمانياً. + Widget _deductionsCard(Deductions d) => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: FinanceDesignSystem.cardColor, + borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius), + border: Border.all(color: FinanceDesignSystem.borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.account_balance_wallet_rounded, + size: 18, color: FinanceDesignSystem.accentBlue), + const SizedBox(width: 8), + Text('الاقتطاعات', style: FinanceDesignSystem.headingStyle), + const Spacer(), + if (d.today > 0) + Text('اليوم ${d.today.toStringAsFixed(2)}', + style: TextStyle( + color: FinanceDesignSystem.textSecondary, + fontSize: 12)), + ], + ), + const SizedBox(height: 10), + ...d.items.map((i) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Expanded( + child: Text(i.name, + style: TextStyle( + color: FinanceDesignSystem.primaryDark, + fontSize: 13))), + Text( + 'متبقٍّ ${i.outstanding.toStringAsFixed(2)} ${CurrencyHelper.currency}', + style: TextStyle( + color: FinanceDesignSystem.textSecondary, + fontSize: 12)), + ], + ), + )), + if (d.outstanding > 0) ...[ + const SizedBox(height: 6), + Text( + 'يُقتطع تدريجياً من أرباحك بسقف يومي، ولا يُخصم منك في يوم بلا دخل.', + style: TextStyle( + color: FinanceDesignSystem.textSecondary, fontSize: 11), + ), + ], + ], + ), + ); + + Widget _weekCard(EarningsSummary s, EarningsController ctl) { + final maxNet = s.daily.fold( + 0, (m, d) => d.net > m ? d.net : m); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: FinanceDesignSystem.cardColor, + borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius), + border: Border.all(color: FinanceDesignSystem.borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('آخر ٧ أيام', style: FinanceDesignSystem.headingStyle), + const Spacer(), + if (s.trendPercent != null) _trendChip(s.trendPercent!), + ], + ), + const SizedBox(height: 4), + Text( + '${s.week.net.toStringAsFixed(2)} ${CurrencyHelper.currency} · ${s.week.trips} رحلة', + style: TextStyle( + color: FinanceDesignSystem.textSecondary, fontSize: 13)), + const SizedBox(height: 16), + SizedBox( + height: 110, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: s.daily.map((d) { + final selected = ctl.selectedDay == d.day; + final ratio = maxNet > 0 ? d.net / maxNet : 0.0; + + return Expanded( + child: GestureDetector( + // ‏نقر العمود يعرض رحلات ذلك اليوم. الرسم البياني الذي + // ‏لا يُنقر عليه زينة؛ والسائق يسأل عن يومٍ بعينه. + onTap: () => ctl.loadTrips(day: selected ? null : d.day), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text(d.net > 0 ? d.net.toStringAsFixed(0) : '', + style: TextStyle( + color: FinanceDesignSystem.textSecondary, + fontSize: 9)), + const SizedBox(height: 4), + Container( + height: 60 * (ratio == 0 ? 0.03 : ratio), + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: selected + ? FinanceDesignSystem.accentBlue + : FinanceDesignSystem.accentBlue + .withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 6), + Text(_dayLabel(d.day), + style: TextStyle( + color: FinanceDesignSystem.textSecondary, + fontSize: 10)), + ], + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ); + } + + /// ‏الشهر — يحلّ محل الرسم الشهري في الشاشة القديمة. + /// + /// ‏أرقام لا رسم بياني: الرسم الشهري كان ثلاثين عموداً ضيّقاً لا يُقرأ + /// ‏منها شيء على شاشة هاتف. السائق يسأل عن مجموع شهره ومعدّله اليومي، + /// ‏لا عن شكل توزيعه. + Widget _monthCard(EarningsSummary s) { + final m = s.month; + final avgPerDay = DateTime.now().day > 0 ? m.net / DateTime.now().day : 0.0; + final perHour = m.hours > 0 ? m.net / m.hours : 0.0; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: FinanceDesignSystem.cardColor, + borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius), + border: Border.all(color: FinanceDesignSystem.borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('هذا الشهر', style: FinanceDesignSystem.headingStyle), + const SizedBox(height: 12), + Row( + children: [ + _monthStat('الصافي', m.net.toStringAsFixed(0)), + _monthStat('رحلة', '${m.trips}'), + _monthStat('ساعة', m.hours.toStringAsFixed(0)), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + // ‏معدّل الساعة هو الرقم الذي يقرّر به السائق متى يعمل — + // ‏وأنفع من أي مجموع، لأن المجموع يكافئ الساعات لا الكفاءة. + _monthStat('معدّل الساعة', perHour.toStringAsFixed(1)), + _monthStat('معدّل اليوم', avgPerDay.toStringAsFixed(1)), + _monthStat('العمولة', m.commission.toStringAsFixed(0)), + ], + ), + ], + ), + ); + } + + Widget _monthStat(String label, String value) => Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, + style: TextStyle( + color: FinanceDesignSystem.primaryDark, + fontSize: 17, + fontWeight: FontWeight.bold)), + const SizedBox(height: 2), + Text(label, + style: TextStyle( + color: FinanceDesignSystem.textSecondary, fontSize: 11)), + ], + ), + ); + + Widget _trendChip(double trend) { + final up = trend >= 0; + final color = + up ? FinanceDesignSystem.successGreen : FinanceDesignSystem.dangerRed; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(up ? Icons.trending_up_rounded : Icons.trending_down_rounded, + size: 14, color: color), + const SizedBox(width: 4), + // ‏المقارنة بأسبوعه هو لا بالسائقين الآخرين. + Text('${trend.abs().toStringAsFixed(0)}٪ عن الأسبوع الماضي', + style: TextStyle(color: color, fontSize: 11)), + ], + ), + ); + } + + String _dayLabel(String iso) { + final d = DateTime.tryParse(iso); + if (d == null) return ''; + const names = ['اثن', 'ثلا', 'أرب', 'خمي', 'جمع', 'سبت', 'أحد']; + return names[d.weekday - 1]; + } + + Widget _tripsSection(EarningsController ctl) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + ctl.selectedDay == null + ? 'رحلات الأسبوع' + : 'رحلات ${ctl.selectedDay}', + style: FinanceDesignSystem.headingStyle), + const Spacer(), + if (ctl.selectedDay != null) + TextButton( + onPressed: () => ctl.loadTrips(), + child: const Text('كل الأسبوع', + style: TextStyle(fontSize: 12))), + ], + ), + const SizedBox(height: 8), + if (ctl.isLoadingTrips) + const Padding( + padding: EdgeInsets.all(20), + child: Center(child: CircularProgressIndicator()), + ) + else if (ctl.trips.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Center( + child: Text('لا رحلات في هذه الفترة', + style: TextStyle( + color: FinanceDesignSystem.textSecondary, + fontSize: 13)), + ), + ) + else + ...ctl.trips.map(_tripTile), + ], + ); + + /// ‏تفصيل الرحلة الواحدة — الإجابة على السؤال الأول عند كل سائق: + /// ‏«لماذا أخذت هذا المبلغ من هذه الرحلة؟» + Widget _tripTile(TripEarning t) { + final b = t.breakdown; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: FinanceDesignSystem.cardColor, + borderRadius: BorderRadius.circular(FinanceDesignSystem.mainRadius), + border: Border.all(color: FinanceDesignSystem.borderColor), + ), + child: Theme( + data: ThemeData(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 14), + childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + title: Row( + children: [ + Expanded( + child: Text( + '${t.from} ← ${t.to}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: FinanceDesignSystem.primaryDark, + fontSize: 13, + fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + Text('${b.net.toStringAsFixed(2)} ${CurrencyHelper.currency}', + style: TextStyle( + color: FinanceDesignSystem.successGreen, + fontWeight: FontWeight.bold, + fontSize: 14)), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + Text( + '${_timeLabel(t.at)} · ${t.distanceKm.toStringAsFixed(1)} كم · ${t.minutes} د', + style: TextStyle( + color: FinanceDesignSystem.textSecondary, fontSize: 11), + ), + if (b.zeroCommission) ...[ + const SizedBox(width: 6), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: FinanceDesignSystem.successGreen + .withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text('عمولة معفاة', + style: TextStyle( + color: FinanceDesignSystem.successGreen, + fontSize: 9)), + ), + ], + ], + ), + ), + children: [ + _row('ما دفعه الراكب', b.gross), + if (b.bonus > 0) _row('مكافأة', b.bonus, positive: true), + _row('عمولة المنصة', b.commission, negative: true), + Divider(color: FinanceDesignSystem.borderColor, height: 16), + _row('صافي لك', b.net, bold: true), + const SizedBox(height: 6), + Row( + children: [ + Icon( + t.paymentMethod.toLowerCase().contains('cash') + ? Icons.money_rounded + : Icons.credit_card_rounded, + size: 13, + color: FinanceDesignSystem.textMuted), + const SizedBox(width: 5), + Text(t.paymentMethod, + style: TextStyle( + color: FinanceDesignSystem.textMuted, fontSize: 11)), + const Spacer(), + Text('#${t.rideId}', + style: TextStyle( + color: FinanceDesignSystem.textMuted, fontSize: 11)), + ], + ), + ], + ), + ), + ); + } + + Widget _row(String label, double value, + {bool bold = false, bool negative = false, bool positive = false}) => + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + Expanded( + child: Text(label, + style: TextStyle( + color: FinanceDesignSystem.textSecondary, + fontSize: bold ? 13 : 12)), + ), + Text( + '${negative ? '− ' : positive ? '+ ' : ''}${value.toStringAsFixed(2)}', + style: TextStyle( + color: negative + ? FinanceDesignSystem.dangerRed + : positive + ? FinanceDesignSystem.successGreen + : FinanceDesignSystem.primaryDark, + fontSize: bold ? 14 : 12, + fontWeight: bold ? FontWeight.bold : FontWeight.w500, + ), + ), + ], + ), + ); + + String _timeLabel(String at) { + final d = DateTime.tryParse(at); + if (d == null) return ''; + final h = d.hour.toString().padLeft(2, '0'); + final m = d.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } +} diff --git a/siro_driver/lib/views/home/statistics/statistics_dashboard.dart b/siro_driver/lib/views/home/statistics/statistics_dashboard.dart deleted file mode 100644 index f8c6048e..00000000 --- a/siro_driver/lib/views/home/statistics/statistics_dashboard.dart +++ /dev/null @@ -1,522 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:siro_driver/constant/finance_design_system.dart'; -import 'package:siro_driver/controller/gamification/gamification_controller.dart'; -import 'package:siro_driver/controller/home/statistics/statistics_controller.dart'; - -import 'widgets/stat_summary_card.dart'; -import 'widgets/daily_goal_widget.dart'; -import 'widgets/level_progress_widget.dart'; -import 'widgets/today_chart_widget.dart'; -import 'widgets/weekly_chart_widget.dart'; -import 'widgets/monthly_chart_widget.dart'; - -class StatisticsDashboard extends StatelessWidget { - StatisticsDashboard({super.key}); - - final GamificationController gamController = - Get.put(GamificationController()); - final StatisticsController statsController = Get.put(StatisticsController()); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: FinanceDesignSystem.backgroundColor, - body: GetBuilder( - builder: (gc) { - return GetBuilder( - builder: (sc) { - if (gc.isLoading || sc.isLoading) { - return Center( - child: CircularProgressIndicator( - color: FinanceDesignSystem.primaryDark), - ); - } - - return CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - // ═══════ App Bar ═══════ - SliverAppBar( - expandedHeight: 200, - pinned: true, - stretch: true, - backgroundColor: Get.isDarkMode - ? FinanceDesignSystem.primaryDark - : const Color(0xFF0A0E21), - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded, - color: Colors.white, size: 20), - onPressed: () => Get.back(), - ), - actions: [ - IconButton( - icon: const Icon(Icons.refresh_rounded, - color: Colors.white), - onPressed: () { - gc.fetchGamificationData(force: true); - sc.reloadData(force: true); - }, - ), - ], - flexibleSpace: FlexibleSpaceBar( - centerTitle: true, - title: Text( - 'Statistics'.tr, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - background: Stack( - fit: StackFit.expand, - children: [ - Container( - decoration: BoxDecoration( - gradient: FinanceDesignSystem.balanceGradient, - ), - ), - // أيقونة زخرفية - Positioned( - right: -40, - top: -20, - child: Icon( - Icons.bar_chart_rounded, - size: 200, - color: Colors.white.withOpacity(0.04), - ), - ), - Positioned( - left: -30, - bottom: 20, - child: Icon( - Icons.emoji_events_rounded, - size: 120, - color: Colors.white.withOpacity(0.03), - ), - ), - // بطاقة المستوى في الهيدر - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 40), - Text( - gc.currentLevel.emoji, - style: const TextStyle(fontSize: 40), - ), - const SizedBox(height: 4), - Text( - Get.locale?.languageCode == 'ar' - ? gc.currentLevel.nameAr - : gc.currentLevel.nameEn, - style: TextStyle( - color: Colors.white.withOpacity(0.9), - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - '${gc.unlockedCount}/${gc.totalAchievements} ${'Achievements'.tr}', - style: TextStyle( - color: Colors.white.withOpacity(0.5), - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - ), - ), - - // ═══════ المحتوى ═══════ - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 40), - sliver: SliverList( - delegate: SliverChildListDelegate([ - // 1. بطاقات الإحصائيات الأربعة - _buildSummaryCards(gc), - const SizedBox( - height: FinanceDesignSystem.verticalSectionPadding), - - // 2. الهدف اليومي - DailyGoalWidget(controller: gc), - const SizedBox( - height: FinanceDesignSystem.verticalSectionPadding), - - // 3. تقدم المستوى - LevelProgressWidget(controller: gc), - const SizedBox( - height: FinanceDesignSystem.verticalSectionPadding), - - // 4. إحصائيات اليوم - TodayChartWidget(), - const SizedBox( - height: FinanceDesignSystem.verticalSectionPadding), - - // 5. الرسم البياني الأسبوعي - const WeeklyChartWidget(), - const SizedBox( - height: FinanceDesignSystem.verticalSectionPadding), - - // 6. التقرير الشهري - const MonthlyChartWidget(), - const SizedBox( - height: FinanceDesignSystem.verticalSectionPadding), - - // 7. تقييم سلوك القيادة - _buildBehaviorSection(gc), - const SizedBox( - height: FinanceDesignSystem.verticalSectionPadding), - - // 8. الإنجازات - _buildAchievementsSection(gc), - ]), - ), - ), - ], - ); - }, - ); - }, - ), - ); - } - - // ═══════ بطاقات الإحصائيات ═══════ - Widget _buildSummaryCards(GamificationController gc) { - return GridView.count( - crossAxisCount: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1.5, - children: [ - StatSummaryCard( - icon: Icons.local_taxi_rounded, - label: 'Total Trips'.tr, - value: gc.totalTrips.toString(), - color: FinanceDesignSystem.accentBlue, - ), - StatSummaryCard( - icon: Icons.star_rounded, - label: 'Rating'.tr, - value: gc.averageRating.toStringAsFixed(1), - color: const Color(0xFFFFD700), - ), - StatSummaryCard( - icon: Icons.whatshot_rounded, - label: 'Day Streak'.tr, - value: '${gc.consecutiveDays}', - color: const Color(0xFFFF5722), - ), - StatSummaryCard( - icon: Icons.people_rounded, - label: 'Referrals'.tr, - value: gc.totalReferrals.toString(), - color: const Color(0xFF9C27B0), - ), - ], - ); - } - - // ═══════ قسم تقييم السلوك ═══════ - Widget _buildBehaviorSection(GamificationController gc) { - bool isExcellent = gc.behaviorScore >= 90; - bool isGood = gc.behaviorScore >= 75 && gc.behaviorScore < 90; - - Color statusColor = isExcellent - ? Colors.green - : isGood - ? Colors.orange - : Colors.red; - - String statusText = isExcellent - ? 'Excellent'.tr - : isGood - ? 'Good'.tr - : 'Needs Improvement'.tr; - - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: FinanceDesignSystem.cardColor, - borderRadius: BorderRadius.circular(24), - boxShadow: const [ - BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4)) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.shield_rounded, - color: FinanceDesignSystem.accentBlue), - const SizedBox(width: 8), - Text('Driving Behavior'.tr, - style: FinanceDesignSystem.headingStyle), - ], - ), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - statusText, - style: TextStyle( - color: statusColor, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - ], - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _behaviorStatItem( - title: 'Score'.tr, - value: '${gc.behaviorScore}%', - icon: Icons.speed_rounded, - color: FinanceDesignSystem.accentBlue, - ), - _behaviorStatItem( - title: 'Max Speed'.tr, - value: '${gc.maxSpeed.toStringAsFixed(0)} km/h', - icon: Icons.directions_car_rounded, - color: Colors.orange, - ), - _behaviorStatItem( - title: 'Hard Brakes'.tr, - value: '${gc.hardBrakes}', - icon: Icons.warning_rounded, - color: Colors.red, - ), - ], - ), - const SizedBox(height: 16), - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: LinearProgressIndicator( - value: gc.behaviorScore / 100, - backgroundColor: FinanceDesignSystem.backgroundColor, - valueColor: AlwaysStoppedAnimation(statusColor), - minHeight: 8, - ), - ), - ], - ), - ); - } - - Widget _behaviorStatItem( - {required String title, - required String value, - required IconData icon, - required Color color}) { - return Column( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: Icon(icon, color: color, size: 24), - ), - const SizedBox(height: 8), - Text(value, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: FinanceDesignSystem.primaryDark)), - Text(title, - style: TextStyle( - fontSize: 12, color: FinanceDesignSystem.textSecondary)), - ], - ); - } - - // ═══════ قسم الإنجازات ═══════ - Widget _buildAchievementsSection(GamificationController gc) { - final unlockedAch = gc.achievements.where((a) => a.isUnlocked).toList(); - final lockedAch = gc.achievements.where((a) => !a.isUnlocked).toList(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Achievements'.tr, style: FinanceDesignSystem.headingStyle), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: FinanceDesignSystem.accentBlue.withOpacity(0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - '${gc.unlockedCount}/${gc.totalAchievements}', - style: TextStyle( - color: FinanceDesignSystem.accentBlue, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - ], - ), - const SizedBox(height: 16), - - // الإنجازات المفتوحة - if (unlockedAch.isNotEmpty) ...[ - ...unlockedAch.map((a) => _buildAchievementCard(a, true)), - const SizedBox(height: 16), - ], - - // الإنجازات المقفلة - ...lockedAch.map((a) => _buildAchievementCard(a, false)), - ], - ); - } - - Widget _buildAchievementCard(Achievement ach, bool unlocked) { - final isAr = Get.locale?.languageCode == 'ar'; - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: unlocked ? Colors.white : Colors.grey.shade50, - borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius), - border: unlocked - ? Border.all(color: ach.color.withOpacity(0.3), width: 1.5) - : null, - boxShadow: unlocked - ? [ - BoxShadow( - color: ach.color.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ] - : null, - ), - child: Row( - children: [ - // أيقونة الإنجاز - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - color: - unlocked ? ach.color.withOpacity(0.15) : Colors.grey.shade200, - borderRadius: BorderRadius.circular(14), - ), - child: Icon( - ach.icon, - color: unlocked ? ach.color : Colors.grey.shade400, - size: 26, - ), - ), - const SizedBox(width: 14), - - // المعلومات - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isAr ? ach.titleAr : ach.titleEn, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: unlocked - ? FinanceDesignSystem.primaryDark - : Colors.grey.shade500, - ), - ), - const SizedBox(height: 2), - Text( - isAr ? ach.descriptionAr : ach.descriptionEn, - style: TextStyle( - fontSize: 11, - color: Colors.grey.shade500, - ), - ), - if (!unlocked) ...[ - const SizedBox(height: 8), - // شريط التقدم - Stack( - children: [ - Container( - height: 6, - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(3), - ), - ), - FractionallySizedBox( - widthFactor: ach.progress, - child: Container( - height: 6, - decoration: BoxDecoration( - color: ach.color.withOpacity(0.6), - borderRadius: BorderRadius.circular(3), - ), - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - '${ach.currentProgress}/${ach.target}', - style: TextStyle( - fontSize: 10, - color: Colors.grey.shade400, - fontWeight: FontWeight.w600, - ), - ), - ], - ], - ), - ), - - // أيقونة الحالة - if (unlocked) - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: ach.color.withOpacity(0.15), - shape: BoxShape.circle, - ), - child: Icon( - Icons.check_rounded, - color: ach.color, - size: 18, - ), - ) - else - Icon( - Icons.lock_outline_rounded, - color: Colors.grey.shade300, - size: 20, - ), - ], - ), - ); - } -} diff --git a/siro_driver/lib/views/home/statistics/widgets/behavior_and_achievements.dart b/siro_driver/lib/views/home/statistics/widgets/behavior_and_achievements.dart new file mode 100644 index 00000000..e8babfca --- /dev/null +++ b/siro_driver/lib/views/home/statistics/widgets/behavior_and_achievements.dart @@ -0,0 +1,311 @@ +// behavior_and_achievements.dart — سلوك القيادة والإنجازات +// +// ‏نُقلا من `statistics_dashboard.dart` (حيث كانا دالتين خاصّتين) إلى +// ‏ودجتين مستقلّتين عند دمج شاشتَي الأرباح والإحصائيات في شاشة واحدة. +// ‏نسخهما كان سيعني نسختين تفترقان عند أول تعديل. +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../../constant/finance_design_system.dart'; +import '../../../../controller/gamification/gamification_controller.dart'; + +class BehaviorSection extends StatelessWidget { + final GamificationController gc; + const BehaviorSection({super.key, required this.gc}); + + @override + Widget build(BuildContext context) { + final bool isExcellent = gc.behaviorScore >= 90; + final bool isGood = gc.behaviorScore >= 75 && gc.behaviorScore < 90; + + final Color statusColor = isExcellent + ? Colors.green + : isGood + ? Colors.orange + : Colors.red; + + final String statusText = isExcellent + ? 'Excellent'.tr + : isGood + ? 'Good'.tr + : 'Needs Improvement'.tr; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: FinanceDesignSystem.cardColor, + borderRadius: BorderRadius.circular(24), + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4)) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(Icons.shield_rounded, + color: FinanceDesignSystem.accentBlue), + const SizedBox(width: 8), + Text('Driving Behavior'.tr, + style: FinanceDesignSystem.headingStyle), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + statusText, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _statItem( + title: 'Score'.tr, + value: '${gc.behaviorScore}%', + icon: Icons.speed_rounded, + color: FinanceDesignSystem.accentBlue, + ), + _statItem( + title: 'Max Speed'.tr, + value: '${gc.maxSpeed.toStringAsFixed(0)} km/h', + icon: Icons.directions_car_rounded, + color: Colors.orange, + ), + _statItem( + title: 'Hard Brakes'.tr, + value: '${gc.hardBrakes}', + icon: Icons.warning_rounded, + color: Colors.red, + ), + ], + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: gc.behaviorScore / 100, + backgroundColor: FinanceDesignSystem.backgroundColor, + valueColor: AlwaysStoppedAnimation(statusColor), + minHeight: 8, + ), + ), + ], + ), + ); + } + + Widget _statItem({ + required String title, + required String value, + required IconData icon, + required Color color, + }) => + Column( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 24), + ), + const SizedBox(height: 8), + Text(value, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: FinanceDesignSystem.primaryDark)), + Text(title, + style: TextStyle( + fontSize: 12, color: FinanceDesignSystem.textSecondary)), + ], + ); +} + +class AchievementsSection extends StatelessWidget { + final GamificationController gc; + const AchievementsSection({super.key, required this.gc}); + + @override + Widget build(BuildContext context) { + final unlocked = gc.achievements.where((a) => a.isUnlocked).toList(); + final locked = gc.achievements.where((a) => !a.isUnlocked).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Achievements'.tr, style: FinanceDesignSystem.headingStyle), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: FinanceDesignSystem.accentBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${gc.unlockedCount}/${gc.totalAchievements}', + style: TextStyle( + color: FinanceDesignSystem.accentBlue, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + if (unlocked.isNotEmpty) ...[ + ...unlocked.map((a) => _AchievementCard(ach: a, unlocked: true)), + const SizedBox(height: 16), + ], + ...locked.map((a) => _AchievementCard(ach: a, unlocked: false)), + ], + ); + } +} + +class _AchievementCard extends StatelessWidget { + final Achievement ach; + final bool unlocked; + + const _AchievementCard({required this.ach, required this.unlocked}); + + @override + Widget build(BuildContext context) { + final isAr = Get.locale?.languageCode == 'ar'; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + // ‏كان اللون هنا Colors.white ثابتاً — يختفي النص عليه في الوضع + // ‏الداكن. نستعمل لون البطاقة من نظام التصميم. + color: unlocked + ? FinanceDesignSystem.cardColor + : FinanceDesignSystem.cardColor.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius), + border: unlocked + ? Border.all(color: ach.color.withValues(alpha: 0.3), width: 1.5) + : Border.all(color: FinanceDesignSystem.borderColor), + boxShadow: unlocked + ? [ + BoxShadow( + color: ach.color.withValues(alpha: 0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: unlocked + ? ach.color.withValues(alpha: 0.15) + : FinanceDesignSystem.textMuted.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + ach.icon, + color: unlocked ? ach.color : FinanceDesignSystem.textMuted, + size: 26, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isAr ? ach.titleAr : ach.titleEn, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: unlocked + ? FinanceDesignSystem.primaryDark + : FinanceDesignSystem.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + isAr ? ach.descriptionAr : ach.descriptionEn, + style: TextStyle( + fontSize: 11, + color: FinanceDesignSystem.textSecondary), + ), + if (!unlocked) ...[ + const SizedBox(height: 8), + Stack( + children: [ + Container( + height: 6, + decoration: BoxDecoration( + color: FinanceDesignSystem.textMuted + .withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(3), + ), + ), + FractionallySizedBox( + widthFactor: ach.progress, + child: Container( + height: 6, + decoration: BoxDecoration( + color: ach.color.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${ach.currentProgress}/${ach.target}', + style: TextStyle( + fontSize: 10, + color: FinanceDesignSystem.textMuted, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + if (unlocked) + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: ach.color.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon(Icons.check_rounded, color: ach.color, size: 18), + ) + else + Icon(Icons.lock_outline_rounded, + color: FinanceDesignSystem.textMuted, size: 20), + ], + ), + ); + } +} diff --git a/siro_driver/lib/views/home/statistics/widgets/monthly_chart_widget.dart b/siro_driver/lib/views/home/statistics/widgets/monthly_chart_widget.dart deleted file mode 100644 index a55f92e4..00000000 --- a/siro_driver/lib/views/home/statistics/widgets/monthly_chart_widget.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:siro_driver/constant/currency.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:fl_chart/fl_chart.dart'; -import '../../../../constant/finance_design_system.dart'; -import '../../../../controller/home/statistics/statistics_controller.dart'; - -class MonthlyChartWidget extends StatelessWidget { - const MonthlyChartWidget({super.key}); - - @override - Widget build(BuildContext context) { - return GetBuilder( - builder: (sc) { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: FinanceDesignSystem.cardColor, - borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.03), - blurRadius: 10, - offset: const Offset(0, 4)) - ], - ), - child: - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Monthly Report'.tr, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: FinanceDesignSystem.primaryDark)), - const SizedBox(height: 16), - // Summary Row - Row(children: [ - _summaryTile( - 'Total Earnings'.tr, - '${sc.monthlyTotalEarnings.toStringAsFixed(0)} ${CurrencyHelper.currency}', - FinanceDesignSystem.successGreen), - const SizedBox(width: 12), - _summaryTile('Total Trips'.tr, '${sc.monthlyTotalTrips}', - FinanceDesignSystem.accentBlue), - const SizedBox(width: 12), - _summaryTile( - 'Best Day'.tr, '${sc.bestDay}', const Color(0xFFFFD700)), - ]), - const SizedBox(height: 20), - // Monthly Earnings Line Chart - SizedBox( - height: 200, - child: sc.monthlyEarnings.isEmpty - ? Center( - child: Text('No data yet'.tr, - style: TextStyle(color: Colors.grey.shade400))) - : LineChart(LineChartData( - lineTouchData: LineTouchData( - enabled: true, - touchTooltipData: LineTouchTooltipData( - getTooltipItems: (spots) => spots - .map((s) => LineTooltipItem( - '${s.y.toStringAsFixed(0)} ${CurrencyHelper.currency}', - const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12), - )) - .toList(), - ), - ), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (v) => FlLine( - color: FinanceDesignSystem.borderColor, - strokeWidth: 1), - ), - titlesData: FlTitlesData( - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - interval: 5, - getTitlesWidget: (v, m) => Padding( - padding: const EdgeInsets.only(top: 8), - child: Text('${v.toInt()}', - style: TextStyle( - fontSize: 10, - color: FinanceDesignSystem.textSecondary)), - ), - )), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - ), - borderData: FlBorderData(show: false), - lineBarsData: [ - LineChartBarData( - spots: sc.monthlyEarnings - .map((e) => - FlSpot(e.day.toDouble(), e.pricePerDay)) - .toList(), - isCurved: true, - curveSmoothness: 0.3, - color: FinanceDesignSystem.accentBlue, - barWidth: 3, - dotData: FlDotData( - show: true, - getDotPainter: (s, p, d, i) => FlDotCirclePainter( - radius: 3, - color: FinanceDesignSystem.accentBlue, - strokeWidth: 1, - strokeColor: Colors.white), - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - FinanceDesignSystem.accentBlue.withOpacity(0.2), - FinanceDesignSystem.accentBlue.withOpacity(0.0) - ], - ), - ), - ), - ], - )), - ), - ]), - ); - }, - ); - } - - Widget _summaryTile(String label, String value, Color color) { - return Expanded( - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: color.withOpacity(0.08), - borderRadius: BorderRadius.circular(12)), - child: Column(children: [ - Text(value, - style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w800, color: color)), - const SizedBox(height: 2), - Text(label, - style: TextStyle( - fontSize: 9, color: FinanceDesignSystem.textSecondary), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis), - ]), - ), - ); - } -} diff --git a/siro_driver/lib/views/home/statistics/widgets/today_chart_widget.dart b/siro_driver/lib/views/home/statistics/widgets/today_chart_widget.dart deleted file mode 100644 index 1bae2b17..00000000 --- a/siro_driver/lib/views/home/statistics/widgets/today_chart_widget.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:siro_driver/constant/currency.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:siro_driver/controller/home/captin/home_captain_controller.dart'; -import '../../../../constant/finance_design_system.dart'; - -class TodayChartWidget extends StatelessWidget { - TodayChartWidget({super.key}); - - @override - Widget build(BuildContext context) { - return GetBuilder( - builder: (hc) { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: FinanceDesignSystem.cardColor, - borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.03), - blurRadius: 10, - offset: const Offset(0, 4)), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Today Overview'.tr, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: FinanceDesignSystem.primaryDark)), - const SizedBox(height: 4), - Text('Summary of your daily activity'.tr, - style: TextStyle( - fontSize: 11, color: FinanceDesignSystem.textSecondary)), - const SizedBox(height: 20), - _buildRow( - Icons.monetization_on_rounded, - 'Earnings'.tr, - '${hc.totalMoneyToday} ${CurrencyHelper.currency}', - FinanceDesignSystem.successGreen), - const Divider(height: 24), - _buildRow(Icons.local_taxi_rounded, 'Rides'.tr, hc.countRideToday, - FinanceDesignSystem.accentBlue), - const Divider(height: 24), - Obx(() => _buildRow(Icons.timer_rounded, 'Online Duration'.tr, - hc.totalDurationDisplay.value, const Color(0xFFFF9800))), - const Divider(height: 24), - _buildRow(Icons.cancel_outlined, 'Refused'.tr, hc.countRefuse, - FinanceDesignSystem.dangerRed), - ], - ), - ); - }, - ); - } - - Widget _buildRow(IconData icon, String label, String value, Color color) { - return Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(10)), - child: Icon(icon, color: color, size: 20), - ), - const SizedBox(width: 14), - Expanded( - child: Text(label, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: FinanceDesignSystem.primaryDark))), - Text(value, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: FinanceDesignSystem.primaryDark, - fontFamily: 'digit')), - ], - ); - } -} diff --git a/siro_driver/lib/views/home/statistics/widgets/weekly_chart_widget.dart b/siro_driver/lib/views/home/statistics/widgets/weekly_chart_widget.dart deleted file mode 100644 index d8f30a2d..00000000 --- a/siro_driver/lib/views/home/statistics/widgets/weekly_chart_widget.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:siro_driver/constant/currency.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:fl_chart/fl_chart.dart'; -import '../../../../constant/finance_design_system.dart'; -import '../../../../controller/home/statistics/statistics_controller.dart'; - -class WeeklyChartWidget extends StatelessWidget { - const WeeklyChartWidget({super.key}); - - @override - Widget build(BuildContext context) { - return GetBuilder( - builder: (sc) { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: FinanceDesignSystem.cardColor, - borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.03), - blurRadius: 10, - offset: const Offset(0, 4)) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Weekly Earnings'.tr, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: FinanceDesignSystem.primaryDark)), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: FinanceDesignSystem.successGreen.withOpacity(0.1), - borderRadius: BorderRadius.circular(12)), - child: Text( - '${sc.weeklyEarnings.toStringAsFixed(0)} ${CurrencyHelper.currency}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: FinanceDesignSystem.successGreen)), - ), - ]), - const SizedBox(height: 8), - Row(children: [ - _miniStat(Icons.local_taxi_rounded, '${sc.weeklyTrips}', - 'Rides'.tr, FinanceDesignSystem.accentBlue), - const SizedBox(width: 16), - _miniStat( - Icons.timer_rounded, - '${sc.weeklyHours.toStringAsFixed(1)}h', - 'Hours'.tr, - const Color(0xFFFF9800)), - ]), - const SizedBox(height: 20), - SizedBox( - height: 180, - child: sc.weeklyStats.isEmpty - ? Center( - child: Text('No data yet'.tr, - style: TextStyle(color: Colors.grey.shade400))) - : BarChart( - BarChartData( - alignment: BarChartAlignment.spaceAround, - maxY: _getMaxY(sc.weeklyStats), - barTouchData: BarTouchData( - enabled: true, - touchTooltipData: BarTouchTooltipData( - getTooltipItem: (group, gi, rod, ri) => - BarTooltipItem( - '${rod.toY.toStringAsFixed(0)} ${CurrencyHelper.currency}', - const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12), - ), - ), - ), - titlesData: FlTitlesData( - show: true, - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (v, m) { - final idx = v.toInt(); - if (idx >= 0 && - idx < sc.weeklyStats.length) { - return Padding( - padding: - const EdgeInsets.only(top: 8), - child: Text( - sc.weeklyStats[idx].dayName.tr, - style: TextStyle( - fontSize: 10, - color: FinanceDesignSystem - .textSecondary))); - } - return const SizedBox.shrink(); - })), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - ), - borderData: FlBorderData(show: false), - gridData: const FlGridData(show: false), - barGroups: List.generate(sc.weeklyStats.length, (i) { - final stat = sc.weeklyStats[i]; - final isToday = i == sc.weeklyStats.length - 1; - return BarChartGroupData(x: i, barRods: [ - BarChartRodData( - toY: stat.earnings, - width: 20, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(6)), - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: isToday - ? [ - FinanceDesignSystem.accentBlue, - const Color(0xFF82B1FF) - ] - : [ - FinanceDesignSystem.primaryDark - .withOpacity(0.6), - FinanceDesignSystem.primaryDark - .withOpacity(0.3) - ], - ), - ), - ]); - }), - ), - ), - ), - ], - ), - ); - }, - ); - } - - Widget _miniStat(IconData icon, String value, String label, Color color) { - return Row(children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 4), - Text(value, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: FinanceDesignSystem.primaryDark)), - const SizedBox(width: 4), - Text(label, - style: TextStyle( - fontSize: 11, color: FinanceDesignSystem.textSecondary)), - ]); - } - - double _getMaxY(List stats) { - if (stats.isEmpty) return 100; - final max = stats.map((s) => s.earnings).reduce((a, b) => a > b ? a : b); - return max <= 0 ? 100 : max * 1.2; - } -}