From ac0c343f18d8c07d7c49a08d1239a3b925703f17 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 9 Jul 2026 01:01:16 +0300 Subject: [PATCH] Update: 2026-07-09 01:01:15 --- .../marketing/get_pricing_stability_log.php | 57 ++++ backend/bot/cron_pricing_stability_engine.php | 269 ++++++++++++++++++ backend/pricing-engine/src/db/connection.ts | 40 +++ backend/pricing-engine/src/index.ts | 15 +- 4 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 backend/Admin/marketing/get_pricing_stability_log.php create mode 100644 backend/bot/cron_pricing_stability_engine.php diff --git a/backend/Admin/marketing/get_pricing_stability_log.php b/backend/Admin/marketing/get_pricing_stability_log.php new file mode 100644 index 00000000..a26a2795 --- /dev/null +++ b/backend/Admin/marketing/get_pricing_stability_log.php @@ -0,0 +1,57 @@ + 'failure', 'message' => 'Unauthorized access. Admin role required.']); + exit; +} + +try { + $countryCode = filterRequest('country_code'); + $limit = filterRequest('limit', 'int') ?? 100; + + $sql = "SELECT * FROM pricing_stability_log"; + $params = []; + + if ($countryCode) { + $sql .= " WHERE country_code = :country"; + $params[':country'] = strtoupper($countryCode); + } + + $sql .= " ORDER BY evaluated_at DESC LIMIT :limit"; + + $stmt = $con->prepare($sql); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + foreach ($params as $key => $val) { + $stmt->bindValue($key, $val); + } + $stmt->execute(); + $log = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // ملخص سريع لآخر تصنيف لكل دولة + $stmtLatest = $con->query(" + SELECT l1.* FROM pricing_stability_log l1 + INNER JOIN ( + SELECT country_code, MAX(evaluated_at) AS max_time + FROM pricing_stability_log + GROUP BY country_code + ) l2 ON l1.country_code = l2.country_code AND l1.evaluated_at = l2.max_time + "); + $latestPerCountry = $stmtLatest->fetchAll(PDO::FETCH_ASSOC); + + jsonSuccess([ + 'log' => $log, + 'latest_per_country' => $latestPerCountry, + ]); + +} catch (Exception $e) { + error_log("[get_pricing_stability_log.php] Error: " . $e->getMessage()); + jsonError("Failed to fetch pricing stability log: " . $e->getMessage()); +} diff --git a/backend/bot/cron_pricing_stability_engine.php b/backend/bot/cron_pricing_stability_engine.php new file mode 100644 index 00000000..a003b554 --- /dev/null +++ b/backend/bot/cron_pricing_stability_engine.php @@ -0,0 +1,269 @@ + 5% مستمر عبر آخر 48 ساعة → sustained_change (تغيير حقيقي مقترح) + * - غير هيك → stable + * + * فترة سكون: أي تغيير "حقيقي" مقترح يُحتجز إذا كان آخر تغيير حقيقي + * (would_apply=1 من نوع sustained_change) بآخر 3 أيام. + * + * جدولة مقترحة: كل 3 ساعات، بالتوازي مع cron_ai_engine.php. + * + * ملاحظة: competitor_formula_history جدول جديد — بيضل فاضي/قليل + * البيانات لأول أسبوع بعد النشر لحد ما يتراكم تاريخ كافي لحساب + * وسيط 7 أيام موثوق. هذا متوقع وطبيعي، مو خطأ. + */ + +require_once __DIR__ . '/../core/bootstrap.php'; +require_once __DIR__ . '/../functions.php'; + +try { + $con = Database::get('main'); +} catch (Exception $e) { + die("Connection failed: " . $e->getMessage() . "\n"); +} + +echo "Starting Pricing Stability Engine (Shadow Mode)...\n"; + +// ========================================== +// 0. إنشاء الجداول إذا لم تكن موجودة +// ========================================== +$con->exec(" +CREATE TABLE IF NOT EXISTS `pricing_stability_log` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `country_code` VARCHAR(5) NOT NULL, + `tier` VARCHAR(20) NOT NULL DEFAULT 'economy', + `reference_median_km_rate` DECIMAL(10,4) NOT NULL, + `current_km_rate` DECIMAL(10,4) NOT NULL, + `drift_pct` DECIMAL(6,2) NOT NULL, + `classification` ENUM('stable','temporary_promo','sustained_change') NOT NULL, + `suggested_action` TEXT NULL, + `would_apply` TINYINT(1) NOT NULL DEFAULT 0, + `hold_reason` VARCHAR(100) NULL, + `evaluated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_country_time` (`country_code`, `evaluated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"); + +$con->exec(" +CREATE TABLE IF NOT EXISTS `country_pricing_floor` ( + `country_code` VARCHAR(5) NOT NULL, + `min_km_rate` DECIMAL(10,4) NOT NULL, + `min_base_fare` DECIMAL(10,4) NOT NULL DEFAULT 0, + `updated_by` VARCHAR(100) NULL, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`country_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"); + +// ========================================== +// إعدادات +// ========================================== +$countryNameMap = ['JO' => 'Jordan', 'SY' => 'Syria', 'EG' => 'Egypt', 'IQ' => 'Iraq']; +$tierPriority = ['economy', 'standard', 'premium']; +$promoDropPct = -15.0; // انخفاض مفاجئ أكبر من هذا = برومو مؤقت +$driftThreshold = 5.0; // انحراف أكبر من هذا (مطلق) يستاهل الانتباه +$sustainedWindow = 48; // ساعة — لازم الانحراف يستمر عبرها +$holdDays = 3; // فترة سكون بعد أي تغيير حقيقي مقترح +$maxStepPct = 3.0; // أقصى تغيير مسموح بالمرة الوحدة +$discountFactor = 1 - 0.065; + +/** وسيط بسيط لمصفوفة أرقام */ +function median_of(array $nums): ?float { + $nums = array_values(array_filter($nums, fn($n) => $n !== null && $n > 0)); + if (empty($nums)) return null; + sort($nums); + $count = count($nums); + $mid = intdiv($count, 2); + if ($count % 2 === 0) { + return ($nums[$mid - 1] + $nums[$mid]) / 2; + } + return $nums[$mid]; +} + +foreach ($countryNameMap as $cc => $countryName) { + echo "\n[$cc] Evaluating...\n"; + + // ── 1. سعر Siro الحالي بجدول kazan (للأرضية وحساب التغيير المقترح) ── + $stmtKazan = $con->prepare("SELECT speedPrice, startPrice FROM kazan WHERE country = :country LIMIT 1"); + $stmtKazan->execute([':country' => $countryName]); + $kazanRow = $stmtKazan->fetch(PDO::FETCH_ASSOC); + $currentSiroKmRate = $kazanRow ? (float)$kazanRow['speedPrice'] : 0.0; + + if ($currentSiroKmRate <= 0) { + echo " ⚠️ No kazan row for $countryName. Skipping.\n"; + continue; + } + + // ── 2. أرضية ربحية — زرعها تلقائياً أول مرة بـ85% من السعر الحالي ── + $stmtFloor = $con->prepare("SELECT min_km_rate FROM country_pricing_floor WHERE country_code = :cc"); + $stmtFloor->execute([':cc' => $cc]); + $floorRow = $stmtFloor->fetch(PDO::FETCH_ASSOC); + + if (!$floorRow) { + $seedFloor = round($currentSiroKmRate * 0.85, 4); + $insFloor = $con->prepare(" + INSERT INTO country_pricing_floor (country_code, min_km_rate, min_base_fare, updated_by) + VALUES (:cc, :floor, 0, 'auto_seed_default') + "); + $insFloor->execute([':cc' => $cc, ':floor' => $seedFloor]); + $floorKmRate = $seedFloor; + echo " 🌱 Seeded default profit floor: $seedFloor (85% of current price)\n"; + } else { + $floorKmRate = (float)$floorRow['min_km_rate']; + } + + // ── 3. اختيار أفضل فئة متوفرة (economy أولاً) من المعادلات الحالية ── + $currentTier = null; + $currentKmRate = null; + foreach ($tierPriority as $tier) { + $stmtCur = $con->prepare(" + SELECT price_per_km FROM competitor_secret_formulas + WHERE country_code = :cc AND tier = :tier + ORDER BY last_updated DESC LIMIT 1 + "); + $stmtCur->execute([':cc' => $cc, ':tier' => $tier]); + $row = $stmtCur->fetch(PDO::FETCH_ASSOC); + if ($row && (float)$row['price_per_km'] > 0) { + $currentTier = $tier; + $currentKmRate = (float)$row['price_per_km']; + break; + } + } + + if ($currentKmRate === null) { + echo " ⚠️ No current competitor formula for $countryName. Skipping.\n"; + continue; + } + + // ── 4. الوسيط المرجعي لآخر 7 أيام من سجل التاريخ ── + $stmtHist = $con->prepare(" + SELECT price_per_km FROM competitor_formula_history + WHERE country_code = :cc AND tier = :tier + AND snapshotted_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + ORDER BY snapshotted_at ASC + "); + $stmtHist->execute([':cc' => $cc, ':tier' => $currentTier]); + $historyRates = array_map('floatval', $stmtHist->fetchAll(PDO::FETCH_COLUMN)); + + $referenceMedian = median_of($historyRates); + + if ($referenceMedian === null) { + echo " ℹ️ Not enough history yet for $countryName [tier=$currentTier] — building up (need ~7 days). Skipping evaluation.\n"; + continue; + } + + // ── 5. حساب الانحراف ── + $driftPct = round((($currentKmRate - $referenceMedian) / $referenceMedian) * 100, 2); + + // ── 6. هل الانحراف مستمر آخر 48 ساعة؟ ── + $stmtRecent = $con->prepare(" + SELECT price_per_km FROM competitor_formula_history + WHERE country_code = :cc AND tier = :tier + AND snapshotted_at >= DATE_SUB(NOW(), INTERVAL :hours HOUR) + ORDER BY snapshotted_at ASC + "); + $stmtRecent->execute([':cc' => $cc, ':tier' => $currentTier, ':hours' => $sustainedWindow]); + $recentRates = array_map('floatval', $stmtRecent->fetchAll(PDO::FETCH_COLUMN)); + + $isSustained = false; + if (count($recentRates) >= 2) { + $sameDirectionCount = 0; + foreach ($recentRates as $r) { + $rDrift = (($r - $referenceMedian) / $referenceMedian) * 100; + $sameSign = ($driftPct >= 0 && $rDrift >= 0) || ($driftPct < 0 && $rDrift < 0); + if ($sameSign && abs($rDrift) > $driftThreshold) { + $sameDirectionCount++; + } + } + // كل اللقطات بآخر 48 ساعة (وليس عينة واحدة) تؤكد نفس الاتجاه + $isSustained = ($sameDirectionCount === count($recentRates)); + } + + // ── 7. التصنيف ── + if ($driftPct <= $promoDropPct) { + $classification = 'temporary_promo'; + } elseif (abs($driftPct) > $driftThreshold && $isSustained) { + $classification = 'sustained_change'; + } else { + $classification = 'stable'; + } + + // ── 8. الإجراء المقترح + would_apply + فترة السكون ── + $wouldApply = false; + $holdReason = null; + $suggestedAction = 'لا إجراء — السعر ثابت'; + + if ($classification === 'temporary_promo') { + $wouldApply = true; + $suggestedAction = "منافس نازل مؤقتاً بنسبة " . abs($driftPct) . "% تحت المرجع — الرد المقترح: كود خصم مؤقت عبر محرك التسويق، بدون لمس السعر الأساسي"; + } elseif ($classification === 'sustained_change') { + // تحقق من فترة السكون بالاعتماد على سجلّنا الخاص فقط (shadow-only) + $stmtLastApply = $con->prepare(" + SELECT evaluated_at FROM pricing_stability_log + WHERE country_code = :cc AND classification = 'sustained_change' AND would_apply = 1 + ORDER BY evaluated_at DESC LIMIT 1 + "); + $stmtLastApply->execute([':cc' => $cc]); + $lastApply = $stmtLastApply->fetchColumn(); + + $inHoldPeriod = false; + if ($lastApply) { + $daysSince = (time() - strtotime($lastApply)) / 86400; + $inHoldPeriod = $daysSince < $holdDays; + } + + // السعر المستهدف: نفس منطق الخصم 6.5% المعتمد، بحد أقصى 3% تغيير بالمرة، ومقيّد بالأرضية + $rawTarget = $currentKmRate * $discountFactor; + $maxUp = $currentSiroKmRate * (1 + $maxStepPct / 100); + $maxDown = $currentSiroKmRate * (1 - $maxStepPct / 100); + $clampedTarget = max($maxDown, min($maxUp, $rawTarget)); + $clampedTarget = max($clampedTarget, $floorKmRate); + $clampedTarget = round($clampedTarget, 4); + + if ($inHoldPeriod) { + $wouldApply = false; + $holdReason = 'hold_period_active'; + $suggestedAction = "تغيير مستمر ({$driftPct}%) — بس بفترة سكون (آخر تغيير قبل أقل من $holdDays أيام). لو مفعّل: من $currentSiroKmRate إلى $clampedTarget"; + } else { + $wouldApply = true; + $suggestedAction = "تغيير مستمر ({$driftPct}%) — سعر كيلو مقترح: من $currentSiroKmRate إلى $clampedTarget (مقيّد بـ{$maxStepPct}% والأرضية $floorKmRate)"; + } + } + + // ── 9. تسجيل النتيجة ── + $insLog = $con->prepare(" + INSERT INTO pricing_stability_log + (country_code, tier, reference_median_km_rate, current_km_rate, drift_pct, classification, suggested_action, would_apply, hold_reason) + VALUES + (:cc, :tier, :ref, :cur, :drift, :cls, :action, :apply, :hold) + "); + $insLog->execute([ + ':cc' => $cc, + ':tier' => $currentTier, + ':ref' => $referenceMedian, + ':cur' => $currentKmRate, + ':drift' => $driftPct, + ':cls' => $classification, + ':action' => $suggestedAction, + ':apply' => $wouldApply ? 1 : 0, + ':hold' => $holdReason, + ]); + + $icon = $classification === 'stable' ? '✅' : ($classification === 'temporary_promo' ? '🎯' : '⚠️'); + echo " $icon [tier=$currentTier] drift={$driftPct}% → $classification\n"; + echo " $suggestedAction\n"; +} + +echo "\nPricing Stability Engine finished (shadow mode — kazan not touched).\n"; diff --git a/backend/pricing-engine/src/db/connection.ts b/backend/pricing-engine/src/db/connection.ts index b72490e3..9f9ad035 100644 --- a/backend/pricing-engine/src/db/connection.ts +++ b/backend/pricing-engine/src/db/connection.ts @@ -129,6 +129,46 @@ export async function saveFormulas( await pool.execute(sql, flatParams); } +/** + * لقطة insert-only من معاملات كل معادلة — لا تُستبدل أبداً، بعكس + * competitor_secret_formulas (UPSERT). هاي المصدر اللي يقارن عليه + * محرك الثبات (cron_pricing_stability_engine.php) "المعامل اليوم + * مقابل المعامل قبل أسبوع" لتمييز التغيير الحقيقي عن البرومو المؤقت. + */ +export async function saveFormulaHistory( + pool: mysql.Pool, + formulas: Array<{ + competitorName: string; + countryCode: string; + tier: string; + baseFare: number; + kmRate: number; + minRate: number; + minFare: number; + rSquared: number; + sampleCount: number; + }> +): Promise { + if (formulas.length === 0) return; + + const values = formulas.map(() => `(?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`).join(','); + const flatParams: (string | number)[] = []; + + for (const f of formulas) { + flatParams.push( + f.competitorName, f.countryCode, f.tier, + f.baseFare, f.kmRate, f.minRate, f.minFare, + f.rSquared, f.sampleCount + ); + } + + const sql = `INSERT INTO competitor_formula_history + (competitor_name, country_code, tier, base_fare, price_per_km, price_per_min, min_fare, r_squared, sample_size, snapshotted_at) + VALUES ${values}`; + + await pool.execute(sql, flatParams); +} + export async function saveSurgeInsights( pool: mysql.Pool, insights: Array<{ diff --git a/backend/pricing-engine/src/index.ts b/backend/pricing-engine/src/index.ts index 7820564e..50955f75 100644 --- a/backend/pricing-engine/src/index.ts +++ b/backend/pricing-engine/src/index.ts @@ -10,7 +10,7 @@ * Cron integration: see crontab examples in package.json scripts */ -import { getMySQL, fetchSamples, saveFormulas, saveSurgeInsights, saveSurgeZones, closeConnections } from './db/connection'; +import { getMySQL, fetchSamples, saveFormulas, saveFormulaHistory, saveSurgeInsights, saveSurgeZones, closeConnections } from './db/connection'; import { runAnalysis } from './analysis/engine'; import { Pool, RowDataPacket } from 'mysql2/promise'; @@ -236,6 +236,19 @@ async function processCompetitor( if (peakHours.length > 0) { console.log(` Peak hours stored: [${peakHours.join(', ')}]`); } + + // لقطة insert-only لمحرك الثبات (drift detection) — لا تُستبدل أبداً + await saveFormulaHistory(pool, formulas.map(f => ({ + competitorName: f.competitorName, + countryCode: f.countryCode, + tier: f.tier, + baseFare: f.baseFare, + kmRate: f.kmRate, + minRate: f.minRate, + minFare: f.minFare, + rSquared: f.rSquared, + sampleCount: f.sampleCount, + }))); } // --- Save surge insights ---