Update: 2026-05-04 16:06:15

This commit is contained in:
Hamza-Ayed
2026-05-04 16:06:15 +03:00
parent 863dabc069
commit 47652b4d95
5 changed files with 77 additions and 77 deletions

View File

@@ -4,6 +4,7 @@ namespace App\Core;
/** /**
* Gemini AI Integration for Invoice Extraction * Gemini AI Integration for Invoice Extraction
* Optimized for Jordan UBL 2.1 Compliance
*/ */
class AI class AI
{ {
@@ -20,54 +21,44 @@ class AI
return null; return null;
} }
$prompt = "أنت نظام متخصص في استخلاص بيانات الفواتير التجارية. مهمتك واحدة فقط: استخراج البيانات من الفاتورة المرفقة بدقة تامة. $prompt = "أنت نظام خبير في استخراج البيانات الضريبية للفواتير في الأردن.
يجب أن تلتزم بالقواعد التالية بصرامة حسابية مطلقة.
## قواعد صارمة: ### 1. القواعد الحسابية الصارمة (إلزامي):
**اللغة:** يجب أن توازن الفاتورة حسابياً قبل إرجاع النتيجة. المعادلة الأساسية هي:
- إذا كانت الفاتورة بالعربية: أبقِ جميع أسماء السلع والعناوين بالعربية بدون ترجمة `Grand Total = Subtotal - Discount Total + Tax Amount`
- إذا كانت بالإنجليزية: أبقِها بالإنجليزية بدون ترجمة
- الأرقام دائماً بالأرقام اللاتينية (0-9) بغض النظر عن لغة الفاتورة
- المبالغ بـ 3 أرقام عشرية (مثال: 15.000 وليس 15)
**الدقة:** **مثال للتوضيح:**
- لا تخترع أي بيانات غير موجودة في الفاتورة — أعد null إذا لم تجد المعلومة إذا كانت البنود هي:
- تحقق رياضياً: subtotal = مجموع (quantity × unit_price - discount) لكل سطر 1. صنف أ: 12.000 (1 × 12.000)
- تحقق: grand_total = subtotal - discount_total + tax_amount 2. صنف ب: 175.000 (35 × 5.000)
- إذا وجدت تناقضاً بين الأرقام في الفاتورة، سجِّله في حقل \"validation_warnings\" فإن المجموع الفرعي (Subtotal) هو 187.000.
إذا كانت الضريبة 16% على صنف أ فقط، فإن Tax Amount = 1.920.
إذاً الإجمالي (Grand Total) يجب أن يكون 188.920.
**الضريبة:** **تنبيه:** إذا وجدت في الفاتورة رقماً مكتوباً كإجمالي (Grand Total) ولكنه لا يطابق مجموع البنود والضريبة، قم بتصحيح البيانات المستخرجة للبنود لتتوافق مع المجموع الصحيح أو اتبع المجموع الرياضي الأدق. لا تخرج إجمالياً (مثلاً 15.000) بينما مجموع البنود (311.000).
- في الأردن: ضريبة المبيعات العامة (GST) = 16% للسلع العامة
- سلع معفاة من الضريبة: المواد الغذائية الأساسية، الأدوية، الكتب، بعض المعدات الطبية
- سلع بضريبة مخفضة: قد تكون 4% أو 8% — استخرج النسبة الفعلية من الفاتورة
- لكل سطر: حدد tax_rate الفعلي (0 للمعفاة، وإلا النسبة المئوية كعدد عشري مثل 0.16)
## البيانات المطلوبة (JSON فقط، بدون أي نص إضافي): ### 2. قواعد استخراج البيانات:
- **اللغة:** لا تترجم. إذا كان الوصف 'صنف أول' أبقه 'صنف أول'.
- **الأرقام:** استخدم الأرقام اللاتينية (0-9).
- **الدقة:** استخدم 3 أرقام عشرية للمبالغ (مثال: 0.500).
- **الضريبة:** في الأردن، الضريبة العامة هي 16% (0.160). حدد لكل بند النسبة الفعلية.
```json ### 3. هيكل البيانات (JSON فقط):
{ {
\"invoice_number\": \"string | null\", \"invoice_number\": \"string\",
\"invoice_date\": \"YYYY-MM-DD | null\", \"invoice_date\": \"YYYY-MM-DD\",
\"invoice_type\": \"cash | credit\", \"invoice_type\": \"cash | credit\",
\"payment_method_code\": \"013 | 010 | 001\", \"invoice_category\": \"simplified | standard\",
\"supplier\": { \"supplier\": { \"name\": \"string\", \"tin\": \"string\", \"address\": \"string\" },
\"name\": \"string | null\", \"buyer\": { \"name\": \"string\", \"tin\": \"string\", \"national_id\": \"string\" },
\"tin\": \"string | null\",
\"address\": \"string | null\"
},
\"buyer\": {
\"name\": \"string | null\",
\"tin\": \"string | null\",
\"national_id\": \"string | null\"
},
\"lines\": [ \"lines\": [
{ {
\"line_number\": 1, \"line_number\": 1,
\"description\": \"string\", \"description\": \"string\",
\"quantity\": 0.000, \"quantity\": 0.000,
\"unit_price\": 0.000, \"unit_price\": 0.000,
\"discount\": 0.000, \"tax_rate\": 0.160,
\"tax_rate\": 0.16,
\"tax_exempt_reason\": \"string | null\",
\"line_total\": 0.000 \"line_total\": 0.000
} }
], ],
@@ -75,14 +66,10 @@ class AI
\"discount_total\": 0.000, \"discount_total\": 0.000,
\"tax_amount\": 0.000, \"tax_amount\": 0.000,
\"grand_total\": 0.000, \"grand_total\": 0.000,
\"currency_code\": \"JOD\", \"currency_code\": \"JOD\"
\"math_verified\": true,
\"validation_warnings\": [],
\"ai_confidence\": 0.95
} }
```
أعد JSON فقط بدون أي شرح أو مقدمة أو علامات Markdown."; أعد كود JSON فقط بدون أي علامات Markdown أو نصوص إضافية.";
$payload = [ $payload = [
"contents" => [ "contents" => [

View File

@@ -32,7 +32,7 @@ if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
if (!$token) outputErrorImage('Forbidden: No token'); if (!$token) outputErrorImage('Forbidden: No token');
$decoded = \App\Core\JWT::decode($token); $decoded = \App\Core\JWT::decode($token, env('JWT_SECRET', ''));
if (!$decoded) outputErrorImage('Forbidden: Invalid token'); if (!$decoded) outputErrorImage('Forbidden: Invalid token');
$db = Database::getInstance(); $db = Database::getInstance();

View File

@@ -59,17 +59,18 @@ try {
$invoices = $stmt->fetchAll(); $invoices = $stmt->fetchAll();
// 3. Decrypt sensitive fields for display // 3. Decrypt sensitive fields for display (Robustly)
$decrypt = fn($val) => Encryption::decrypt($val ?? '') ?: ($val ?? '-');
foreach ($invoices as &$inv) { foreach ($invoices as &$inv) {
$inv['supplier_name'] = Encryption::decrypt($inv['supplier_name'] ?? '') ?: ($inv['supplier_name'] ?? '-'); $inv['supplier_name'] = $decrypt($inv['supplier_name']);
$inv['supplier_tin'] = Encryption::decrypt($inv['supplier_tin'] ?? '') ?: ($inv['supplier_tin'] ?? '-'); $inv['supplier_tin'] = $decrypt($inv['supplier_tin']);
$inv['buyer_name'] = Encryption::decrypt($inv['buyer_name'] ?? '') ?: ($inv['buyer_name'] ?? '-'); $inv['buyer_name'] = $decrypt($inv['buyer_name']);
if (!empty($inv['company_name'])) { if (!empty($inv['company_name'])) {
$inv['company_name'] = Encryption::decrypt($inv['company_name']) ?: $inv['company_name']; $inv['company_name'] = $decrypt($inv['company_name']);
} }
if (!empty($inv['tenant_name'])) { if (!empty($inv['tenant_name'])) {
$inv['tenant_name'] = Encryption::decrypt($inv['tenant_name']) ?: $inv['tenant_name']; $inv['tenant_name'] = $decrypt($inv['tenant_name']);
} }
} }

View File

@@ -43,16 +43,18 @@ try {
$stmtLines->execute([$id]); $stmtLines->execute([$id]);
$invoice['items'] = $stmtLines->fetchAll(); $invoice['items'] = $stmtLines->fetchAll();
// 5. Decrypt Fields // 5. Decrypt Fields (Robustly)
$invoice['supplier_tin'] = Encryption::decrypt($invoice['supplier_tin'] ?? '') ?: $invoice['supplier_tin']; $decrypt = fn($val) => Encryption::decrypt($val ?? '') ?: $val;
$invoice['supplier_name'] = Encryption::decrypt($invoice['supplier_name'] ?? '') ?: $invoice['supplier_name'];
$invoice['supplier_address'] = Encryption::decrypt($invoice['supplier_address'] ?? '') ?: $invoice['supplier_address']; $invoice['supplier_tin'] = $decrypt($invoice['supplier_tin']);
$invoice['buyer_tin'] = Encryption::decrypt($invoice['buyer_tin'] ?? '') ?: $invoice['buyer_tin']; $invoice['supplier_name'] = $decrypt($invoice['supplier_name']);
$invoice['buyer_name'] = Encryption::decrypt($invoice['buyer_name'] ?? '') ?: $invoice['buyer_name']; $invoice['supplier_address'] = $decrypt($invoice['supplier_address']);
$invoice['buyer_national_id'] = Encryption::decrypt($invoice['buyer_national_id'] ?? '') ?: $invoice['buyer_national_id']; $invoice['buyer_tin'] = $decrypt($invoice['buyer_tin']);
$invoice['buyer_name'] = $decrypt($invoice['buyer_name']);
$invoice['buyer_national_id'] = $decrypt($invoice['buyer_national_id']);
if (!empty($invoice['company_name'])) { if (!empty($invoice['company_name'])) {
$invoice['company_name'] = Encryption::decrypt($invoice['company_name']) ?: $invoice['company_name']; $invoice['company_name'] = $decrypt($invoice['company_name']);
} }
// 6. Generate Public URL for File (Assuming storage is symlinked or served) // 6. Generate Public URL for File (Assuming storage is symlinked or served)

View File

@@ -328,8 +328,11 @@
<div class="p-6 bg-gray-950/50 border-t border-gray-800 flex gap-4"> <div class="p-6 bg-gray-950/50 border-t border-gray-800 flex gap-4">
<template x-if="currentInvoice?.status === 'extracted'"> <template x-if="currentInvoice?.status === 'extracted'">
<button @click="approveInvoice(currentInvoice.id)" class="flex-1 bg-emerald-600 hover:bg-emerald-500 py-3 rounded-xl font-bold transition flex items-center justify-center gap-2"> <button @click="approveInvoice(currentInvoice.id)"
<span> اعتماد الفاتورة وتوليد QR</span> class="flex-1 bg-emerald-600 hover:bg-emerald-500 py-3 rounded-xl font-bold transition flex items-center justify-center gap-2 disabled:opacity-50"
:disabled="isApproving">
<span x-show="!isApproving"> اعتماد الفاتورة وتوليد QR</span>
<span x-show="isApproving">جارِ الإرسال إلى جوفوترة... </span>
</button> </button>
</template> </template>
<template x-if="currentInvoice?.status === 'approved'"> <template x-if="currentInvoice?.status === 'approved'">
@@ -417,6 +420,7 @@
showUploadModal: false, showUploadModal: false,
showViewModal: false, showViewModal: false,
isUploading: false, isUploading: false,
isApproving: false,
globalError: '', globalError: '',
newUser: { name: '', email: '', password: '', role: 'employee', tenant_id: '' }, newUser: { name: '', email: '', password: '', role: 'employee', tenant_id: '' },
@@ -470,7 +474,8 @@
async approveInvoice(id) { async approveInvoice(id) {
if (!confirm('هل أنت متأكد من اعتماد الفاتورة وإرسالها إلى جوفوترة؟')) return; if (!confirm('هل أنت متأكد من اعتماد الفاتورة وإرسالها إلى جوفوترة؟')) return;
this.isApproving = true;
try {
const res = await fetch('/index.php?route=v1/invoices/approve', { const res = await fetch('/index.php?route=v1/invoices/approve', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -482,21 +487,26 @@
const json = await res.json(); const json = await res.json();
if (json.success) { if (json.success) {
alert('تم الاعتماد بنجاح!'); alert('تم الاعتماد بنجاح!');
this.viewInvoice(id); // Reload to show QR this.viewInvoice(id); // Reload to show QR
this.loadInvoices(); this.loadInvoices();
} else { } else {
this.showError(json.message); this.showError(json.message || 'فشل الاتصال بنظام جوفوترة');
}
} catch (e) {
this.showError('خطأ غير متوقع: ' + e.message);
} finally {
this.isApproving = false;
} }
}, },
generateQRPng(base64Tlv) { generateQRPng(base64Tlv) {
if (!base64Tlv) return '';
const qr = new QRious({ const qr = new QRious({
value: base64Tlv, value: base64Tlv,
size: 200, size: 200,
level: 'M' level: 'M'
}); });
// Remove 'data:image/png;base64,' from the return, as the template adds it
return qr.toDataURL().split(',')[1]; return qr.toDataURL().split(',')[1];
}, },
@@ -519,7 +529,7 @@
if (json.success) { if (json.success) {
this.showUploadModal = false; this.showUploadModal = false;
this.loadInvoices(); this.loadInvoices();
this.viewInvoice(json.data.id); // Open view modal immediately! this.viewInvoice(json.data.id);
} else { } else {
this.showError(json.message); this.showError(json.message);
} }