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

This commit is contained in:
Hamza-Ayed
2026-08-08 12:58:17 +03:00
parent 611b5e616d
commit fae0e4a38a
55 changed files with 5771 additions and 1820 deletions
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* fuel/cancel_voucher.php — إلغاء قسيمة لم تُصرف
*
* ‏يحرّر المبلغ المحجوز فوراً. بدونه ينتظر السائق انتهاء الصلاحية ليعود
* ‏سقفه — وهو ينتظر عند مضخة ليتزوّد بمبلغ آخر.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
// ‏الشرط على driver_id إلى جانب الحالة: الرمز وحده يكفي للعثور على
// ‏قسيمة، ولا يجوز أن يُلغي سائقٌ قسيمة غيره برمز وقع بيده.
$st = $con->prepare("
UPDATE fuel_vouchers
SET status = 'cancelled'
WHERE driver_id = ? AND status = 'issued'
");
$st->execute([$driverId]);
if ($st->rowCount() === 0) {
jsonError('لا توجد قسيمة قابلة للإلغاء');
}
$obligation = fuelObligation($con, $driverId);
$state = $obligation ? fuelCreditState($con, $obligation) : null;
jsonSuccess([
'cancelled' => true,
'available' => $state['available'] ?? null,
], 'أُلغيت القسيمة وعاد المبلغ إلى سقفك');
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* fuel/enroll.php — تفعيل محفظة الوقود للسائق
*
* ‏يفتح التزام `fuel_wallet` بسقف ائتمان مبدئي. لا يصرف شيئاً — الصرف
* ‏يبدأ بقسيمة، والدَّين يُقيَّد عند تأكيد المحطة.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/../driver_assurance/eligibility.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
if (fuelObligation($con, $driverId)) {
jsonError('محفظة الوقود مفعّلة لديك بالفعل');
}
$product = obligationProduct($con, FUEL_PRODUCT_CODE);
if (!$product) {
error_log('[fuel/enroll] منتج محفظة الوقود غير موجود أو مُطفأ');
jsonError('محفظة الوقود غير متاحة حالياً');
}
// ‏شروط الرحلات والتقييم تُقاس بالدالة نفسها التي يستعملها التأمين:
// ‏المنطق واحد (سائق أثبت أنه باقٍ)، وازدواج تطبيقه كان سينتج معيارين
// ‏يفترقان بصمت عند أول تعديل.
$check = assuranceCheckEligibility($con, $driverId, $product);
if (!$check['eligible']) {
jsonError('لم تستوفِ شروط محفظة الوقود بعد: ' . implode('، ', $check['reasons']));
}
// ‏بوابة الائتمان أشدّ هنا منها في التأمين: الوقود قيمة فعلية تُسلَّم في
// ‏خزّان السيارة، ولا سبيل لاستردادها إلا من أرباح تمرّ بالمحفظة.
$gate = obligationCheckWalletIncome($product, $driverId);
if (!$gate['eligible']) {
jsonError(
'محفظة الوقود تُسدَّد من أرباحك في المحفظة، ولذلك تشترط أن تمرّ أرباحك بها: '
. implode('، ', $gate['reasons'])
);
}
// ‏السقف المبدئي واحد للجميع في هذه المرحلة. رفعه بحسب سلوك السداد قرار
// ‏لاحق يحتاج بيانات لا نملكها بعد — ومنح سقف متدرّج بلا تاريخ سداد
// ‏تخمينٌ يرتدي ثوب الخوارزمية.
$defaultLimit = (float) (getenv('FUEL_DEFAULT_CREDIT_LIMIT') ?: 30);
try {
$con->beginTransaction();
$obligationId = obligationOpen($con, $driverId, FUEL_PRODUCT_CODE, [
'cycle_amount' => 0, // ‏منتج سحب: لا قسط دوري له
'rides' => $check['rides'],
'rating' => $check['rating'],
]);
$con->prepare("UPDATE driver_obligations SET credit_limit = ? WHERE id = ?")
->execute([$defaultLimit, $obligationId]);
$con->commit();
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
error_log('[fuel/enroll] ' . $e->getMessage());
jsonError('تعذّر تفعيل محفظة الوقود حالياً');
}
error_log("[fuel] فُعّلت محفظة الوقود للسائق $driverId بسقف $defaultLimit");
jsonSuccess([
'obligation_id' => $obligationId,
'credit_limit' => $defaultLimit,
'available' => $defaultLimit,
'currency' => $product['currency'],
], 'تم تفعيل محفظة الوقود');
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* fuel/functions.php — منطق محفظة الوقود المشترك
* ─────────────────────────────────────────────────────────────
* ‏محفظة الوقود منتج `drawdown` في محرك الالتزامات: لا دورة فوترة له،
* ‏والاستحقاق يُقيَّد لحظة الصرف من المحطة لا على جدول زمني.
*
* ‏كل ما يخصّ المال هنا يقتصر على **قيد الدَّين**. الخصم من رصيد السائق
* ‏يبقى حكراً على محرك التسوية، كما في التأمين.
*/
require_once __DIR__ . '/../obligations/functions.php';
const FUEL_PRODUCT_CODE = 'fuel_wallet';
const FUEL_VOUCHER_TTL_MIN = 45;
/**
* ‏التزام الوقود النشط للسائق، أو null.
*/
function fuelObligation(PDO $con, string $driverId): ?array
{
$st = $con->prepare("
SELECT o.*, p.currency, p.daily_cap_percent
FROM driver_obligations o
JOIN obligation_products p ON p.id = o.product_id
WHERE o.driver_id = ? AND p.code = ? AND o.status = 'active'
LIMIT 1
");
$st->execute([$driverId, FUEL_PRODUCT_CODE]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* ‏يُنهي صلاحية القسائم التي مضى وقتها.
*
* ‏يُستدعى قبل أي حساب سقف لا في كرون: القسيمة المنتهية تحجز من سقف
* ‏السائق، وتأخير تحريرها ساعةً كاملةً حتى يمرّ الكرون يعني منعه من
* ‏التزوّد بسبب قسيمة ماتت. التنظيف عند القراءة يجعل الرقم صادقاً دائماً.
*/
function fuelExpireStaleVouchers(PDO $con, string $driverId): void
{
$con->prepare("
UPDATE fuel_vouchers
SET status = 'expired'
WHERE driver_id = ? AND status = 'issued' AND expires_at < NOW()
")->execute([$driverId]);
}
/**
* ‏حالة سقف السائق: المستخدَم، المحجوز، والمتاح.
*
* ‏المحجوز يُطرح كالمستخدَم تماماً: قسيمة أُصدرت ولم تُصرف بعد قد تُصرف
* ‏بعد لحظة، والسماح بإصدار قسائم بمجموع يتجاوز السقف يعني سائقاً
* ‏يتزوّد بضعف ما يستحق ثم لا نجد ما نخصم منه.
*
* @return array{limit: float, outstanding: float, held: float, available: float}
*/
function fuelCreditState(PDO $con, array $obligation): array
{
$obligationId = (int) $obligation['id'];
$st = $con->prepare("
SELECT COALESCE(SUM(amount_remaining), 0)
FROM obligation_ledger
WHERE obligation_id = ? AND status IN ('pending', 'partial')
");
$st->execute([$obligationId]);
$outstanding = (float) $st->fetchColumn();
$st = $con->prepare("
SELECT COALESCE(SUM(amount_authorized), 0)
FROM fuel_vouchers
WHERE obligation_id = ? AND status = 'issued' AND expires_at >= NOW()
");
$st->execute([$obligationId]);
$held = (float) $st->fetchColumn();
$limit = (float) $obligation['credit_limit'];
$available = round($limit - $outstanding - $held, 3);
return [
'limit' => $limit,
'outstanding' => round($outstanding, 3),
'held' => round($held, 3),
'available' => max($available, 0.0),
];
}
/**
* ‏يولّد رمز قسيمة غير متوقَّع.
*
* ‏`random_int` لا `rand`: الرمز مفتاحُ صرفٍ فعلي، ومولّد عشوائية قابل
* ‏للتنبّؤ يعني أن من يعرف رمزاً واحداً قد يخمّن التالي ويتزوّد على
* ‏حساب سائق آخر. عشرة أرقام تُبقي المجال واسعاً والرقم قابلاً للإملاء
* ‏على عامل المحطة هاتفياً.
*/
function fuelGenerateCode(PDO $con): string
{
for ($i = 0; $i < 10; $i++) {
$code = (string) random_int(1000000000, 9999999999);
$st = $con->prepare("SELECT 1 FROM fuel_vouchers WHERE code = ? LIMIT 1");
$st->execute([$code]);
if (!$st->fetchColumn()) {
return $code;
}
}
// ‏عشر محاولات فاشلة على مجال من تسعة مليارات ليست صدفة — هي عطل
// ‏(قاعدة ممتلئة أو مولّد معطوب). الاستمرار هنا يعني رمزاً مكرّراً.
throw new RuntimeException('تعذّر توليد رمز قسيمة فريد');
}
+80
View File
@@ -0,0 +1,80 @@
<?php
/**
* fuel/get.php — حالة محفظة الوقود للسائق
*
* ‏السقف، المستخدَم، المحجوز، والمتاح — ثم آخر الحركات. الأرقام الأربعة
* ‏معاً لا المتاح وحده: السائق الذي يرى «المتاح ٥» بلا تفسير يظنّها
* ‏عطلاً، والذي يرى «سقفك ٣٠، عليك ٢٠، محجوز ٥» يفهم ويتصرّف.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$obligation = fuelObligation($con, $driverId);
if (!$obligation) {
jsonSuccess([
'enrolled' => false,
'credit' => null,
'voucher' => null,
'entries' => [],
], 'محفظة الوقود غير مفعّلة');
}
fuelExpireStaleVouchers($con, $driverId);
$state = fuelCreditState($con, $obligation);
$voucher = null;
$entries = [];
try {
$st = $con->prepare("
SELECT id, code, amount_authorized, currency, expires_at
FROM fuel_vouchers
WHERE driver_id = ? AND status = 'issued' AND expires_at >= NOW()
LIMIT 1
");
$st->execute([$driverId]);
if ($row = $st->fetch(PDO::FETCH_ASSOC)) {
$voucher = [
'voucher_id' => (int) $row['id'],
'code' => $row['code'],
'amount' => (float) $row['amount_authorized'],
'expires_at' => $row['expires_at'],
];
}
// ‏آخر عشرين حركة: كافية لشهر تزوّد، وتمنع رداً ينمو بلا حد.
$st = $con->prepare("
SELECT charge_date, amount, amount_collected, amount_remaining,
currency, status, note
FROM obligation_ledger
WHERE obligation_id = ?
ORDER BY id DESC LIMIT 20
");
$st->execute([(int) $obligation['id']]);
$entries = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[fuel/get] تعذّرت قراءة الحالة: ' . $e->getMessage());
}
jsonSuccess([
'enrolled' => true,
'credit' => [
'limit' => $state['limit'],
'outstanding' => $state['outstanding'],
'held' => $state['held'],
'available' => $state['available'],
'currency' => $obligation['currency'],
// ‏نسبة الاقتطاع اليومي معروضة صراحةً: الخصم من الأرباح يجب ألّا
// ‏يكون مفاجأة، والسائق الذي يعرف أن الاقتطاع ١٥٪ يخطّط عليه.
'daily_cap_percent' => (float) $obligation['daily_cap_percent'],
],
'voucher' => $voucher,
'entries' => $entries,
], 'success');
+152
View File
@@ -0,0 +1,152 @@
<?php
/**
* fuel/redeem.php — تأكيد المحطة صرف القسيمة
* ─────────────────────────────────────────────────────────────
* ‏هذه هي **نقطة قيد الاستحقاق** لمنتج الوقود: اللحظة التي يتحوّل فيها
* ‏وقودٌ في الخزّان إلى دَين في الدفتر.
*
* ‏تُنادى من نظام المحطة لا من تطبيق السائق. السائق طرف مستفيد، وجعل
* ‏تأكيد الصرف بيده يعني أن ينفي ما استلمه.
*
* ‏لا تخصم شيئاً من رصيد أحد — تقيّد استحقاقاً، ويحصّله محرك التسوية
* ‏بالسقف اليومي. نفس الفصل الذي يحكم التأمين.
*/
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/functions.php';
try {
$con = Database::get('main');
} catch (Exception $e) {
error_log('[fuel/redeem] DB unavailable: ' . $e->getMessage());
jsonError('Server error', 500);
}
// ‏مصادقة المحطة بمفتاحها الخاص لا بمفتاح النظام المشترك: المفاتيح
// ‏موزّعة على أطراف خارجية، وتسريب مفتاح محطة يجب أن يكشف تلك المحطة
// ‏وحدها ويُبطَل بصفّ واحد.
$stationKey = $_SERVER['HTTP_X_STATION_KEY'] ?? '';
if (empty($stationKey)) {
jsonError('Unauthorized', 401);
}
$st = $con->prepare("
SELECT id, name_ar, discount_percent
FROM fuel_stations
WHERE api_key_hash = ? AND is_active = 1 LIMIT 1
");
$st->execute([hash('sha256', $stationKey)]);
$station = $st->fetch(PDO::FETCH_ASSOC);
if (!$station) {
jsonError('Unauthorized', 401);
}
$code = filterRequest('code');
$amount = (float) (filterRequest('amount', 'numeric') ?: 0);
if (empty($code) || $amount <= 0) {
jsonError('code و amount مطلوبان');
}
try {
$con->beginTransaction();
// ‏القفل ضروري لا احتياطي: طلبان متزامنان بالرمز نفسه (ضغطة مكرّرة
// ‏على جهاز المحطة، أو محاولة صرف مزدوج) كانا سيمرّان كلاهما فيُقيَّد
// ‏الدَّين مرتين على وقود صُرف مرة.
$st = $con->prepare("
SELECT v.*, o.driver_id AS o_driver
FROM fuel_vouchers v
JOIN driver_obligations o ON o.id = v.obligation_id
WHERE v.code = ? FOR UPDATE
");
$st->execute([$code]);
$voucher = $st->fetch(PDO::FETCH_ASSOC);
if (!$voucher) {
$con->rollBack();
jsonError('رمز غير معروف');
}
if ($voucher['status'] !== 'issued') {
$con->rollBack();
// ‏رسالة صريحة لا عامة: عامل المحطة يحتاج أن يعرف إن كانت صُرفت
// ‏سابقاً أم انتهت صلاحيتها، وإلا أعاد المحاولة أمام سائق ينتظر.
jsonError('القسيمة ' . match ($voucher['status']) {
'redeemed' => 'مصروفة سابقاً',
'expired' => 'منتهية الصلاحية',
'cancelled' => 'ملغاة',
default => 'غير قابلة للصرف',
});
}
if (strtotime($voucher['expires_at']) < time()) {
$con->prepare("UPDATE fuel_vouchers SET status = 'expired' WHERE id = ?")
->execute([(int) $voucher['id']]);
$con->commit();
jsonError('القسيمة منتهية الصلاحية');
}
// ‏الصرف لا يتجاوز المأذون. تجاوزه يعني ديناً لم يوافق عليه السائق
// ‏ولم يُحجز له سقف — أي خرقاً للسقف من باب خلفي.
if ($amount > (float) $voucher['amount_authorized']) {
$con->rollBack();
jsonError(
'المبلغ يتجاوز المأذون به في القسيمة ('
. (float) $voucher['amount_authorized'] . ')'
);
}
$con->prepare("
UPDATE fuel_vouchers
SET status = 'redeemed', station_id = ?, amount_redeemed = ?,
discount_percent = ?, redeemed_at = NOW()
WHERE id = ?
")->execute([
(int) $station['id'], $amount,
(float) $station['discount_percent'], (int) $voucher['id'],
]);
// ‏قيد الاستحقاق. `source_ref` رقم القسيمة — وهو ما يسمح بأكثر من
// ‏سحبة في اليوم الواحد (المفتاح الفريد يشمله)، ويجعل إعادة النداء
// ‏بالرمز نفسه غير قادرة على مضاعفة الدَّين.
$con->prepare("
INSERT INTO obligation_ledger
(obligation_id, driver_id, product_code, charge_date, source_ref,
amount, amount_collected, amount_remaining, currency, status, note)
VALUES (?, ?, ?, CURDATE(), ?, ?, 0, ?, ?, 'pending', ?)
")->execute([
(int) $voucher['obligation_id'], $voucher['driver_id'], FUEL_PRODUCT_CODE,
'voucher_' . (int) $voucher['id'],
$amount, $amount, $voucher['currency'],
mb_substr('تزوّد من ' . $station['name_ar'], 0, 255),
]);
$con->commit();
} catch (PDOException $e) {
if ($con->inTransaction()) $con->rollBack();
// ‏23000 على المفتاح الفريد يعني أن هذه القسيمة قُيّدت فعلاً — نداء
// ‏مكرّر لا خطأ. القسيمة مصروفة والدَّين مقيَّد مرة واحدة.
if ($e->getCode() === '23000') {
jsonSuccess(['code' => $code, 'duplicate' => true], 'القسيمة مصروفة سابقاً');
}
error_log('[fuel/redeem] ' . $e->getMessage());
jsonError('تعذّر إتمام الصرف', 500);
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
error_log('[fuel/redeem] ' . $e->getMessage());
jsonError('تعذّر إتمام الصرف', 500);
}
error_log("[fuel] صُرفت القسيمة {$code} في محطة #{$station['id']} بقيمة $amount");
jsonSuccess([
'code' => $code,
'amount' => $amount,
'currency' => $voucher['currency'],
'station' => $station['name_ar'],
'discount_percent' => (float) $station['discount_percent'],
], 'تم الصرف');
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* fuel/request_voucher.php — إصدار قسيمة تزوّد
*
* ‏السائق يطلب مبلغاً فيحصل على رمز يعطيه لعامل المحطة. لا دَين بعد —
* ‏القسيمة حجزٌ من السقف فقط، والدَّين يُقيَّد عند تأكيد المحطة الصرف.
*
* ‏لماذا قسيمة مسبقة لا خصم عند المضخة؟ لأن عامل المحطة لا يملك وسيلة
* ‏للتحقق من هوية السائق ولا من سقفه. القسيمة تنقل القرار إلى تطبيق
* ‏السائق حيث الهوية مؤكَّدة والسقف معلوم، ولا يبقى على المحطة إلا
* ‏تأكيد رقم.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$amount = (float) (filterRequest('amount', 'numeric') ?: 0);
if ($amount <= 0) {
jsonError('حدّد مبلغ التزوّد');
}
$obligation = fuelObligation($con, $driverId);
if (!$obligation) {
jsonError('فعّل محفظة الوقود أولاً');
}
// ‏تحرير المنتهي قبل الحساب: بدونه يبقى المبلغ محجوزاً بقسيمة ماتت،
// ‏فيُمنع السائق من التزوّد بسبب طلب نسيه قبل ساعة.
fuelExpireStaleVouchers($con, $driverId);
// ‏قسيمة مفتوحة واحدة في كل وقت. قسيمتان تعنيان رمزين في يد السائق،
// ‏وخطأ عامل المحطة في اختيار أحدهما يصرف المبلغ الخطأ — والأهم أن
// ‏السائق لا يحتاج أكثر من واحدة أصلاً.
$st = $con->prepare("
SELECT id, code, amount_authorized, expires_at
FROM fuel_vouchers
WHERE driver_id = ? AND status = 'issued' AND expires_at >= NOW()
LIMIT 1
");
$st->execute([$driverId]);
if ($open = $st->fetch(PDO::FETCH_ASSOC)) {
jsonError(
'لديك قسيمة سارية بالرمز ' . $open['code'] . ' بقيمة '
. (float) $open['amount_authorized'] . '. استخدمها أو ألغِها أولاً.'
);
}
$state = fuelCreditState($con, $obligation);
if ($amount > $state['available']) {
jsonError(
'المتاح في محفظة وقودك ' . $state['available'] . ' ' . $obligation['currency']
. '. سدّد المستحق عليك ليعود سقفك.'
);
}
try {
$code = fuelGenerateCode($con);
$con->prepare("
INSERT INTO fuel_vouchers
(obligation_id, driver_id, code, amount_authorized, currency, status, expires_at)
VALUES (?, ?, ?, ?, ?, 'issued', DATE_ADD(NOW(), INTERVAL ? MINUTE))
")->execute([
(int) $obligation['id'], $driverId, $code, $amount,
$obligation['currency'], FUEL_VOUCHER_TTL_MIN,
]);
$voucherId = (int) $con->lastInsertId();
} catch (Throwable $e) {
error_log('[fuel/request_voucher] ' . $e->getMessage());
jsonError('تعذّر إصدار القسيمة حالياً');
}
error_log("[fuel] قسيمة #$voucherId للسائق $driverId بقيمة $amount");
jsonSuccess([
'voucher_id' => $voucherId,
'code' => $code,
'amount' => $amount,
'currency' => $obligation['currency'],
'expires_in_min' => FUEL_VOUCHER_TTL_MIN,
'available_after' => round($state['available'] - $amount, 3),
], 'أعطِ هذا الرمز لعامل المحطة');
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* fuel/stations.php — المحطات الشريكة
*
* ‏قائمة للسائق، مرتّبة بالقرب إن أرسل موقعه. لا يُرجع مفاتيح المحطات
* ‏ولا أي حقل تشغيلي — القائمة تُعرض في تطبيق السائق، وكل ما يخرج إليه
* ‏يجب افتراض أنه علنيّ.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$lat = filterRequest('lat', 'numeric');
$lng = filterRequest('lng', 'numeric');
try {
if ($lat !== null && $lng !== null) {
// ‏مسافة تقريبية بفرق الإحداثيات لا Haversine: الغرض ترتيب قائمة
// ‏محطات في مدينة واحدة، والفارق بين الطريقتين على هذا النطاق
// ‏لا يغيّر ترتيباً — بينما الحساب المثلثي على كل صف يكلّف بلا مقابل.
$st = $con->prepare("
SELECT id, name_ar, chain_name, city, address, lat, lng, phone,
discount_percent,
(POW(lat - ?, 2) + POW(lng - ?, 2)) AS dist
FROM fuel_stations
WHERE is_active = 1
ORDER BY (lat IS NULL), dist ASC
LIMIT 50
");
$st->execute([(float) $lat, (float) $lng]);
} else {
$st = $con->query("
SELECT id, name_ar, chain_name, city, address, lat, lng, phone,
discount_percent
FROM fuel_stations
WHERE is_active = 1
ORDER BY city ASC, name_ar ASC
LIMIT 50
");
}
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[fuel/stations] ' . $e->getMessage());
jsonError('Server error');
}
$out = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'name' => $r['name_ar'],
'chain' => $r['chain_name'],
'city' => $r['city'],
'address' => $r['address'],
'lat' => $r['lat'] !== null ? (float) $r['lat'] : null,
'lng' => $r['lng'] !== null ? (float) $r['lng'] : null,
'phone' => $r['phone'],
'discount_percent' => (float) $r['discount_percent'],
], $rows);
jsonSuccess(['stations' => $out], 'success');