$maxBytes) { json_error('حجم ملف الصوت أكبر من الحد المسموح (10MB)', 413); } $geminiApiKey = env('GEMINI_API_KEY'); if (!$geminiApiKey) { json_error('Gemini API Key غير متوفر', 500); } $tmpPath = $audio['tmp_name']; $rawAudio = @file_get_contents($tmpPath); if ($rawAudio === false) { json_error('فشل في قراءة ملف الصوت', 500); } $base64Audio = base64_encode($rawAudio); $mimeType = detectAudioMimeType( $tmpPath, (string)($audio['type'] ?? ''), (string)($audio['name'] ?? '') ); $intent = extractIntentFromAudio($base64Audio, $mimeType, $geminiApiKey); $execution = executeVoiceAction($decoded, $intent); json_success([ 'intent' => $intent, 'execution' => $execution, ], 'تم تحليل الأمر الصوتي وتنفيذه'); function detectAudioMimeType(string $path, string $fallback, string $fileName = ''): string { $allowed = [ 'audio/mp3', 'audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/aiff', 'audio/aac', 'audio/ogg', 'audio/flac', ]; $detected = $fallback; if (function_exists('finfo_open')) { $finfo = finfo_open(FILEINFO_MIME_TYPE); if ($finfo !== false) { $probe = finfo_file($finfo, $path); if (is_string($probe) && $probe !== '') { $detected = $probe; } finfo_close($finfo); } } if ($detected === 'audio/x-wav' || str_ends_with(strtolower($fileName), '.wav')) { return 'audio/wav'; } // The Flutter recorder now sends WAV. If the server cannot detect the part // MIME type, use a Gemini-supported fallback instead of m4a/mp4. return in_array($detected, $allowed, true) ? $detected : 'audio/wav'; } function extractIntentFromAudio(string $base64Audio, string $mimeType, string $apiKey): array { $model = env('GEMINI_MODEL', 'gemini-flash-lite-latest'); $systemPrompt = << [ [ 'parts' => [ ['text' => 'حلّل هذا التسجيل الصوتي واستخرج أمر النظام بصيغة JSON فقط.'], [ 'inline_data' => [ 'mime_type' => $mimeType, 'data' => $base64Audio, ], ], ], ], ], 'systemInstruction' => [ 'parts' => [ ['text' => $systemPrompt], ], ], 'generationConfig' => [ 'responseMimeType' => 'application/json', 'temperature' => 0.1, ], ]; $result = callGeminiGenerateContent($model, $payload, $apiKey); // Some Gemini model/API combinations reject JSON mode for multimodal audio. // Retry once with prompt-only JSON enforcement before failing the request. if ($result['http_code'] !== 200 || !$result['body']) { $fallbackPayload = $payload; unset($fallbackPayload['generationConfig']['responseMimeType']); $result = callGeminiGenerateContent($model, $fallbackPayload, $apiKey); } if ($result['http_code'] !== 200 || !$result['body']) { $geminiError = parseGeminiError($result['body']); error_log( "Voice Gemini Error: HTTP {$result['http_code']} | {$result['curl_error']} | {$result['body']}" ); json_error( 'فشل تحليل الصوت بواسطة Gemini: ' . $geminiError['message'], 502, [ 'gemini_http_code' => $result['http_code'], 'gemini_status' => $geminiError['status'], 'gemini_model' => $model, 'audio_mime_type' => $mimeType, ] ); } $respData = json_decode($result['body'], true); if (!is_array($respData)) { json_error('تعذر قراءة رد Gemini', 500); } $rawText = $respData['candidates'][0]['content']['parts'][0]['text'] ?? ''; if (!is_string($rawText) || trim($rawText) === '') { json_error('رد غير متوقع من Gemini', 500); } $parsed = decodeModelJson($rawText); if (!is_array($parsed)) { error_log("Voice Gemini JSON parse failed. Raw: " . $rawText); json_error('فشل تفسير الأمر الصوتي', 500); } $action = isset($parsed['action']) && is_string($parsed['action']) ? strtolower(trim($parsed['action'])) : 'navigate'; $params = isset($parsed['params']) && is_array($parsed['params']) ? $parsed['params'] : []; $confirmation = isset($parsed['confirmation']) && is_string($parsed['confirmation']) ? trim($parsed['confirmation']) : 'تم فهم الأمر'; $transcript = isset($parsed['transcript']) && is_string($parsed['transcript']) ? trim($parsed['transcript']) : ''; return [ 'action' => $action, 'params' => $params, 'confirmation' => $confirmation, 'transcript' => $transcript, ]; } function callGeminiGenerateContent(string $model, array $payload, string $apiKey): array { $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent"; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE), CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'x-goog-api-key: ' . $apiKey, ], CURLOPT_TIMEOUT => 60, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($httpCode !== 200) { error_log("Gemini API Call Failed: HTTP $httpCode | Error: $error | URL: $url"); } return [ 'body' => is_string($response) ? $response : '', 'http_code' => (int)$httpCode, 'curl_error' => $error ?: '', ]; } function parseGeminiError(string $response): array { $decoded = json_decode($response, true); $error = is_array($decoded) ? ($decoded['error'] ?? null) : null; if (is_array($error)) { return [ 'message' => (string)($error['message'] ?? 'رد غير معروف من Gemini'), 'status' => (string)($error['status'] ?? ''), ]; } return [ 'message' => 'رد غير معروف من Gemini', 'status' => '', ]; } function decodeModelJson(string $rawText): ?array { $text = trim($rawText); // Remove fenced blocks if model wrapped JSON in ```json ... ``` if (str_starts_with($text, '```')) { $text = preg_replace('/^```(?:json)?/i', '', $text) ?? $text; $text = preg_replace('/```$/', '', $text) ?? $text; $text = trim($text); } $decoded = json_decode($text, true); if (is_array($decoded)) { return $decoded; } // Fallback: try to extract the first JSON object if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $text, $m) === 1) { $decoded = json_decode($m[0], true); if (is_array($decoded)) { return $decoded; } } return null; } function executeVoiceAction(array $decoded, array $intent): array { $action = (string)($intent['action'] ?? ''); $params = is_array($intent['params'] ?? null) ? $intent['params'] : []; $tenantId = (string)($decoded['tenant_id'] ?? ''); $userId = (string)($decoded['user_id'] ?? ''); $role = (string)($decoded['role'] ?? ''); try { switch ($action) { case 'list_invoices': return executeListInvoices($tenantId, $userId, $role, $params); case 'search_invoice': return executeSearchInvoices($tenantId, $userId, $role, $params); case 'check_quota': if ($tenantId === '') { return [ 'status' => 'failed', 'action' => $action, 'message' => 'لا يمكن جلب تفاصيل الباقة بدون tenant_id', 'data' => null, ]; } return [ 'status' => 'executed', 'action' => $action, 'message' => 'تم جلب استهلاك الباقة', 'data' => QuotaMiddleware::getUsageSummary($tenantId), ]; case 'check_status': return executeCheckStatus($tenantId, $role, $params); case 'get_report': return executeGetReport($tenantId, $role, $params); case 'open_scanner': case 'navigate': case 'export_pdf': return [ 'status' => 'client_action', 'action' => $action, 'message' => 'يتطلب تنفيذ هذا الإجراء من واجهة التطبيق', 'data' => $params, ]; default: return [ 'status' => 'not_supported', 'action' => $action, 'message' => 'الأمر مفهوم لكن غير مدعوم حالياً في التنفيذ المباشر', 'data' => $params, ]; } } catch (\Throwable $e) { error_log("Voice Action Execution Error ({$action}): " . $e->getMessage()); return [ 'status' => 'failed', 'action' => $action, 'message' => 'حدث خطأ أثناء تنفيذ الأمر داخلياً', 'data' => null, ]; } } function executeListInvoices(string $tenantId, string $userId, string $role, array $params): array { $db = Database::getInstance(); $where = []; $bind = []; if ($role !== 'super_admin') { $where[] = 'i.tenant_id = ?'; $bind[] = $tenantId; } // Role scoping for accountant/viewer (assigned companies only) if (in_array($role, ['accountant', 'viewer'], true)) { $stmtAssigned = $db->prepare("SELECT company_id FROM user_company_assignments WHERE user_id = ? AND is_active = 1"); $stmtAssigned->execute([$userId]); $assigned = $stmtAssigned->fetchAll(PDO::FETCH_COLUMN); if (empty($assigned)) { return [ 'status' => 'executed', 'action' => 'list_invoices', 'message' => 'لا توجد شركات مخصصة لك حالياً', 'data' => ['items' => [], 'count' => 0], ]; } $placeholders = implode(',', array_fill(0, count($assigned), '?')); $where[] = "i.company_id IN ({$placeholders})"; foreach ($assigned as $companyId) { $bind[] = $companyId; } } if (!empty($params['status']) && is_string($params['status'])) { $where[] = 'i.status = ?'; $bind[] = trim($params['status']); } if (!empty($params['from']) && is_string($params['from'])) { $where[] = 'i.invoice_date >= ?'; $bind[] = trim($params['from']); } if (!empty($params['to']) && is_string($params['to'])) { $where[] = 'i.invoice_date <= ?'; $bind[] = trim($params['to']); } if (!empty($params['company']) && is_string($params['company'])) { $where[] = '(c.name LIKE ? OR c.name_en LIKE ?)'; $needle = '%' . trim($params['company']) . '%'; $bind[] = $needle; $bind[] = $needle; } $limit = isset($params['limit']) ? (int)$params['limit'] : 20; if ($limit < 1) $limit = 20; if ($limit > 50) $limit = 50; $sql = " SELECT i.id, i.invoice_number, i.invoice_date, i.status, i.grand_total, i.tax_amount, c.name AS company_name FROM invoices i LEFT JOIN companies c ON c.id = i.company_id "; if (!empty($where)) { $sql .= ' WHERE ' . implode(' AND ', $where); } $sql .= ' ORDER BY i.created_at DESC LIMIT ' . $limit; $stmt = $db->prepare($sql); $stmt->execute($bind); $items = $stmt->fetchAll(); foreach ($items as &$row) { $row['company_name'] = decryptIfNeeded((string)($row['company_name'] ?? '')); } return [ 'status' => 'executed', 'action' => 'list_invoices', 'message' => 'تم جلب قائمة الفواتير', 'data' => [ 'items' => $items, 'count' => count($items), ], ]; } function executeSearchInvoices(string $tenantId, string $userId, string $role, array $params): array { // Reuse list logic with extra flexible filters. $filters = [ 'status' => $params['status'] ?? null, 'company' => $params['company'] ?? null, 'from' => $params['from'] ?? null, 'to' => $params['to'] ?? null, 'limit' => $params['limit'] ?? 20, ]; $result = executeListInvoices($tenantId, $userId, $role, $filters); if (($result['status'] ?? '') !== 'executed') { return $result; } $items = $result['data']['items'] ?? []; if (!empty($params['number']) && is_string($params['number'])) { $needle = strtolower(trim($params['number'])); $items = array_values(array_filter($items, static function (array $row) use ($needle): bool { return str_contains(strtolower((string)($row['invoice_number'] ?? '')), $needle); })); } if (isset($params['amount']) && is_numeric($params['amount'])) { $target = (float)$params['amount']; $items = array_values(array_filter($items, static function (array $row) use ($target): bool { $value = (float)($row['grand_total'] ?? 0); return abs($value - $target) <= 0.01; })); } return [ 'status' => 'executed', 'action' => 'search_invoice', 'message' => 'تم تنفيذ البحث عن الفاتورة', 'data' => [ 'items' => $items, 'count' => count($items), ], ]; } function executeCheckStatus(string $tenantId, string $role, array $params): array { $db = Database::getInstance(); $invoiceId = isset($params['invoice_id']) ? trim((string)$params['invoice_id']) : ''; $invoiceNumber = isset($params['invoice_number']) ? trim((string)$params['invoice_number']) : ''; if ($invoiceId === '' && $invoiceNumber === '') { return [ 'status' => 'failed', 'action' => 'check_status', 'message' => 'يرجى تحديد رقم الفاتورة أو معرفها', 'data' => null, ]; } $where = []; $bind = []; if ($invoiceId !== '') { $where[] = 'i.id = ?'; $bind[] = $invoiceId; } if ($invoiceNumber !== '') { $where[] = 'i.invoice_number = ?'; $bind[] = $invoiceNumber; } if ($role !== 'super_admin') { $where[] = 'i.tenant_id = ?'; $bind[] = $tenantId; } $sql = " SELECT i.id, i.invoice_number, i.status, i.invoice_date, i.grand_total, i.jofotara_uuid, c.name AS company_name FROM invoices i LEFT JOIN companies c ON c.id = i.company_id WHERE " . implode(' AND ', $where) . " ORDER BY i.created_at DESC LIMIT 1 "; $stmt = $db->prepare($sql); $stmt->execute($bind); $row = $stmt->fetch(); if (!$row) { return [ 'status' => 'executed', 'action' => 'check_status', 'message' => 'لم يتم العثور على الفاتورة المطلوبة', 'data' => null, ]; } $row['company_name'] = decryptIfNeeded((string)($row['company_name'] ?? '')); return [ 'status' => 'executed', 'action' => 'check_status', 'message' => 'تم جلب حالة الفاتورة', 'data' => $row, ]; } function executeGetReport(string $tenantId, string $role, array $params): array { $db = Database::getInstance(); $type = strtolower(trim((string)($params['type'] ?? 'monthly'))); $period = trim((string)($params['period'] ?? date('Y-m'))); $periodRegex = '/^\d{4}-\d{2}$/'; if (!preg_match($periodRegex, $period)) { $period = date('Y-m'); } $where = "DATE_FORMAT(i.invoice_date, '%Y-%m') = ?"; $bind = [$period]; if ($role !== 'super_admin') { $where .= " AND i.tenant_id = ?"; $bind[] = $tenantId; } if ($type === 'tax') { $sql = " SELECT COUNT(i.id) AS invoices_count, ROUND(COALESCE(SUM(i.tax_amount), 0), 3) AS total_tax, ROUND(COALESCE(SUM(i.grand_total), 0), 3) AS total_with_tax FROM invoices i WHERE {$where} "; } else { $sql = " SELECT COUNT(i.id) AS invoices_count, ROUND(COALESCE(SUM(i.grand_total), 0), 3) AS total_amount, ROUND(COALESCE(SUM(i.tax_amount), 0), 3) AS total_tax, SUM(CASE WHEN i.status = 'approved' THEN 1 ELSE 0 END) AS approved_count FROM invoices i WHERE {$where} "; } $stmt = $db->prepare($sql); $stmt->execute($bind); $summary = $stmt->fetch() ?: []; return [ 'status' => 'executed', 'action' => 'get_report', 'message' => 'تم إنشاء التقرير المختصر', 'data' => [ 'type' => $type, 'period' => $period, 'summary' => $summary, ], ]; } function decryptIfNeeded(string $value): string { if ($value === '') { return ''; } try { $dec = Encryption::decrypt($value); if ($dec !== false && $dec !== null) { return (string)$dec; } } catch (\Throwable $e) { // Keep original value } return $value; }