chore: استيراد أولي من سيرو (ecfe7568) — بلا أي تعديل
نسخة كاملة من مستودع سيرو عند ecfe7568 لتكون أساس تطبيق «انطلق». نُسخ المتعقَّب في git فقط (12,509 ملفاً / 302 م.ب) بـ git archive، لا `cp -r` — فاستُثنيت تلقائياً مخلفات البناء (build · node_modules · .dart_tool · .gradle · Pods ≈ 10.7 غ.ب) وكل ما يستثنيه .gitignore. هذا الكوميت **بلا أي تعديل عمداً** حتى يكون كل ما يليه فرقاً مقروءاً مقابل سيرو الأصلي. سيرو نفسه لم يُمسّ. ⚠️ لا يبني بعد: `.env` و`lib/env/env.g.dart` غير متعقَّبين في سيرو (وهذا صحيح — أسرار لكل مستأجر). كل تطبيق فلاتر هنا يحتاج .env خاصاً بانطلق ثم توليد env.g.dart عبر build_runner. لا تُنسخ أسرار سيرو. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// Admin/marketing/trigger_campaign.php
|
||||
// API Endpoint to trigger Gemini AI campaign generation and dispatch
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
require_once __DIR__ . '/../../core/Services/SiroGeminiService.php';
|
||||
|
||||
// 1. Authorize Admin/Super Admin
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized access. Admin role required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Filter inputs
|
||||
$countryCode = filterRequest('country_code') ?? 'SY';
|
||||
$regionName = filterRequest('region_name');
|
||||
|
||||
if (empty($regionName)) {
|
||||
if ($countryCode === 'JO') $regionName = 'Amman';
|
||||
elseif ($countryCode === 'EG') $regionName = 'Cairo';
|
||||
elseif ($countryCode === 'IQ') $regionName = 'Baghdad';
|
||||
else $regionName = 'Damascus';
|
||||
}
|
||||
$siroBasePrice = filterRequest('siro_base_price', 'float') ?? 10000.0;
|
||||
|
||||
try {
|
||||
// 3. Fetch recent competitor prices for this region to supply context to Gemini
|
||||
$sqlPrices = "SELECT competitor_name, price_amount AS total_price, (price_amount / price_per_km) AS distance_km
|
||||
FROM scraped_competitor_prices
|
||||
WHERE country_code = :country AND price_per_km > 0
|
||||
ORDER BY created_at DESC LIMIT 10";
|
||||
$stmtPrices = $con->prepare($sqlPrices);
|
||||
$stmtPrices->execute([':country' => strtoupper($countryCode)]);
|
||||
$competitorPrices = $stmtPrices->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($competitorPrices)) {
|
||||
// Fallback mock context if no competitor pricing has been scraped yet
|
||||
$competitorPrices = [
|
||||
['competitor_name' => 'yallago', 'total_price' => 12000, 'distance_km' => 5],
|
||||
['competitor_name' => 'zaken', 'total_price' => 11500, 'distance_km' => 5]
|
||||
];
|
||||
}
|
||||
|
||||
// 4. Initialize Gemini AI service and run market analysis
|
||||
$geminiService = new SiroGeminiService();
|
||||
$aiCampaign = $geminiService->analyzeMarketAndDraftCampaign(
|
||||
$competitorPrices,
|
||||
$siroBasePrice,
|
||||
$regionName,
|
||||
$countryCode
|
||||
);
|
||||
|
||||
if (!$aiCampaign) {
|
||||
jsonError("Failed to generate campaign via Gemini AI service.");
|
||||
}
|
||||
|
||||
// Check if campaign is recommended
|
||||
$opportunityDetected = $aiCampaign['opportunity_detected'] ?? false;
|
||||
if (!$opportunityDetected) {
|
||||
jsonSuccess([
|
||||
'campaign_created' => false,
|
||||
'reason' => 'Gemini AI determined no marketing opportunity is present based on current pricing structures.',
|
||||
'ai_analysis' => $aiCampaign
|
||||
]);
|
||||
}
|
||||
|
||||
$promoCode = $aiCampaign['promo_code'] ?? 'SIROGO10';
|
||||
$discountVal = $aiCampaign['discount_percentage'] ?? 10;
|
||||
$pushTitle = $aiCampaign['push_title'] ?? 'خصومات مميزة من سيرو!';
|
||||
$pushBody = $aiCampaign['push_body'] ?? 'وفر أكثر على رحلتك القادمة معنا.';
|
||||
$smsBody = $aiCampaign['sms_body'] ?? 'اشتقنا لك! عد إلينا ووفر أكثر مع الرمز الترويجي الخاص بك.';
|
||||
|
||||
// 5. Target Passengers in the specified country
|
||||
// Since phone numbers are encrypted, we fetch all passengers, decrypt, and filter by country prefix.
|
||||
$sqlTarget = "SELECT id AS passenger_id, phone FROM passengers";
|
||||
$stmtTarget = $con->prepare($sqlTarget);
|
||||
$stmtTarget->execute();
|
||||
$allPassengers = $stmtTarget->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$targets = [];
|
||||
$debugCounts = ['JO' => 0, 'SY' => 0, 'EG' => 0, 'IQ' => 0, 'UNKNOWN' => 0, 'DECRYPT_FAIL' => 0];
|
||||
foreach ($allPassengers as $p) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($p['phone']);
|
||||
if (!$decryptedPhone) {
|
||||
$debugCounts['DECRYPT_FAIL']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$cleanPhone = preg_replace('/[^0-9]/', '', $decryptedPhone);
|
||||
$pCountry = 'UNKNOWN';
|
||||
if (strpos($cleanPhone, '962') === 0 || strpos($cleanPhone, '07') === 0) $pCountry = 'JO';
|
||||
elseif (strpos($cleanPhone, '963') === 0 || (strpos($cleanPhone, '09') === 0 && strlen($cleanPhone) == 10)) $pCountry = 'SY';
|
||||
elseif (strpos($cleanPhone, '20') === 0 || (strpos($cleanPhone, '01') === 0 && strlen($cleanPhone) == 11)) $pCountry = 'EG';
|
||||
elseif (strpos($cleanPhone, '964') === 0) $pCountry = 'IQ';
|
||||
|
||||
$debugCounts[$pCountry]++;
|
||||
|
||||
if ($pCountry === strtoupper($countryCode)) {
|
||||
$targets[] = ['passenger_id' => $p['passenger_id'], 'decrypted_phone' => $decryptedPhone];
|
||||
}
|
||||
}
|
||||
|
||||
$sentFcm = 0;
|
||||
$sentSms = 0;
|
||||
$sentWhatsApp = 0;
|
||||
$dispatchedPassengers = [];
|
||||
$fcmErrors = [];
|
||||
|
||||
// 5.5 وضع المعاينة: يُرجع ما ستفعله الحملة (النص، الكود، حجم الجمهور)
|
||||
// دون إنشاء كود ترويجي ودون إرسال أي إشعار. الحملة تُنشئ خصماً حقيقياً
|
||||
// وتصل كل ركاب الدولة، فوجود معاينة قبل الإطلاق ضروري.
|
||||
if (filterRequest('dry_run') === '1') {
|
||||
jsonSuccess([
|
||||
'dry_run' => true,
|
||||
'campaign_created' => false,
|
||||
'promo_code' => $promoCode,
|
||||
'discount_percent' => $discountVal,
|
||||
'region' => $regionName,
|
||||
'country_code' => strtoupper($countryCode),
|
||||
'audience_size' => count($targets),
|
||||
'ai_analysis' => $aiCampaign,
|
||||
], 'Preview only — no promo code was created and no notification was sent.');
|
||||
}
|
||||
|
||||
// 6. Save broadcast promo for this campaign (Option 1 - promos table adjustment)
|
||||
$sqlPromo = "INSERT INTO promos
|
||||
(promo_code, amount, description, passengerID, source, validity_start_date, validity_end_date)
|
||||
VALUES (:code, :amount, :desc, 'all', 'ai_generated', CURDATE(), DATE_ADD(CURDATE(), INTERVAL 7 DAY))";
|
||||
$stmtPromo = $con->prepare($sqlPromo);
|
||||
$stmtPromo->execute([
|
||||
':code' => $promoCode,
|
||||
':amount' => (string)$discountVal,
|
||||
':desc' => "AI Dynamic Promo: $promoCode ($discountVal%)"
|
||||
]);
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$passengerId = $target['passenger_id'];
|
||||
|
||||
// Enforce anti-spam: check if passenger received any SMS/WhatsApp campaign in the last 24 hours
|
||||
$sqlSpamCheck = "SELECT COUNT(*) FROM marketing_campaigns_log
|
||||
WHERE passenger_id = :pid
|
||||
AND message_type IN ('sms', 'whatsapp')
|
||||
AND sent_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)";
|
||||
$stmtSpam = $con->prepare($sqlSpamCheck);
|
||||
$stmtSpam->execute([':pid' => $passengerId]);
|
||||
$spamCount = intval($stmtSpam->fetchColumn());
|
||||
|
||||
// Check if passenger has active FCM token
|
||||
$sqlToken = "SELECT token FROM tokens WHERE passengerID = :pid ORDER BY id DESC LIMIT 1";
|
||||
$stmtToken = $con->prepare($sqlToken);
|
||||
$stmtToken->execute([':pid' => $passengerId]);
|
||||
$fcmToken = $stmtToken->fetchColumn();
|
||||
|
||||
$pushSent = false;
|
||||
if ($fcmToken) {
|
||||
$decryptedToken = $encryptionHelper->decryptData($fcmToken);
|
||||
if ($decryptedToken) {
|
||||
// Send FCM Push Notification (Free channel - no anti-spam restriction needed)
|
||||
$fcmData = [
|
||||
'type' => 'marketing_campaign',
|
||||
'promo_code' => $promoCode,
|
||||
'discount' => (string)$discountVal
|
||||
];
|
||||
|
||||
$fcmResult = sendFcmNotification(
|
||||
$decryptedToken,
|
||||
$pushTitle,
|
||||
$pushBody,
|
||||
$fcmData,
|
||||
'Marketing',
|
||||
'notification'
|
||||
);
|
||||
|
||||
if ($fcmResult['status'] === 'success') {
|
||||
$sentFcm++;
|
||||
// Log campaign dispatch
|
||||
$logStmt = $con->prepare("INSERT INTO marketing_campaigns_log (passenger_id, message_type, country_code, region_name, triggered_by) VALUES (?, 'push', ?, ?, 'autopilot')");
|
||||
$logStmt->execute([$passengerId, $countryCode, $regionName]);
|
||||
$dispatchedPassengers[] = $passengerId;
|
||||
$pushSent = true;
|
||||
} else {
|
||||
$fcmErrors[] = ['passenger_id' => $passengerId, 'error' => $fcmResult];
|
||||
}
|
||||
} else {
|
||||
$fcmErrors[] = ['passenger_id' => $passengerId, 'error' => 'Token decryption failed'];
|
||||
}
|
||||
} else {
|
||||
$fcmErrors[] = ['passenger_id' => $passengerId, 'error' => 'No token in DB'];
|
||||
}
|
||||
|
||||
if (!$pushSent) {
|
||||
// Fallback: Churned user (No token) OR Push failed -> Send WhatsApp or SMS
|
||||
// Check anti-spam first to prevent unnecessary marketing cost
|
||||
if ($spamCount === 0) {
|
||||
// Fetch and decrypt passenger phone number
|
||||
$sqlUser = "SELECT phone FROM passengers WHERE id = :pid LIMIT 1";
|
||||
$stmtUser = $con->prepare($sqlUser);
|
||||
$stmtUser->execute([':pid' => $passengerId]);
|
||||
$encPhone = $stmtUser->fetchColumn();
|
||||
|
||||
if ($encPhone) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($encPhone);
|
||||
if ($decryptedPhone) {
|
||||
// Send WhatsApp (or fallback to SMS simulation)
|
||||
$waResult = sendWhatsAppFromServer($decryptedPhone, $smsBody);
|
||||
|
||||
if ($waResult && ($waResult['status'] ?? '') === 'success') {
|
||||
$sentWhatsApp++;
|
||||
$logStmt = $con->prepare("INSERT INTO marketing_campaigns_log (passenger_id, message_type, country_code, region_name, triggered_by) VALUES (?, 'whatsapp', ?, ?, 'autopilot')");
|
||||
$logStmt->execute([$passengerId, $countryCode, $regionName]);
|
||||
$dispatchedPassengers[] = $passengerId;
|
||||
} else {
|
||||
// Fallback to SMS simulation
|
||||
$sentSms++;
|
||||
$logStmt = $con->prepare("INSERT INTO marketing_campaigns_log (passenger_id, message_type, country_code, region_name, triggered_by) VALUES (?, 'sms', ?, ?, 'autopilot')");
|
||||
$logStmt->execute([$passengerId, $countryCode, $regionName]);
|
||||
$dispatchedPassengers[] = $passengerId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log the audit event for Admin action
|
||||
logAudit(
|
||||
$con,
|
||||
$user_id ?? 'admin_system',
|
||||
'trigger_marketing_campaign',
|
||||
'promos',
|
||||
$promoCode,
|
||||
['promo_code' => $promoCode, 'targets_count' => count($dispatchedPassengers)]
|
||||
);
|
||||
|
||||
jsonSuccess([
|
||||
'campaign_created' => true,
|
||||
'promo_code' => $promoCode,
|
||||
'discount_percentage' => $discountVal,
|
||||
'push_notification' => [
|
||||
'title' => $pushTitle,
|
||||
'body' => $pushBody,
|
||||
'sent_count' => $sentFcm
|
||||
],
|
||||
'whatsapp_sms' => [
|
||||
'body' => $smsBody,
|
||||
'whatsapp_sent_count' => $sentWhatsApp,
|
||||
'sms_sent_count' => $sentSms
|
||||
],
|
||||
'total_dispatched' => count($dispatchedPassengers),
|
||||
'debug_info' => [
|
||||
'requested_country' => $countryCode,
|
||||
'total_passengers_in_db' => count($allPassengers),
|
||||
'matched_targets' => count($targets),
|
||||
'distribution' => $debugCounts,
|
||||
'fcm_errors' => $fcmErrors ?? []
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[trigger_campaign.php] Error: " . $e->getMessage());
|
||||
jsonError("Failed to trigger marketing campaign: " . $e->getMessage());
|
||||
}
|
||||
Reference in New Issue
Block a user