diff --git a/app/Modules/Invoices/InvoiceController.php b/app/Modules/Invoices/InvoiceController.php index 5ac7807..4c453b5 100644 --- a/app/Modules/Invoices/InvoiceController.php +++ b/app/Modules/Invoices/InvoiceController.php @@ -6,6 +6,7 @@ namespace App\Modules\Invoices; use App\Core\{Request, Response}; use App\Services\FileStorageService; +use App\Services\AiExtractionService; use App\Modules\Invoices\InvoiceModel; use Throwable; @@ -13,7 +14,8 @@ final class InvoiceController { public function __construct( private readonly InvoiceModel $invoiceModel, - private readonly FileStorageService $storage + private readonly FileStorageService $storage, + private readonly AiExtractionService $aiExtraction ) {} public function list(Request $request): void @@ -45,24 +47,52 @@ final class InvoiceController $fileHash = $this->storage->getHash($filePath); // Create invoice record - $invoiceId = $this->invoiceModel->create([ + $invoiceId = \Ramsey\Uuid\Uuid::uuid4()->toString(); + $this->invoiceModel->create([ + 'id' => $invoiceId, + 'invoice_uuid' => \Ramsey\Uuid\Uuid::uuid4()->toString(), 'tenant_id' => $tenantId, 'company_id' => $companyId, 'uploaded_by' => $request->user->user_id, - 'status' => 'uploaded', + 'status' => 'PROCESSING', 'original_file_path' => $filePath, 'original_file_hash' => $fileHash, 'idempotency_key' => bin2hex(random_bytes(16)) ]); - // TODO: Push to queue for AI extraction - // QueueService::push('extract_invoice', ['invoice_id' => $invoiceId]); + // Attempt AI Extraction + try { + $mimeType = mime_content_type($filePath); + $extractedData = $this->aiExtraction->extractInvoiceData($filePath, $mimeType); + + // Update Invoice with extracted data + $this->invoiceModel->update($invoiceId, [ + 'status' => 'EXTRACTED', + 'extracted_data' => json_encode($extractedData, JSON_UNESCAPED_UNICODE) + ]); + + Response::json([ + 'success' => true, + 'data' => [ + 'invoice_id' => $invoiceId, + 'extracted_data' => $extractedData + ], + 'message' => 'تم رفع الفاتورة واستخراج البيانات بنجاح بالذكاء الاصطناعي' + ]); + + } catch (Throwable $aiError) { + // Keep it uploaded, maybe manual retry later + $this->invoiceModel->update($invoiceId, [ + 'status' => 'AI_FAILED' + ]); + + Response::json([ + 'success' => true, + 'data' => ['invoice_id' => $invoiceId], + 'message' => 'تم الرفع ولكن فشل استخراج البيانات. ' . $aiError->getMessage() + ]); + } - Response::json([ - 'success' => true, - 'data' => ['invoice_id' => $invoiceId], - 'message' => 'تم رفع الفاتورة بنجاح وبدء المعالجة' - ]); } catch (Throwable $e) { Response::error($e->getMessage(), 'UPLOAD_FAILED', 500); } diff --git a/app/Modules/Users/UsersController.php b/app/Modules/Users/UsersController.php new file mode 100644 index 0000000..ea1756a --- /dev/null +++ b/app/Modules/Users/UsersController.php @@ -0,0 +1,71 @@ +tenantId; + $db = Database::getInstance(); + $stmt = $db->prepare("SELECT id, name, email, role, is_active, created_at FROM users WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at DESC"); + $stmt->execute([$tenantId]); + $users = $stmt->fetchAll(); + + Response::json([ + 'success' => true, + 'data' => $users + ]); + } catch (Throwable $e) { + Response::error('Failed to load users: ' . $e->getMessage(), 'USERS_FETCH_ERROR', 500); + } + } + + public function create(Request $request): void + { + $name = $request->input('name'); + $email = $request->input('email'); + $password = $request->input('password'); + $role = $request->input('role', 'accountant'); + + if (!$name || !$email || !$password) { + Response::error('Name, email, and password are required', 'VALIDATION_ERROR', 422); + return; + } + + try { + // Check if email exists + if ($this->userModel->findByEmail($email)) { + Response::error('Email already in use', 'EMAIL_EXISTS', 409); + return; + } + + $userId = \Ramsey\Uuid\Uuid::uuid4()->toString(); + $this->userModel->create([ + 'id' => $userId, + 'tenant_id' => $request->tenantId, + 'name' => $name, + 'email' => $email, + 'password_hash' => password_hash($password, PASSWORD_BCRYPT), + 'role' => $role, + 'is_active' => 1 + ]); + + Response::json([ + 'success' => true, + 'message' => 'User created successfully', + 'data' => ['id' => $userId] + ]); + } catch (Throwable $e) { + Response::error($e->getMessage(), 'USER_CREATE_ERROR', 500); + } + } +} diff --git a/app/Services/AiExtractionService.php b/app/Services/AiExtractionService.php new file mode 100644 index 0000000..8544819 --- /dev/null +++ b/app/Services/AiExtractionService.php @@ -0,0 +1,89 @@ +apiKey = $_ENV['GEMINI_API_KEY'] ?? ''; + $this->model = $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash'; + } + + public function extractInvoiceData(string $filePath, string $mimeType): array + { + if (empty($this->apiKey)) { + throw new Exception("Gemini API Key is missing. Please configure it in .env"); + } + + $fileContent = file_get_contents($filePath); + if ($fileContent === false) { + throw new Exception("Could not read uploaded invoice file."); + } + + $base64Data = base64_encode($fileContent); + + $prompt = "Please extract the following information from this invoice and return it strictly as JSON without markdown blocks or backticks:\n" + . "- invoice_number\n" + . "- invoice_date (YYYY-MM-DD)\n" + . "- total_amount\n" + . "- tax_amount\n" + . "- vendor_name\n" + . "- vendor_tax_number"; + + $payload = [ + 'contents' => [ + [ + 'parts' => [ + ['text' => $prompt], + [ + 'inline_data' => [ + 'mime_type' => $mimeType, + 'data' => $base64Data + ] + ] + ] + ] + ], + 'generationConfig' => [ + 'temperature' => 0.1, + 'response_mime_type' => 'application/json' + ] + ]; + + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}"; + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json' + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + throw new Exception("AI Extraction failed. HTTP Code: {$httpCode}. Response: {$response}"); + } + + $result = json_decode($response, true); + $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? '{}'; + + $data = json_decode($text, true); + if (!is_array($data)) { + throw new Exception("Failed to parse AI output as JSON: {$text}"); + } + + return $data; + } +} diff --git a/app/Services/FileStorageService.php b/app/Services/FileStorageService.php index cc65392..c56d438 100644 --- a/app/Services/FileStorageService.php +++ b/app/Services/FileStorageService.php @@ -22,9 +22,9 @@ final class FileStorageService $mime = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); - $allowedMimes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']; + $allowedMimes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'application/json', 'text/plain', 'text/xml', 'application/xml']; if (!in_array($mime, $allowedMimes)) { - throw new Exception("نوع الملف غير مسموح به"); + throw new Exception("نوع الملف غير مسموح به ({$mime})"); } // 2. Generate path @@ -37,8 +37,15 @@ final class FileStorageService $filename = hash('sha256', $file['name'] . time() . uniqid()) . '.' . $extension; $targetPath = "{$dir}/{$filename}"; + if (isset($file['error']) && $file['error'] !== UPLOAD_ERR_OK) { + throw new Exception("حدث خطأ أثناء رفع الملف من المتصفح. كود الخطأ: " . $file['error']); + } + if (!move_uploaded_file($file['tmp_name'], $targetPath)) { - throw new Exception("فشل رفع الملف"); + // Fallback for some non-standard PHP environments + if (!copy($file['tmp_name'], $targetPath)) { + throw new Exception("فشل نقل الملف إلى المسار النهائي: " . $targetPath); + } } return $targetPath; diff --git a/describe.php b/describe.php new file mode 100644 index 0000000..9cd8b1a --- /dev/null +++ b/describe.php @@ -0,0 +1,7 @@ +load(); +$db = new PDO("mysql:host={$_ENV['DB_HOST']};port={$_ENV['DB_PORT']};dbname={$_ENV['DB_DATABASE']}", $_ENV['DB_USERNAME'], $_ENV['DB_PASSWORD']); +$stmt = $db->query("DESCRIBE invoices"); +print_r($stmt->fetchAll(PDO::FETCH_ASSOC)); diff --git a/public/index.php b/public/index.php index 2789654..f990e7a 100644 --- a/public/index.php +++ b/public/index.php @@ -28,6 +28,16 @@ $router->addRoute('PUT', '/api/v1/companies/{id}/jofotara', [ 'handler' => [\App\Modules\Companies\CompanyController::class, 'updateJoFotara'] ]); +// ══ User Routes ══════════════════════════════════════════════ +$router->addRoute('GET', '/api/v1/users', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Users\UsersController::class, 'list'] +]); +$router->addRoute('POST', '/api/v1/users', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Users\UsersController::class, 'create'] +]); + // ══ Invoice Routes ═══════════════════════════════════════════ $router->addRoute('GET', '/api/v1/invoices', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], diff --git a/public/shell.php b/public/shell.php index e4feccf..68a05ff 100644 --- a/public/shell.php +++ b/public/shell.php @@ -171,25 +171,99 @@ async function renderUsers() { document.getElementById('page-title').textContent = 'إدارة المستخدمين'; try { - // We'll build the API for this later, for now just show a placeholder - contentDiv.innerHTML = ` -
-

لوحة تحكم السوبر يوزر لإدارة المحاسبين والمدراء وربطهم بالشركات.

-
-
- -

قريباً...

-

جاري برمجة واجهات ربط المحاسبين بالشركات وتحديد الصلاحيات الخاصة بهم.

-
+
`; + + if (users.length === 0) { + html += `
لا يوجد مستخدمين مسجلين.
`; + } else { + users.forEach(user => { + const roleColor = user.role === 'admin' ? 'text-primary' : (user.role === 'manager' ? 'text-blue-400' : 'text-slate-400'); + const roleLabel = user.role === 'admin' ? 'سوبر أدمن' : (user.role === 'manager' ? 'مدير' : 'محاسب'); + html += ` +
+
+
+ ${user.name.charAt(0)} +
+
+

${user.name}

+

${user.email}

+
+
+
+
+ الصلاحية + ${roleLabel} +
+
+ الحالة + ${user.is_active ? 'نشط' : 'معطل'} +
+
+
+ `; + }); + } + + html += `
`; + contentDiv.innerHTML = html; } catch(err) { contentDiv.innerHTML = `
خطأ في جلب المستخدمين
`; } } + function showAddUserModal() { + const modals = document.getElementById('modals'); + modals.innerHTML = \` +
+
+

إضافة مستخدم جديد

+
+ + + + + +
+ + +
+
+
+
+ \`; + + document.getElementById('add-user-form').onsubmit = async (e) => { + e.preventDefault(); + try { + const data = { + name: document.getElementById('usr-name').value, + email: document.getElementById('usr-email').value, + password: document.getElementById('usr-password').value, + role: document.getElementById('usr-role').value + }; + await API.post('/users', data); + document.getElementById('user-modal').remove(); + renderUsers(); + } catch(err) { + alert(err.error?.message_ar || err.error?.details?.message || err.message || 'حدث خطأ'); + } + }; + } + // ── Login View ─────────────────────────────────────────── function renderLogin() { document.getElementById('sidebar').classList.add('hidden'); @@ -240,50 +314,104 @@ // ── Dashboard View ─────────────────────────────────────── async function renderDashboard() { - document.getElementById('page-title').textContent = 'لوحة التحكم'; + document.getElementById('page-title').textContent = 'لوحة التحكم السريعة'; try { const res = await API.get('/dashboard'); const stats = res.data; let html = `
-
-

فواتير هذا الشهر

-

${stats.total_this_month}

+
+
+

فواتير هذا الشهر

+ +
+

${stats.total_this_month}

-
-

نسبة الاستخدام

-

${stats.subscription_usage}%

+
+
+

نسبة استهلاك الباقة

+ +
+

${stats.subscription_usage}%

-
- +
+

إجراءات سريعة

+ +
-
-

أحدث الفواتير

+
+

أحدث الفواتير

`; if (stats.recent_invoices.length === 0) { - html += `

لا توجد فواتير بعد

`; + html += `

لا توجد فواتير بعد

`; } else { stats.recent_invoices.forEach(inv => { const statusColor = inv.status === 'APPROVED' ? 'text-primary' : (inv.status === 'REJECTED' ? 'text-red-400' : 'text-yellow-400'); html += ` -
-
-

${inv.invoice_uuid.substring(0,8)}...

-

${inv.company_name}

+
+
+
+ +
+
+

${inv.invoice_uuid.substring(0,8)}...

+

${inv.company_name}

+
- ${inv.status} + ${inv.status}
`; }); } - html += `
`; + html += ` +
+
+
+

حالة الفواتير

+
+ +
+
+
`; contentDiv.innerHTML = html; + + // Render Chart + if (stats.status_distribution && stats.status_distribution.length > 0) { + const ctx = document.getElementById('statusChart').getContext('2d'); + if (currentChart) currentChart.destroy(); + currentChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: stats.status_distribution.map(s => s.status), + datasets: [{ + data: stats.status_distribution.map(s => s.count), + backgroundColor: ['#10b981', '#fbbf24', '#f87171', '#60a5fa'], + borderWidth: 0, + hoverOffset: 4 + }] + }, + options: { + responsive: true, + plugins: { + legend: { position: 'bottom', labels: { color: '#cbd5e1', font: { family: 'system-ui' } } } + }, + cutout: '70%' + } + }); + } + } catch (err) { contentDiv.innerHTML = `
خطأ في جلب الإحصائيات: ${err.error?.message_ar || err.message}
`; }