Update: 2026-05-07 02:01:59
This commit is contained in:
@@ -30,7 +30,7 @@ class AI
|
|||||||
"contents" => [
|
"contents" => [
|
||||||
[
|
[
|
||||||
"parts" => [
|
"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" => [
|
"inline_data" => [
|
||||||
"mime_type" => $mimeType,
|
"mime_type" => $mimeType,
|
||||||
@@ -65,6 +65,11 @@ class AI
|
|||||||
|
|
||||||
if (!$textResponse) return null;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,10 +64,37 @@ class NotificationService
|
|||||||
return $successCount > 0;
|
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
|
* 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)) {
|
if (!file_exists($this->serviceAccountPath)) {
|
||||||
error_log("[NotificationService] Firebase service account file missing: {$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";
|
$url = "https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send";
|
||||||
|
|
||||||
$payload = [
|
$message = [
|
||||||
'message' => [
|
'token' => $token,
|
||||||
'token' => $token,
|
'data' => array_map('strval', $data),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($title || $body) {
|
||||||
|
$message['notification'] = [
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
];
|
||||||
|
$message['android'] = [
|
||||||
|
'priority' => 'high',
|
||||||
'notification' => [
|
'notification' => [
|
||||||
'title' => $title,
|
'sound' => 'default',
|
||||||
'body' => $body,
|
'channel_id' => 'high_importance_channel'
|
||||||
],
|
]
|
||||||
'data' => array_map('strval', $data), // FCM data values must be strings
|
];
|
||||||
'android' => [
|
$message['apns'] = [
|
||||||
'priority' => 'high',
|
'payload' => [
|
||||||
'notification' => [
|
'aps' => [
|
||||||
'sound' => 'default',
|
'sound' => 'default',
|
||||||
'channel_id' => 'high_importance_channel'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'apns' => [
|
|
||||||
'payload' => [
|
|
||||||
'aps' => [
|
|
||||||
'sound' => 'default',
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
];
|
||||||
];
|
}
|
||||||
|
|
||||||
|
$payload = ['message' => $message];
|
||||||
|
|
||||||
|
|
||||||
$ch = curl_init();
|
$ch = curl_init();
|
||||||
curl_setopt($ch, CURLOPT_URL, $url);
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
|||||||
@@ -129,9 +129,24 @@ try {
|
|||||||
$db->commit();
|
$db->commit();
|
||||||
echo "Success: Created Invoice $invoiceId\n";
|
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
|
// Increment Quota
|
||||||
QuotaMiddleware::incrementInvoiceUsage($tenantId);
|
QuotaMiddleware::incrementInvoiceUsage($tenantId);
|
||||||
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$db->rollBack();
|
$db->rollBack();
|
||||||
echo "DB Error: " . $e->getMessage() . "\n";
|
echo "DB Error: " . $e->getMessage() . "\n";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import '../../../core/utils/logger.dart';
|
import '../../../core/utils/logger.dart';
|
||||||
@@ -15,6 +16,10 @@ class ScannerController extends GetxController {
|
|||||||
var uploadProgress = 0.0.obs;
|
var uploadProgress = 0.0.obs;
|
||||||
var companies = <Map<String, dynamic>>[].obs;
|
var companies = <Map<String, dynamic>>[].obs;
|
||||||
var isLoadingCompanies = false.obs;
|
var isLoadingCompanies = false.obs;
|
||||||
|
var currentBatchId = ''.obs;
|
||||||
|
var processedImagesCount = 0.obs;
|
||||||
|
var totalImagesCount = 0.obs;
|
||||||
|
var isBatchDone = false.obs;
|
||||||
|
|
||||||
final InvoiceUploadService _uploadService = InvoiceUploadService();
|
final InvoiceUploadService _uploadService = InvoiceUploadService();
|
||||||
|
|
||||||
@@ -22,6 +27,21 @@ class ScannerController extends GetxController {
|
|||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
fetchCompanies();
|
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<void> fetchCompanies() async {
|
Future<void> fetchCompanies() async {
|
||||||
@@ -96,23 +116,58 @@ class ScannerController extends GetxController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (batchId != null) {
|
if (batchId != null) {
|
||||||
|
currentBatchId.value = batchId;
|
||||||
|
totalImagesCount.value = capturedImages.length;
|
||||||
|
processedImagesCount.value = 0;
|
||||||
capturedImages.clear();
|
capturedImages.clear();
|
||||||
uploadProgress.value = 0.0;
|
uploadProgress.value = 0.0;
|
||||||
|
|
||||||
Get.defaultDialog(
|
Get.dialog(
|
||||||
title: 'جاري المعالجة ⏳',
|
AlertDialog(
|
||||||
middleText: 'تم استلام الفواتير بنجاح وسيتم إشعارك فور الانتهاء من تدقيقها عبر الذكاء الاصطناعي.',
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
textConfirm: 'حسناً',
|
title: const Center(
|
||||||
confirmTextColor: Colors.white,
|
child: Text('جاري المعالجة ⏳',
|
||||||
buttonColor: const Color(0xFF0F4C81),
|
style: TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold, fontSize: 18)
|
||||||
onConfirm: () {
|
)
|
||||||
if (Get.isDialogOpen ?? false) Get.back(); // close dialog
|
),
|
||||||
Get.back(); // go back to dashboard
|
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>(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,
|
barrierDismissible: false,
|
||||||
titleStyle: const TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold, fontSize: 18),
|
|
||||||
middleTextStyle: const TextStyle(fontFamily: 'El Messiri', fontSize: 14),
|
|
||||||
radius: 12,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً');
|
AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً');
|
||||||
|
|||||||
Reference in New Issue
Block a user