diff --git a/app/Core/AI.php b/app/Core/AI.php index 18955cd..a649bd1 100644 --- a/app/Core/AI.php +++ b/app/Core/AI.php @@ -30,7 +30,7 @@ class AI "contents" => [ [ "parts" => [ - ["text" => $prompt], + ["text" => $prompt . " If the image is not an invoice, is blank, or is completely unreadable, return ONLY: {\"error\": \"invalid_invoice\"}. DO NOT guess or invent data."], [ "inline_data" => [ "mime_type" => $mimeType, @@ -65,6 +65,11 @@ class AI if (!$textResponse) return null; - return json_decode($textResponse, true); + $data = json_decode($textResponse, true); + if (isset($data['error']) && $data['error'] === 'invalid_invoice') { + return null; + } + + return $data; } } diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index fc5990d..5e5a3b1 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -64,10 +64,37 @@ class NotificationService return $successCount > 0; } + /** + * Send a data-only (silent) notification to update background state (e.g., progress) + */ + public function sendDataNotification(string $userId, array $data, ?string $deviceId = null): bool + { + $db = Database::getInstance(); + if ($deviceId) { + $stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND device_fingerprint = ? AND push_token IS NOT NULL"); + $stmt->execute([$userId, $deviceId]); + } else { + $stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND push_token IS NOT NULL AND is_active = 1"); + $stmt->execute([$userId]); + } + + $tokens = $stmt->fetchAll(\PDO::FETCH_COLUMN); + if (empty($tokens)) return false; + + $successCount = 0; + foreach ($tokens as $token) { + if ($this->dispatchToFcm($token, null, null, $data)) { + $successCount++; + } + } + + return $successCount > 0; + } + /** * Dispatch notification to Firebase via HTTP v1 API */ - private function dispatchToFcm(string $token, string $title, string $body, array $data): bool + private function dispatchToFcm(string $token, ?string $title, ?string $body, array $data): bool { if (!file_exists($this->serviceAccountPath)) { error_log("[NotificationService] Firebase service account file missing: {$this->serviceAccountPath}"); @@ -79,30 +106,34 @@ class NotificationService $url = "https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send"; - $payload = [ - 'message' => [ - 'token' => $token, + $message = [ + 'token' => $token, + 'data' => array_map('strval', $data), + ]; + + if ($title || $body) { + $message['notification'] = [ + 'title' => $title, + 'body' => $body, + ]; + $message['android'] = [ + 'priority' => 'high', 'notification' => [ - 'title' => $title, - 'body' => $body, - ], - 'data' => array_map('strval', $data), // FCM data values must be strings - 'android' => [ - 'priority' => 'high', - 'notification' => [ + 'sound' => 'default', + 'channel_id' => 'high_importance_channel' + ] + ]; + $message['apns'] = [ + 'payload' => [ + 'aps' => [ 'sound' => 'default', - 'channel_id' => 'high_importance_channel' - ] - ], - 'apns' => [ - 'payload' => [ - 'aps' => [ - 'sound' => 'default', - ], ], ], - ], - ]; + ]; + } + + $payload = ['message' => $message]; + $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); diff --git a/app/cron/process_batches.php b/app/cron/process_batches.php index e72480a..bd2e706 100644 --- a/app/cron/process_batches.php +++ b/app/cron/process_batches.php @@ -122,16 +122,31 @@ try { // Mark queue item done $db->prepare("UPDATE invoice_processing_queue SET status = 'done', invoice_id = ?, processed_at = NOW() WHERE id = ?")->execute([$invoiceId, $queueId]); - + // Update batch progress $db->prepare("UPDATE invoice_batches SET processed_images = processed_images + 1 WHERE id = ?")->execute([$batchId]); $db->commit(); echo "Success: Created Invoice $invoiceId\n"; + // Send silent push update for progress + $stmt = $db->prepare("SELECT total_images, processed_images, uploaded_by FROM invoice_batches WHERE id = ?"); + $stmt->execute([$batchId]); + $currentBatch = $stmt->fetch(); + if ($currentBatch) { + $notifier = new NotificationService(); + $notifier->sendDataNotification($currentBatch['uploaded_by'], [ + 'type' => 'batch_progress', + 'batch_id' => $batchId, + 'processed' => $currentBatch['processed_images'], + 'total' => $currentBatch['total_images'] + ]); + } + // Increment Quota QuotaMiddleware::incrementInvoiceUsage($tenantId); + } catch (Exception $e) { $db->rollBack(); echo "DB Error: " . $e->getMessage() . "\n"; diff --git a/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart index 8012580..cca2b6f 100644 --- a/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart +++ b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../../../core/utils/logger.dart'; @@ -15,6 +16,10 @@ class ScannerController extends GetxController { var uploadProgress = 0.0.obs; var companies = >[].obs; var isLoadingCompanies = false.obs; + var currentBatchId = ''.obs; + var processedImagesCount = 0.obs; + var totalImagesCount = 0.obs; + var isBatchDone = false.obs; final InvoiceUploadService _uploadService = InvoiceUploadService(); @@ -22,6 +27,21 @@ class ScannerController extends GetxController { void onInit() { super.onInit(); fetchCompanies(); + _initFcmListener(); + } + + void _initFcmListener() { + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + final data = message.data; + if (data['type'] == 'batch_progress' && data['batch_id'] == currentBatchId.value) { + processedImagesCount.value = int.tryParse(data['processed'].toString()) ?? 0; + totalImagesCount.value = int.tryParse(data['total'].toString()) ?? 0; + + if (processedImagesCount.value >= totalImagesCount.value) { + isBatchDone.value = true; + } + } + }); } Future fetchCompanies() async { @@ -96,23 +116,58 @@ class ScannerController extends GetxController { ); if (batchId != null) { + currentBatchId.value = batchId; + totalImagesCount.value = capturedImages.length; + processedImagesCount.value = 0; capturedImages.clear(); uploadProgress.value = 0.0; - Get.defaultDialog( - title: 'جاري المعالجة ⏳', - middleText: 'تم استلام الفواتير بنجاح وسيتم إشعارك فور الانتهاء من تدقيقها عبر الذكاء الاصطناعي.', - textConfirm: 'حسناً', - confirmTextColor: Colors.white, - buttonColor: const Color(0xFF0F4C81), - onConfirm: () { - if (Get.isDialogOpen ?? false) Get.back(); // close dialog - Get.back(); // go back to dashboard - }, + Get.dialog( + AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: const Center( + child: Text('جاري المعالجة ⏳', + style: TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold, fontSize: 18) + ) + ), + content: Obx(() => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('يتم الآن تدقيق الفواتير عبر الذكاء الاصطناعي...', + textAlign: TextAlign.center, + style: TextStyle(fontFamily: 'El Messiri', fontSize: 14) + ), + const SizedBox(height: 20), + if (!isBatchDone.value) ...[ + LinearProgressIndicator( + value: totalImagesCount.value > 0 ? processedImagesCount.value / totalImagesCount.value : 0, + backgroundColor: Colors.grey[200], + valueColor: const AlwaysStoppedAnimation(Color(0xFF0F4C81)), + ), + const SizedBox(height: 10), + Text('${processedImagesCount.value} من ${totalImagesCount.value}', + style: const TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold) + ), + ] else ...[ + const Icon(Icons.check_circle, color: Colors.green, size: 50), + const SizedBox(height: 10), + const Text('اكتملت المعالجة بنجاح!', + style: TextStyle(fontFamily: 'El Messiri', color: Colors.green, fontWeight: FontWeight.bold) + ), + ], + ], + )), + actions: [ + TextButton( + onPressed: () { + Get.back(); // close dialog + Get.back(); // go back to dashboard + }, + child: const Text('إغلاق', style: TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold)), + ) + ], + ), barrierDismissible: false, - titleStyle: const TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold, fontSize: 18), - middleTextStyle: const TextStyle(fontFamily: 'El Messiri', fontSize: 14), - radius: 12, ); } else { AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً');