Files
Siro/backend/bot/cron_pricing_stability_engine.php
T

270 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* cron_pricing_stability_engine.php
* ─────────────────────────────────────────────────────────────
* محرك الثبات والاستقرار — وضع مراقبة (Shadow Mode) فقط.
*
* ما بيلمس جدول kazan أبداً. بيقارن معامل سعر الكيلو الحالي لكل
* منافس/دولة (من competitor_secret_formulas) مع الوسيط المرجعي
* لآخر 7 أيام (من competitor_formula_history)، ويسجّل تصنيفه
* والإجراء المقترح بجدول pricing_stability_log لمراجعته يدوياً.
*
* التصنيف:
* - drift <= -15% → temporary_promo (برومو مؤقت — لا يُغيَّر السعر الأساسي)
* - |drift| > 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";