Update: 2026-05-04 16:06:15
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Core;
|
||||
|
||||
/**
|
||||
* Gemini AI Integration for Invoice Extraction
|
||||
* Optimized for Jordan UBL 2.1 Compliance
|
||||
*/
|
||||
class AI
|
||||
{
|
||||
@@ -20,54 +21,44 @@ class AI
|
||||
return null;
|
||||
}
|
||||
|
||||
$prompt = "أنت نظام متخصص في استخلاص بيانات الفواتير التجارية. مهمتك واحدة فقط: استخراج البيانات من الفاتورة المرفقة بدقة تامة.
|
||||
$prompt = "أنت نظام خبير في استخراج البيانات الضريبية للفواتير في الأردن.
|
||||
يجب أن تلتزم بالقواعد التالية بصرامة حسابية مطلقة.
|
||||
|
||||
## قواعد صارمة:
|
||||
**اللغة:**
|
||||
- إذا كانت الفاتورة بالعربية: أبقِ جميع أسماء السلع والعناوين بالعربية بدون ترجمة
|
||||
- إذا كانت بالإنجليزية: أبقِها بالإنجليزية بدون ترجمة
|
||||
- الأرقام دائماً بالأرقام اللاتينية (0-9) بغض النظر عن لغة الفاتورة
|
||||
- المبالغ بـ 3 أرقام عشرية (مثال: 15.000 وليس 15)
|
||||
### 1. القواعد الحسابية الصارمة (إلزامي):
|
||||
يجب أن توازن الفاتورة حسابياً قبل إرجاع النتيجة. المعادلة الأساسية هي:
|
||||
`Grand Total = Subtotal - Discount Total + Tax Amount`
|
||||
|
||||
**الدقة:**
|
||||
- لا تخترع أي بيانات غير موجودة في الفاتورة — أعد null إذا لم تجد المعلومة
|
||||
- تحقق رياضياً: subtotal = مجموع (quantity × unit_price - discount) لكل سطر
|
||||
- تحقق: grand_total = subtotal - discount_total + tax_amount
|
||||
- إذا وجدت تناقضاً بين الأرقام في الفاتورة، سجِّله في حقل \"validation_warnings\"
|
||||
**مثال للتوضيح:**
|
||||
إذا كانت البنود هي:
|
||||
1. صنف أ: 12.000 (1 × 12.000)
|
||||
2. صنف ب: 175.000 (35 × 5.000)
|
||||
فإن المجموع الفرعي (Subtotal) هو 187.000.
|
||||
إذا كانت الضريبة 16% على صنف أ فقط، فإن Tax Amount = 1.920.
|
||||
إذاً الإجمالي (Grand Total) يجب أن يكون 188.920.
|
||||
|
||||
**الضريبة:**
|
||||
- في الأردن: ضريبة المبيعات العامة (GST) = 16% للسلع العامة
|
||||
- سلع معفاة من الضريبة: المواد الغذائية الأساسية، الأدوية، الكتب، بعض المعدات الطبية
|
||||
- سلع بضريبة مخفضة: قد تكون 4% أو 8% — استخرج النسبة الفعلية من الفاتورة
|
||||
- لكل سطر: حدد tax_rate الفعلي (0 للمعفاة، وإلا النسبة المئوية كعدد عشري مثل 0.16)
|
||||
**تنبيه:** إذا وجدت في الفاتورة رقماً مكتوباً كإجمالي (Grand Total) ولكنه لا يطابق مجموع البنود والضريبة، قم بتصحيح البيانات المستخرجة للبنود لتتوافق مع المجموع الصحيح أو اتبع المجموع الرياضي الأدق. لا تخرج إجمالياً (مثلاً 15.000) بينما مجموع البنود (311.000).
|
||||
|
||||
## البيانات المطلوبة (JSON فقط، بدون أي نص إضافي):
|
||||
### 2. قواعد استخراج البيانات:
|
||||
- **اللغة:** لا تترجم. إذا كان الوصف 'صنف أول' أبقه 'صنف أول'.
|
||||
- **الأرقام:** استخدم الأرقام اللاتينية (0-9).
|
||||
- **الدقة:** استخدم 3 أرقام عشرية للمبالغ (مثال: 0.500).
|
||||
- **الضريبة:** في الأردن، الضريبة العامة هي 16% (0.160). حدد لكل بند النسبة الفعلية.
|
||||
|
||||
```json
|
||||
### 3. هيكل البيانات (JSON فقط):
|
||||
{
|
||||
\"invoice_number\": \"string | null\",
|
||||
\"invoice_date\": \"YYYY-MM-DD | null\",
|
||||
\"invoice_number\": \"string\",
|
||||
\"invoice_date\": \"YYYY-MM-DD\",
|
||||
\"invoice_type\": \"cash | credit\",
|
||||
\"payment_method_code\": \"013 | 010 | 001\",
|
||||
\"supplier\": {
|
||||
\"name\": \"string | null\",
|
||||
\"tin\": \"string | null\",
|
||||
\"address\": \"string | null\"
|
||||
},
|
||||
\"buyer\": {
|
||||
\"name\": \"string | null\",
|
||||
\"tin\": \"string | null\",
|
||||
\"national_id\": \"string | null\"
|
||||
},
|
||||
\"invoice_category\": \"simplified | standard\",
|
||||
\"supplier\": { \"name\": \"string\", \"tin\": \"string\", \"address\": \"string\" },
|
||||
\"buyer\": { \"name\": \"string\", \"tin\": \"string\", \"national_id\": \"string\" },
|
||||
\"lines\": [
|
||||
{
|
||||
\"line_number\": 1,
|
||||
\"description\": \"string\",
|
||||
\"quantity\": 0.000,
|
||||
\"unit_price\": 0.000,
|
||||
\"discount\": 0.000,
|
||||
\"tax_rate\": 0.16,
|
||||
\"tax_exempt_reason\": \"string | null\",
|
||||
\"tax_rate\": 0.160,
|
||||
\"line_total\": 0.000
|
||||
}
|
||||
],
|
||||
@@ -75,14 +66,10 @@ class AI
|
||||
\"discount_total\": 0.000,
|
||||
\"tax_amount\": 0.000,
|
||||
\"grand_total\": 0.000,
|
||||
\"currency_code\": \"JOD\",
|
||||
\"math_verified\": true,
|
||||
\"validation_warnings\": [],
|
||||
\"ai_confidence\": 0.95
|
||||
\"currency_code\": \"JOD\"
|
||||
}
|
||||
```
|
||||
|
||||
أعد JSON فقط بدون أي شرح أو مقدمة أو علامات Markdown.";
|
||||
أعد كود JSON فقط بدون أي علامات Markdown أو نصوص إضافية.";
|
||||
|
||||
$payload = [
|
||||
"contents" => [
|
||||
|
||||
@@ -32,7 +32,7 @@ if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
|
||||
|
||||
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');
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
@@ -59,17 +59,18 @@ try {
|
||||
|
||||
$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) {
|
||||
$inv['supplier_name'] = Encryption::decrypt($inv['supplier_name'] ?? '') ?: ($inv['supplier_name'] ?? '-');
|
||||
$inv['supplier_tin'] = Encryption::decrypt($inv['supplier_tin'] ?? '') ?: ($inv['supplier_tin'] ?? '-');
|
||||
$inv['buyer_name'] = Encryption::decrypt($inv['buyer_name'] ?? '') ?: ($inv['buyer_name'] ?? '-');
|
||||
$inv['supplier_name'] = $decrypt($inv['supplier_name']);
|
||||
$inv['supplier_tin'] = $decrypt($inv['supplier_tin']);
|
||||
$inv['buyer_name'] = $decrypt($inv['buyer_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'])) {
|
||||
$inv['tenant_name'] = Encryption::decrypt($inv['tenant_name']) ?: $inv['tenant_name'];
|
||||
$inv['tenant_name'] = $decrypt($inv['tenant_name']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,16 +43,18 @@ try {
|
||||
$stmtLines->execute([$id]);
|
||||
$invoice['items'] = $stmtLines->fetchAll();
|
||||
|
||||
// 5. Decrypt Fields
|
||||
$invoice['supplier_tin'] = Encryption::decrypt($invoice['supplier_tin'] ?? '') ?: $invoice['supplier_tin'];
|
||||
$invoice['supplier_name'] = Encryption::decrypt($invoice['supplier_name'] ?? '') ?: $invoice['supplier_name'];
|
||||
$invoice['supplier_address'] = Encryption::decrypt($invoice['supplier_address'] ?? '') ?: $invoice['supplier_address'];
|
||||
$invoice['buyer_tin'] = Encryption::decrypt($invoice['buyer_tin'] ?? '') ?: $invoice['buyer_tin'];
|
||||
$invoice['buyer_name'] = Encryption::decrypt($invoice['buyer_name'] ?? '') ?: $invoice['buyer_name'];
|
||||
$invoice['buyer_national_id'] = Encryption::decrypt($invoice['buyer_national_id'] ?? '') ?: $invoice['buyer_national_id'];
|
||||
// 5. Decrypt Fields (Robustly)
|
||||
$decrypt = fn($val) => Encryption::decrypt($val ?? '') ?: $val;
|
||||
|
||||
$invoice['supplier_tin'] = $decrypt($invoice['supplier_tin']);
|
||||
$invoice['supplier_name'] = $decrypt($invoice['supplier_name']);
|
||||
$invoice['supplier_address'] = $decrypt($invoice['supplier_address']);
|
||||
$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'])) {
|
||||
$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)
|
||||
|
||||
@@ -328,8 +328,11 @@
|
||||
|
||||
<div class="p-6 bg-gray-950/50 border-t border-gray-800 flex gap-4">
|
||||
<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">
|
||||
<span>✅ اعتماد الفاتورة وتوليد QR</span>
|
||||
<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 disabled:opacity-50"
|
||||
:disabled="isApproving">
|
||||
<span x-show="!isApproving">✅ اعتماد الفاتورة وتوليد QR</span>
|
||||
<span x-show="isApproving">جارِ الإرسال إلى جوفوترة... ⏳</span>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="currentInvoice?.status === 'approved'">
|
||||
@@ -417,6 +420,7 @@
|
||||
showUploadModal: false,
|
||||
showViewModal: false,
|
||||
isUploading: false,
|
||||
isApproving: false,
|
||||
globalError: '',
|
||||
|
||||
newUser: { name: '', email: '', password: '', role: 'employee', tenant_id: '' },
|
||||
@@ -470,33 +474,39 @@
|
||||
|
||||
async approveInvoice(id) {
|
||||
if (!confirm('هل أنت متأكد من اعتماد الفاتورة وإرسالها إلى جوفوترة؟')) return;
|
||||
|
||||
const res = await fetch('/index.php?route=v1/invoices/approve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + this.token(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ id: id })
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.success) {
|
||||
alert('تم الاعتماد بنجاح!');
|
||||
this.viewInvoice(id); // Reload to show QR
|
||||
this.loadInvoices();
|
||||
} else {
|
||||
this.showError(json.message);
|
||||
this.isApproving = true;
|
||||
try {
|
||||
const res = await fetch('/index.php?route=v1/invoices/approve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + this.token(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ id: id })
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.success) {
|
||||
alert('✅ تم الاعتماد بنجاح!');
|
||||
this.viewInvoice(id); // Reload to show QR
|
||||
this.loadInvoices();
|
||||
} else {
|
||||
this.showError(json.message || 'فشل الاتصال بنظام جوفوترة');
|
||||
}
|
||||
} catch (e) {
|
||||
this.showError('خطأ غير متوقع: ' + e.message);
|
||||
} finally {
|
||||
this.isApproving = false;
|
||||
}
|
||||
},
|
||||
|
||||
generateQRPng(base64Tlv) {
|
||||
if (!base64Tlv) return '';
|
||||
const qr = new QRious({
|
||||
value: base64Tlv,
|
||||
size: 200,
|
||||
level: 'M'
|
||||
});
|
||||
// Remove 'data:image/png;base64,' from the return, as the template adds it
|
||||
return qr.toDataURL().split(',')[1];
|
||||
},
|
||||
|
||||
@@ -519,7 +529,7 @@
|
||||
if (json.success) {
|
||||
this.showUploadModal = false;
|
||||
this.loadInvoices();
|
||||
this.viewInvoice(json.data.id); // Open view modal immediately!
|
||||
this.viewInvoice(json.data.id);
|
||||
} else {
|
||||
this.showError(json.message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user