Update codebase
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
/**
|
||||
* obligations/functions.php — دوال محرك الالتزامات المشتركة
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* الواجهة التي تستعملها المنتجات (تأمين، وقود، صيانة) لفتح التزام
|
||||
* وإغلاقه وقراءة المستحق عليه. لا شيء هنا يلمس مالاً: الخصم حكرٌ على
|
||||
* `bot/cron_obligation_settlement.php` وحده.
|
||||
*
|
||||
* سبب وجود هذا الملف أن `driver_assurance/` كان يكتب في جداوله الخاصة،
|
||||
* وبقاؤه كذلك بعد الترحيل كان يعني أن كل اشتراك **جديد** لا يظهر في
|
||||
* الدفتر الموحّد — أي لا يُقيَّد له قسط ولا يُحصَّل منه شيء. الترحيل
|
||||
* لمرة واحدة يعالج الماضي؛ هذه الدوال تمنع تكرار الثغرة في المستقبل.
|
||||
*/
|
||||
|
||||
const OBLIGATION_HTTP_TIMEOUT = 15;
|
||||
|
||||
function obligationWalletServerUrl(): string
|
||||
{
|
||||
// bootstrap.php يعرّف GLOBAL_COUNTRY ثابتاً ولا يضعه في البيئة —
|
||||
// استخدم الثابت لا getenv (نفس فخّ foodWalletServerUrl).
|
||||
$country = strtolower(defined('GLOBAL_COUNTRY') ? GLOBAL_COUNTRY : (getenv('GLOBAL_COUNTRY') ?: 'jordan'));
|
||||
return match ($country) {
|
||||
'egypt' => getenv('WALLET_SERVER_EGYPT') ?: 'https://wallet-egypt.siromove.com',
|
||||
'syria' => getenv('WALLET_SERVER_SYRIA') ?: 'https://wallet-syria.siromove.com',
|
||||
default => getenv('WALLET_SERVER_JORDAN') ?: 'https://walletintaleq.intaleq.xyz',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* نداء خادم-لخادم إلى سيرفر المحفظة. يُرجع الحمولة أو null عند الفشل.
|
||||
*
|
||||
* printSuccess في سيرفر المحفظة يغلّف الحمولة في المفتاح "message" —
|
||||
* اصطلاحه لا خطأ تسمية هنا.
|
||||
*/
|
||||
function obligationWalletCall(string $path, array $params): ?array
|
||||
{
|
||||
$key = getenv('S2S_SHARED_KEY') ?: '';
|
||||
if ($key === '') {
|
||||
error_log('[obligation] S2S_SHARED_KEY غير معرّف');
|
||||
return null;
|
||||
}
|
||||
|
||||
$ch = curl_init(obligationWalletServerUrl() . $path);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($params),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => OBLIGATION_HTTP_TIMEOUT,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'X-S2S-Api-Key: ' . $key,
|
||||
],
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $code !== 200) {
|
||||
error_log("[obligation] نداء المحفظة فشل ($path): HTTP $code $err");
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode((string) $body, true);
|
||||
if (!is_array($json) || ($json['status'] ?? '') !== 'success' || !is_array($json['message'] ?? null)) {
|
||||
error_log("[obligation] رد غير متوقّع ($path): " . substr((string) $body, 0, 200));
|
||||
return null;
|
||||
}
|
||||
|
||||
return $json['message'];
|
||||
}
|
||||
|
||||
/**
|
||||
* ملخّص دخل السائق المارّ بالمحفظة في آخر ثلاثين يوماً، أو null عند
|
||||
* تعذّر الوصول. يُقرأ من سيرفر المحفظة لأن الحركات تعيش هناك.
|
||||
*/
|
||||
function obligationWalletIncomeSummary(string $driverId): ?array
|
||||
{
|
||||
return obligationWalletCall(
|
||||
'/v2/main/ride/driverWallet/income_summary_s2s.php',
|
||||
['driverID' => $driverId, 'window_days' => 30]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* بوابة الائتمان: هل تمرّ أرباح هذا السائق بالمحفظة بما يكفي؟
|
||||
*
|
||||
* قرار المالك (2026-08-09): لا التزام ائتماني لمن لا تمرّ أرباحه
|
||||
* بالمحفظة. السبب أن سقف الخصم اليومي نسبة من الدخل المارّ بها، فسائق
|
||||
* الكاش سقفه صفر دائماً ولا يُحصَّل منه شيء أبداً — ومنحه وقوداً أو
|
||||
* صيانة بالدَّين تسليمُ قيمةٍ فعلية مقابل قناة سداد لا وجود لها.
|
||||
*
|
||||
* الفشل مغلق: تعذُّر الوصول إلى سيرفر المحفظة يعني **رفضاً** لا قبولاً.
|
||||
* عطلٌ شبكي عابر يؤجّل منح ائتمان، بينما القبول عند الشك يمنحه لمن قد
|
||||
* لا يُسترد منه — والخطأ الثاني وحده هو الذي يكلّف مالاً.
|
||||
*
|
||||
* `$summary` يُمرَّر جاهزاً حين تُفحص عدة منتجات لسائق واحد (شاشة
|
||||
* الخطط): الملخّص عن السائق لا عن المنتج، ونداء لكل خطة يعني أربعة
|
||||
* نداءات شبكية لبناء شاشة واحدة.
|
||||
*
|
||||
* @return array{eligible: bool, reasons: string[], summary: array|null}
|
||||
*/
|
||||
function obligationCheckWalletIncome(array $product, string $driverId, ?array $summary = null): array
|
||||
{
|
||||
if ((int) ($product['requires_wallet_income'] ?? 1) !== 1) {
|
||||
return ['eligible' => true, 'reasons' => [], 'summary' => null];
|
||||
}
|
||||
|
||||
$minDays = (int) ($product['min_wallet_days_30d'] ?? 0);
|
||||
$minIncome = (float) ($product['min_wallet_income_30d'] ?? 0);
|
||||
|
||||
$summary ??= obligationWalletIncomeSummary($driverId);
|
||||
|
||||
if ($summary === null) {
|
||||
return [
|
||||
'eligible' => false,
|
||||
'reasons' => ['تعذّر التحقق من حركة محفظتك حالياً. حاول لاحقاً.'],
|
||||
'summary' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$days = (int) ($summary['days_with_income'] ?? 0);
|
||||
$income = (float) ($summary['total_income'] ?? 0);
|
||||
|
||||
$reasons = [];
|
||||
if ($days < $minDays) {
|
||||
$reasons[] = "أرباحك مرّت بالمحفظة في $days يوماً خلال آخر ٣٠ يوماً، والمطلوب $minDays";
|
||||
}
|
||||
if ($minIncome > 0 && $income < $minIncome) {
|
||||
$reasons[] = 'إجمالي ما مرّ بمحفظتك خلال آخر ٣٠ يوماً أقل من الحد المطلوب';
|
||||
}
|
||||
|
||||
return [
|
||||
'eligible' => empty($reasons),
|
||||
'reasons' => $reasons,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* يقرأ منتج التزام برمزه. يُرجع null إن لم يوجد أو كان مُطفأً.
|
||||
*/
|
||||
function obligationProduct(PDO $con, string $productCode): ?array
|
||||
{
|
||||
$st = $con->prepare("
|
||||
SELECT * FROM obligation_products WHERE code = ? AND is_active = 1 LIMIT 1
|
||||
");
|
||||
$st->execute([$productCode]);
|
||||
|
||||
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* يفتح التزاماً لسائق على منتج، ويُرجع معرّفه.
|
||||
*
|
||||
* `cycle_amount` يُنسخ من المنتج لحظة الفتح ولا يُقرأ منه لاحقاً: رفع
|
||||
* سعر المنتج غداً يجب ألّا يغيّر قسط من اشترك اليوم بأثر رجعي.
|
||||
*/
|
||||
function obligationOpen(
|
||||
PDO $con,
|
||||
string $driverId,
|
||||
string $productCode,
|
||||
array $snapshot = [],
|
||||
float $principal = 0.0,
|
||||
?string $externalRef = null
|
||||
): int {
|
||||
$product = obligationProduct($con, $productCode);
|
||||
|
||||
if (!$product) {
|
||||
throw new RuntimeException("منتج الالتزام غير موجود أو غير مفعّل: $productCode");
|
||||
}
|
||||
|
||||
// البوابة تُفحص هنا أيضاً لا في نقطة الاستدعاء وحدها. النقطة قد
|
||||
// تفحص وتنسى، أو يُضاف منتج جديد بنقطة جديدة تُغفلها — والفحص عند
|
||||
// الكتابة هو الوحيد الذي لا يمكن الالتفاف عليه بالسهو.
|
||||
$gate = obligationCheckWalletIncome($product, $driverId);
|
||||
if (!$gate['eligible']) {
|
||||
throw new RuntimeException('بوابة الائتمان: ' . implode('، ', $gate['reasons']));
|
||||
}
|
||||
|
||||
$con->prepare("
|
||||
INSERT INTO driver_obligations
|
||||
(driver_id, product_id, external_ref, status, started_at,
|
||||
cycle_amount, principal_amount, rides_at_signup, rating_at_signup)
|
||||
VALUES (?, ?, ?, 'active', CURDATE(), ?, ?, ?, ?)
|
||||
")->execute([
|
||||
$driverId,
|
||||
(int) $product['id'],
|
||||
$externalRef,
|
||||
(float) ($snapshot['cycle_amount'] ?? 0),
|
||||
$principal,
|
||||
(int) ($snapshot['rides'] ?? 0),
|
||||
(float) ($snapshot['rating'] ?? 0),
|
||||
]);
|
||||
|
||||
return (int) $con->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* يُغلق التزاماً نشطاً. لا يمسّ القيود المستحقّة — تصفيرها عند الإلغاء
|
||||
* كانت ستجعل الإلغاء وسيلةً للتهرّب من أيام استفاد منها السائق فعلاً.
|
||||
*/
|
||||
function obligationClose(PDO $con, string $driverId, string $productCode, string $reason): bool
|
||||
{
|
||||
$st = $con->prepare("
|
||||
UPDATE driver_obligations o
|
||||
JOIN obligation_products p ON p.id = o.product_id
|
||||
SET o.status = 'cancelled', o.ended_at = CURDATE(), o.cancel_reason = ?
|
||||
WHERE o.driver_id = ? AND p.code = ? AND o.status = 'active'
|
||||
");
|
||||
$st->execute([mb_substr($reason, 0, 255), $driverId, $productCode]);
|
||||
|
||||
return $st->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* مجموع ما بقي على السائق في منتج ما.
|
||||
*
|
||||
* الأساس `amount_remaining` لا `amount`: قيدٌ حُصِّل نصفه لم يعد مستحقاً
|
||||
* بكامله، وعرض الأصل للسائق بعد اقتطاعٍ منه مطالبةٌ بما دُفع.
|
||||
*/
|
||||
function obligationPendingTotal(PDO $con, string $driverId, string $productCode): float
|
||||
{
|
||||
$st = $con->prepare("
|
||||
SELECT COALESCE(SUM(amount_remaining), 0)
|
||||
FROM obligation_ledger
|
||||
WHERE driver_id = ? AND product_code = ?
|
||||
AND status IN ('pending', 'partial')
|
||||
");
|
||||
$st->execute([$driverId, $productCode]);
|
||||
|
||||
return (float) $st->fetchColumn();
|
||||
}
|
||||
Reference in New Issue
Block a user