Update: 2026-05-04 01:33:55

This commit is contained in:
Hamza-Ayed
2026-05-04 01:33:55 +03:00
parent ad48142492
commit 90f2f6f6e3
6 changed files with 251 additions and 42 deletions

103
app/core/AI.php Normal file
View File

@@ -0,0 +1,103 @@
<?php
namespace App\Core;
/**
* Gemini AI Integration for Invoice Extraction
*/
class AI
{
private static string $baseUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent";
/**
* Extract Data from Invoice Image or PDF (Base64)
*/
public static function extractInvoiceData(string $base64Data, string $mimeType): ?array
{
$apiKey = env('GEMINI_API_KEY');
if (!$apiKey) {
error_log('AI Error: GEMINI_API_KEY is missing');
return null;
}
$prompt = "You are an expert in Jordanian E-Invoicing (UBL 2.1).
Extract all data from this invoice image/document into a JSON format.
Strict Rules:
1. Ensure numeric values are numbers, not strings.
2. Identify the Supplier TIN (Tax Identification Number) and Buyer TIN (if present).
3. Identify if the invoice is 'Cash' or 'Credit'.
4. Identify if it is 'Simplified' (B2C) or 'Standard' (B2B).
5. Extract line items precisely.
6. Return ONLY valid JSON, no markdown formatting.
Required JSON Structure:
{
\"invoice_number\": \"\",
\"invoice_date\": \"YYYY-MM-DD\",
\"invoice_type\": \"cash|credit\",
\"invoice_category\": \"simplified|standard\",
\"supplier_tin\": \"\",
\"supplier_name\": \"\",
\"supplier_address\": \"\",
\"buyer_tin\": \"\",
\"buyer_name\": \"\",
\"buyer_national_id\": \"\",
\"subtotal\": 0.000,
\"tax_amount\": 0.000,
\"discount_total\": 0.000,
\"grand_total\": 0.000,
\"currency\": \"JOD\",
\"items\": [
{
\"description\": \"\",
\"quantity\": 0,
\"unit_price\": 0.000,
\"tax_amount\": 0.000,
\"total\": 0.000
}
]
}";
$payload = [
"contents" => [
[
"parts" => [
["text" => $prompt],
[
"inline_data" => [
"mime_type" => $mimeType,
"data" => $base64Data
]
]
]
]
],
"generationConfig" => [
"response_mime_type" => "application/json"
]
];
$ch = curl_init(self::$baseUrl . "?key=" . $apiKey);
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) {
error_log("AI Error: Gemini API returned code $httpCode. Response: " . $response);
return null;
}
$result = json_decode($response, true);
$textResponse = $result['candidates'][0]['content']['parts'][0]['text'] ?? null;
if (!$textResponse) return null;
return json_decode($textResponse, true);
}
}

View File

@@ -15,58 +15,107 @@ if (!in_array($decoded['role'], $allowedRoles)) {
json_error('Unauthorized to upload invoices', 403);
}
// 2. Validate Request
$companyId = $_POST['company_id'] ?? null;
if (!$companyId || !isset($_FILES['invoice'])) {
json_error('Company ID and invoice file are required', 422);
}
// 3. Permission Check (Can this user upload to this company?)
// 3. Permission Check
$tenantId = $decoded['tenant_id'];
$userId = $decoded['user_id'];
if ($decoded['role'] === 'admin') {
$stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$companyId, $tenantId]);
} elseif ($decoded['role'] === 'accountant') {
$stmt = $db->prepare("
SELECT c.id FROM companies c
JOIN user_company_assignments uca ON c.id = uca.company_id
WHERE c.id = ? AND uca.user_id = ? AND uca.is_active = 1
");
$stmt->execute([$companyId, $userId]);
} else { // employee
// In our schema, employee is linked via users.company_id
$stmt = $db->prepare("SELECT id FROM users WHERE id = ? AND company_id = ?");
// Everyone (except Super Admin who shouldn't upload here) must belong to the tenant
// And if they are NOT an admin, they must be assigned to this company
if ($decoded['role'] !== 'admin' && $decoded['role'] !== 'super_admin') {
$stmt = $db->prepare("SELECT id FROM user_company_assignments WHERE user_id = ? AND company_id = ? AND is_active = 1");
$stmt->execute([$userId, $companyId]);
}
if (!$stmt->fetch()) {
if (!$stmt->fetch()) {
json_error('Access denied to this company', 403);
}
}
// 4. Handle File Upload (Mock logic for now, using storage/invoices)
$uploadDir = __DIR__ . '/../../../storage/invoices/' . $tenantId . '/' . $companyId . '/';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
// 4. Handle File Upload
$dateFolder = date('Y-m-d');
$uploadDir = STORAGE_PATH . '/invoices/' . $tenantId . '/' . $companyId . '/' . $dateFolder . '/';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0775, true);
$fileName = time() . '_' . basename($_FILES['invoice']['name']);
$extension = pathinfo($_FILES['invoice']['name'], PATHINFO_EXTENSION);
$fileName = bin2hex(random_bytes(8)) . '_' . time() . '.' . $extension;
$targetFile = $uploadDir . $fileName;
if (move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) {
// 5. Save to DB
// 5. Run AI Extraction
$mimeType = $_FILES['invoice']['type'];
$base64Data = base64_encode(file_get_contents($targetFile));
$extracted = \App\Core\AI::extractInvoiceData($base64Data, $mimeType);
if (!$extracted) {
// Still save basic record if AI fails
$stmt = $db->prepare("INSERT INTO invoices (tenant_id, company_id, uploaded_by, original_file_path, status, created_at) VALUES (?, ?, ?, ?, 'uploaded', NOW())");
$stmt->execute([$tenantId, $companyId, $userId, $targetFile]);
json_success(['id' => $db->lastInsertId()], 'تم رفع الفاتورة ولكن فشل استخراج البيانات تلقائياً');
}
// 6. Save Extracted Data with Encryption
try {
$db->beginTransaction();
$stmt = $db->prepare("
INSERT INTO invoices (
tenant_id, company_id, status, uploaded_by, original_file_path, created_at
) VALUES (?, ?, 'uploaded', ?, ?, NOW())
tenant_id, company_id, uploaded_by, original_file_path, status,
invoice_number, invoice_date, invoice_type, invoice_category,
supplier_tin, supplier_name, supplier_address,
buyer_tin, buyer_name, buyer_national_id,
subtotal, tax_amount, discount_total, grand_total, currency_code,
created_at
) VALUES (?, ?, ?, ?, 'extracted', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
");
$stmt->execute([
$tenantId,
$companyId,
$userId,
$targetFile
$tenantId, $companyId, $userId, $targetFile,
$extracted['invoice_number'] ?? null,
$extracted['invoice_date'] ?? null,
$extracted['invoice_type'] ?? 'cash',
$extracted['invoice_category'] ?? 'simplified',
// Encrypt sensitive details
\App\Core\Encryption::encrypt($extracted['supplier_tin'] ?? ''),
\App\Core\Encryption::encrypt($extracted['supplier_name'] ?? ''),
\App\Core\Encryption::encrypt($extracted['supplier_address'] ?? ''),
\App\Core\Encryption::encrypt($extracted['buyer_tin'] ?? ''),
\App\Core\Encryption::encrypt($extracted['buyer_name'] ?? ''),
\App\Core\Encryption::encrypt($extracted['buyer_national_id'] ?? ''),
$extracted['subtotal'] ?? 0,
$extracted['tax_amount'] ?? 0,
$extracted['discount_total'] ?? 0,
$extracted['grand_total'] ?? 0,
$extracted['currency'] ?? 'JOD'
]);
json_success(['id' => $db->lastInsertId()], 'تم رفع الفاتورة بنجاح وبدأت عملية المعالجة');
$invoiceId = $db->lastInsertId();
// Save Line Items (No encryption for lines for now, usually not sensitive but can be)
if (!empty($extracted['items'])) {
$lineStmt = $db->prepare("
INSERT INTO invoice_lines (invoice_id, description, quantity, unit_price, tax_amount, total)
VALUES (?, ?, ?, ?, ?, ?)
");
foreach ($extracted['items'] as $item) {
$lineStmt->execute([
$invoiceId,
$item['description'] ?? 'N/A',
$item['quantity'] ?? 1,
$item['unit_price'] ?? 0,
$item['tax_amount'] ?? 0,
$item['total'] ?? 0
]);
}
}
$db->commit();
json_success(['id' => $invoiceId], 'تم رفع الفاتورة واستخراج البيانات بنجاح');
} catch (\Exception $e) {
$db->rollBack();
error_log("DB Error during invoice save: " . $e->getMessage());
json_error('حدث خطأ أثناء حفظ بيانات الفاتورة', 500);
}
} else {
json_error('Failed to save uploaded file', 500);
}

View File

@@ -19,7 +19,7 @@ $data = input();
// 1. Role Authorization check (Prevent Role Escalation)
$allowedRoles = match($decoded['role']) {
'super_admin' => ['super_admin', 'admin', 'accountant', 'employee', 'viewer'],
'admin' => ['accountant', 'employee', 'viewer'],
'admin' => ['accountant', 'employee', 'viewer'], // Cannot create other admins
default => []
};

View File

@@ -0,0 +1,55 @@
<?php
/**
* Delete User Endpoint (Soft Delete)
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
// 1. Auth Check
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$currentUserId = $decoded['user_id'];
$currentUserRole = $decoded['role'];
$targetUserId = input('id');
if (!$targetUserId) {
json_error('User ID is required', 422);
}
// 2. Prevent self-deletion
if ($currentUserId === $targetUserId) {
json_error('لا يمكنك حذف حسابك الشخصي من هنا', 403);
}
// 3. Fetch target user to check permissions
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$targetUserId]);
$targetUser = $stmt->fetch();
if (!$targetUser) {
json_error('المستخدم غير موجود', 404);
}
// 4. Role-based Authorization
if ($currentUserRole === 'super_admin') {
// Super Admin can delete anyone except themselves
} elseif ($currentUserRole === 'admin') {
// Admin can only delete users in THEIR tenant
if ($targetUser['tenant_id'] !== $decoded['tenant_id']) {
json_error('ليس لديك صلاحية لحذف هذا المستخدم', 403);
}
// Admin cannot delete other admins (only super_admin can)
if ($targetUser['role'] === 'admin' || $targetUser['role'] === 'super_admin') {
json_error('لا يمكنك حذف مدير آخر. فقط السوبر أدمن يمكنه ذلك.', 403);
}
} else {
json_error('غير مصرح لك بحذف المستخدمين', 403);
}
// 5. Perform Soft Delete
$stmt = $db->prepare("UPDATE users SET deleted_at = NOW(), is_active = 0 WHERE id = ?");
$stmt->execute([$targetUserId]);
json_success(null, 'تم حذف المستخدم بنجاح');

View File

@@ -21,6 +21,7 @@ $routes = [
'v1/auth/logout' => ['POST', 'auth/logout.php'],
'v1/users' => ['GET', 'users/index.php'],
'v1/users/create' => ['POST', 'users/create.php'],
'v1/users/delete' => ['POST', 'users/delete.php'],
'v1/companies' => ['GET', 'companies/index.php'],
'v1/companies/create' => ['POST', 'companies/create.php'],
'v1/invoices/upload' => ['POST', 'invoices/upload.php'],

View File

@@ -107,6 +107,7 @@ CREATE TABLE invoices (
grand_total DECIMAL(15,3) DEFAULT 0,
currency_code CHAR(3) DEFAULT 'JOD',
status ENUM('uploaded','extracting','extracted','validated','validation_failed','submitting','approved','rejected') DEFAULT 'uploaded',
uploaded_by CHAR(36) NULL,
original_file_path TEXT NULL,
invoice_category VARCHAR(20) DEFAULT 'simplified',
validation_errors JSON NULL,