Update: 2026-05-04 17:29:56
This commit is contained in:
165
app/Services/InvoiceExtractionService.php
Normal file
165
app/Services/InvoiceExtractionService.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class InvoiceExtractionService
|
||||
{
|
||||
public function buildExtractionPrompt(): string
|
||||
{
|
||||
return <<<'PROMPT'
|
||||
أنت نظام متخصص في استخلاص بيانات الفواتير التجارية الأردنية. مهمتك الوحيدة: استخراج البيانات بدقة تامة وتصنيف الضرائب بشكل صحيح.
|
||||
|
||||
════════════════════════════════════════
|
||||
## قواعد اللغة والأرقام (إلزامية):
|
||||
════════════════════════════════════════
|
||||
- إذا كانت الفاتورة بالعربية: أبقِ أسماء السلع والعناوين بالعربية دون ترجمة
|
||||
- إذا كانت بالإنجليزية: أبقِها بالإنجليزية دون ترجمة
|
||||
- الأرقام دائماً بالأرقام اللاتينية (0-9) بغض النظر عن لغة الفاتورة
|
||||
- المبالغ دائماً بـ 3 أرقام عشرية (مثال: 15.000 وليس 15 أو 15.00)
|
||||
- لا تخترع أي بيانات غير موجودة — أعد null إذا لم تجد المعلومة
|
||||
|
||||
════════════════════════════════════════
|
||||
## التحقق الرياضي (إلزامي):
|
||||
════════════════════════════════════════
|
||||
- line_total = (quantity × unit_price) - discount لكل سطر
|
||||
- subtotal = مجموع كل line_total
|
||||
- tax_amount = مجموع (line_total × tax_rate) لكل سطر
|
||||
- grand_total = subtotal - discount_total + tax_amount
|
||||
- إذا وجدت تناقضاً في الفاتورة بين الأرقام المطبوعة والحسابات: سجِّله في validation_warnings، واستخدم القيم المحسوبة
|
||||
|
||||
════════════════════════════════════════
|
||||
## جدول الضرائب الأردنية (مرجعك الإلزامي):
|
||||
════════════════════════════════════════
|
||||
|
||||
### نسبة 0.16 — الضريبة العامة (16%)
|
||||
تطبق على: جميع السلع والخدمات التي لم يُذكر لها استثناء في الأقسام أدناه.
|
||||
|
||||
### نسبة 0.10 — مخفضة (10%)
|
||||
تطبق على:
|
||||
- الأجبان المحضرة (عدا ما في قائمة 4%)
|
||||
- سجق ومنتجات مماثلة من لحوم أو أحشاء
|
||||
- أسماك الانقليس محضرة أو محفوظة
|
||||
- محضرات وأصناف محفوظة من لحوم أو أحشاء (عدا الخنزير)
|
||||
- حلاوة الطحينة بالسكر (بدون كاكاو)
|
||||
- الطحينة
|
||||
- بذور السمسم
|
||||
- نباتات وأجزاؤها مستعملة في العطور أو الصيدلة
|
||||
- أقلام الحبر الجاف، أقلام الرصاص، أقلام التلوين
|
||||
- مدخلات صناعة الألبان (صناديق، علب، أقفاص)
|
||||
|
||||
### نسبة 0.05 — مخفضة (5%)
|
||||
تطبق على:
|
||||
- العبوات البلاستيكية والعلب المعدنية والكرتونية المستخدمة لتعبئة أنواع محددة من الألبان
|
||||
|
||||
### نسبة 0.04 — مخفضة (4%)
|
||||
تطبق على:
|
||||
- البوتاس، الفوسفات، بعض الأسمدة
|
||||
- القرطاسية
|
||||
- الزي المدرسي وأقمشة الزي المدرسي
|
||||
- مدافئ تعمل بالكاز والغاز
|
||||
- الكرتون لأطباق البيض
|
||||
|
||||
### نسبة 0.02 — مخفضة (2%)
|
||||
تطبق على:
|
||||
- ملفوف طازج أو مبرد
|
||||
- بازلاء طازجة أو مبردة
|
||||
- باميا طازجة أو مبردة
|
||||
- أكياس تغليف التمر على الأشجار قبل الحصاد
|
||||
|
||||
### نسبة 0.00 — صفري (0%) — فئة: "Z" — يُسمح بخصم ضريبة المدخلات
|
||||
تطبق على:
|
||||
- اللحوم (عدا ما في قائمة 10%)
|
||||
- الأسماك (عدا الانقليس)
|
||||
- المحضرات الخاصة لتغذية الأطفال والمعوقين والمحضرات الطبية
|
||||
- أغطية بلاستيك للزراعة (الملش الزراعي)
|
||||
- لوازم شبكات الري (أنابيب، فواصل، أكواع)
|
||||
- صناديق وأقفاص خشبية لتعبئة المنتجات الزراعية
|
||||
- بيض الطيور الطازج لصناعة اللقاحات البيطرية
|
||||
- بصيلات ودرنات وجذور في طور البيات
|
||||
- هياكل البيوت الزراعية من حديد أو صلب
|
||||
- آلات وأدوات البستنة ومحادل الملاعب
|
||||
- نباتات وجذور الهندباء
|
||||
- زيوت النفط الخام والغازات البترولية (عدا زيوت التشحيم)
|
||||
- الأدوية واللقاحات البيطرية
|
||||
- أسمدة NPK، اليوريا، الأمونياك
|
||||
|
||||
### معفاة كلياً — فئة: "E" — لا يُسمح بخصم ضريبة المدخلات
|
||||
تطبق على:
|
||||
- دقيق الحنطة
|
||||
- عدس وحمص يابس والبقوليات
|
||||
- زيت الزيتون غير المعدل كيماوياً
|
||||
- سكر مكرر (عدا سكر القصب)
|
||||
- الشاي الأسود (عبوات ≤ 3 كغ)
|
||||
- الحليب المعبأ (≤ 5 كغ) والحليب المجفف (≤ 3 كغ)
|
||||
- بيض المائدة
|
||||
- خضروات طازجة أو مبردة: بصل، ثوم، خيار، بندورة، بطاطا، فول
|
||||
- أجهزة الهواتف الذكية
|
||||
- الطاقة الكهربائية
|
||||
- النقود الورقية والمعدنية
|
||||
- حافلات نقل 10 أشخاص أو أكثر
|
||||
- سيارات عمرها 5 سنوات فأكثر
|
||||
- السيارات الكهربائية والهجينة
|
||||
|
||||
### ضريبة خاصة — فئة: "O"
|
||||
تطبق على: الإسمنت، التبغ، المشروبات الكحولية، السيارات الجديدة، المحروقات، زيوت التشحيم
|
||||
|
||||
════════════════════════════════════════
|
||||
## قواعد تصنيف الضريبة لكل سطر:
|
||||
════════════════════════════════════════
|
||||
1. ابحث أولاً في قوائم الإعفاء والصفر والنسب المخفضة
|
||||
2. إذا لم تجد السلعة في أي قائمة → نسبة 16% هي الافتراضية
|
||||
3. إذا صرّحت الفاتورة بنسبة مختلفة عن المتوقع → استخدم ما في الفاتورة وسجِّل ملاحظة في validation_warnings
|
||||
4. tax_category: استخدم "S" للخاضعة (16% أو مخفضة)، "Z" للصفري، "E" للمعفاة، "O" للخاصة
|
||||
|
||||
════════════════════════════════════════
|
||||
## تصنيف طريقة الدفع:
|
||||
════════════════════════════════════════
|
||||
- "013" = نقداً (cash, كاش, نقد)
|
||||
- "010" = بطاقة ائتمانية أو مدى (credit card, debit card, بطاقة)
|
||||
- "001" = تحويل بنكي (bank transfer, حوالة بنكية, شيك)
|
||||
- إذا لم تُذكر → افتراضي "013"
|
||||
|
||||
════════════════════════════════════════
|
||||
## البيانات المطلوبة — أعد JSON فقط بدون أي نص:
|
||||
════════════════════════════════════════
|
||||
{
|
||||
"invoice_number": "string | null",
|
||||
"invoice_date": "YYYY-MM-DD | null",
|
||||
"invoice_type": "cash | credit",
|
||||
"payment_method_code": "013 | 010 | 001",
|
||||
"ubl_type_code": "388",
|
||||
"supplier": {
|
||||
"name": "string | null",
|
||||
"tin": "string | null",
|
||||
"address": "string | null"
|
||||
},
|
||||
"buyer": {
|
||||
"name": "string | null",
|
||||
"tin": "string | null",
|
||||
"national_id": "string | null"
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"line_number": 1,
|
||||
"description": "string",
|
||||
"quantity": 1.000,
|
||||
"unit_price": 0.000,
|
||||
"discount": 0.000,
|
||||
"tax_rate": 0.16,
|
||||
"tax_category": "S | Z | E | O",
|
||||
"tax_exempt_reason": "string | null",
|
||||
"line_total": 0.000
|
||||
}
|
||||
],
|
||||
"subtotal": 0.000,
|
||||
"discount_total": 0.000,
|
||||
"tax_amount": 0.000,
|
||||
"grand_total": 0.000,
|
||||
"currency_code": "JOD",
|
||||
"math_verified": true,
|
||||
"validation_warnings": [],
|
||||
"ai_confidence": 0.95
|
||||
}
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Services\InvoiceExtractionService;
|
||||
|
||||
/**
|
||||
* Gemini AI Integration for Invoice Extraction
|
||||
* Optimized for Jordan UBL 2.1 Compliance
|
||||
@@ -21,55 +23,8 @@ class AI
|
||||
return null;
|
||||
}
|
||||
|
||||
$prompt = "أنت نظام خبير في استخراج البيانات الضريبية للفواتير في الأردن.
|
||||
يجب أن تلتزم بالقواعد التالية بصرامة حسابية مطلقة.
|
||||
|
||||
### 1. القواعد الحسابية الصارمة (إلزامي):
|
||||
يجب أن توازن الفاتورة حسابياً قبل إرجاع النتيجة. المعادلة الأساسية هي:
|
||||
`Grand Total = Subtotal - Discount Total + Tax Amount`
|
||||
|
||||
**مثال للتوضيح:**
|
||||
إذا كانت البنود هي:
|
||||
1. صنف أ: 12.000 (1 × 12.000)
|
||||
2. صنف ب: 175.000 (35 × 5.000)
|
||||
فإن المجموع الفرعي (Subtotal) هو 187.000.
|
||||
إذا كانت الضريبة 16% على صنف أ فقط، فإن Tax Amount = 1.920.
|
||||
إذاً الإجمالي (Grand Total) يجب أن يكون 188.920.
|
||||
|
||||
**تنبيه:** إذا وجدت في الفاتورة رقماً مكتوباً كإجمالي (Grand Total) ولكنه لا يطابق مجموع البنود والضريبة، قم بتصحيح البيانات المستخرجة للبنود لتتوافق مع المجموع الصحيح أو اتبع المجموع الرياضي الأدق. لا تخرج إجمالياً (مثلاً 15.000) بينما مجموع البنود (311.000).
|
||||
|
||||
### 2. قواعد استخراج البيانات:
|
||||
- **اللغة:** لا تترجم. إذا كان الوصف 'صنف أول' أبقه 'صنف أول'.
|
||||
- **الأرقام:** استخدم الأرقام اللاتينية (0-9).
|
||||
- **الدقة:** استخدم 3 أرقام عشرية للمبالغ (مثال: 0.500).
|
||||
- **الضريبة:** في الأردن، الضريبة العامة هي 16% (0.160). حدد لكل بند النسبة الفعلية.
|
||||
|
||||
### 3. هيكل البيانات (JSON فقط):
|
||||
{
|
||||
\"invoice_number\": \"string\",
|
||||
\"invoice_date\": \"YYYY-MM-DD\",
|
||||
\"invoice_type\": \"cash | credit\",
|
||||
\"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,
|
||||
\"tax_rate\": 0.160,
|
||||
\"line_total\": 0.000
|
||||
}
|
||||
],
|
||||
\"subtotal\": 0.000,
|
||||
\"discount_total\": 0.000,
|
||||
\"tax_amount\": 0.000,
|
||||
\"grand_total\": 0.000,
|
||||
\"currency_code\": \"JOD\"
|
||||
}
|
||||
|
||||
أعد كود JSON فقط بدون أي علامات Markdown أو نصوص إضافية.";
|
||||
$service = new InvoiceExtractionService();
|
||||
$prompt = $service->buildExtractionPrompt();
|
||||
|
||||
$payload = [
|
||||
"contents" => [
|
||||
|
||||
@@ -43,4 +43,9 @@ final class Database
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function generateUuid(): string
|
||||
{
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,17 @@ final class AuthMiddleware
|
||||
$decoded = JWT::decode($token, $secret);
|
||||
|
||||
if (!$decoded) {
|
||||
json_error('Unauthorized: Invalid or expired token', 401);
|
||||
// Check if it's specifically expired if your JWT class supports it,
|
||||
// otherwise just send the standard 401 with a code.
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'انتهت صلاحية الجلسة',
|
||||
'code' => 'TOKEN_EXPIRED',
|
||||
'redirect'=> '/login.php'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
|
||||
65
app/modules_app/companies/connect_jofotara.php
Normal file
65
app/modules_app/companies/connect_jofotara.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* Link Company to JoFotara API
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\Encryption;
|
||||
use App\Core\JoFotara;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
|
||||
// 1. Auth Check
|
||||
$decoded = AuthMiddleware::check();
|
||||
if (!in_array($decoded['role'], [ 'super_admin'])) {
|
||||
json_error('Unauthorized to modify JoFotara settings', 403);
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$companyId = $data['id'] ?? null;
|
||||
$clientId = $data['client_id'] ?? null;
|
||||
$secretKey = $data['secret_key'] ?? null;
|
||||
$sequence = $data['income_source_sequence'] ?? null;
|
||||
|
||||
if (!$companyId || !$clientId || !$secretKey) {
|
||||
json_error('Company ID, Client ID, and Secret Key are required', 422);
|
||||
}
|
||||
|
||||
$tenantId = $decoded['tenant_id'];
|
||||
|
||||
try {
|
||||
// 2. Validate Company Ownership
|
||||
$stmt = $db->prepare("SELECT id FROM companies WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$companyId, $tenantId]);
|
||||
if (!$stmt->fetch()) json_error('Access denied', 403);
|
||||
|
||||
// 3. Test Connection (Optional but recommended)
|
||||
$jofotara = new JoFotara();
|
||||
// Here you would typically call a health check endpoint if JoFotara provides one,
|
||||
// or just assume the credentials are correct for now.
|
||||
|
||||
// 4. Update Company with Encrypted Credentials
|
||||
$stmtUpdate = $db->prepare("
|
||||
UPDATE companies
|
||||
SET
|
||||
jofotara_client_id_encrypted = ?,
|
||||
jofotara_secret_key_encrypted = ?,
|
||||
jofotara_income_source_sequence = ?,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
|
||||
$stmtUpdate->execute([
|
||||
Encryption::encrypt($clientId),
|
||||
Encryption::encrypt($secretKey),
|
||||
$sequence,
|
||||
$companyId
|
||||
]);
|
||||
|
||||
json_success(null, 'تم ربط الشركة بنظام جوفوترة بنجاح');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
error_log("JoFotara Connection Error: " . $e->getMessage());
|
||||
json_error('فشل في حفظ البيانات: ' . $e->getMessage(), 500);
|
||||
}
|
||||
67
app/modules_app/companies/stats.php
Normal file
67
app/modules_app/companies/stats.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* Company Monthly Stats & JoFotara Status
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
|
||||
// 1. Auth Check
|
||||
$decoded = AuthMiddleware::check();
|
||||
$db = Database::getInstance();
|
||||
|
||||
$companyId = $_GET['id'] ?? null;
|
||||
if (!$companyId) json_error('Company ID is required', 422);
|
||||
|
||||
$tenantId = $decoded['tenant_id'];
|
||||
|
||||
try {
|
||||
// 2. Permission Check
|
||||
$stmt = $db->prepare("SELECT id, name, tax_identification_number, is_active,
|
||||
(jofotara_client_id_encrypted IS NOT NULL) as is_jofotara_connected,
|
||||
jofotara_income_source_sequence
|
||||
FROM companies WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$companyId, $tenantId]);
|
||||
$company = $stmt->fetch();
|
||||
|
||||
if (!$company) json_error('Company not found', 404);
|
||||
|
||||
// 3. Monthly Invoice Stats
|
||||
$stmtStats = $db->prepare("
|
||||
SELECT
|
||||
DATE_FORMAT(invoice_date, '%Y-%m') as month,
|
||||
COUNT(*) as total_invoices,
|
||||
SUM(CASE WHEN status='approved' THEN 1 ELSE 0 END) as approved_count,
|
||||
SUM(grand_total) as total_amount
|
||||
FROM invoices
|
||||
WHERE company_id = ? AND deleted_at IS NULL
|
||||
GROUP BY month
|
||||
ORDER BY month DESC
|
||||
LIMIT 12
|
||||
");
|
||||
$stmtStats->execute([$companyId]);
|
||||
$monthly = $stmtStats->fetchAll();
|
||||
|
||||
// 4. Lifetime Totals
|
||||
$stmtTotals = $db->prepare("
|
||||
SELECT
|
||||
COUNT(*) as total_invoices,
|
||||
SUM(grand_total) as total_amount,
|
||||
SUM(tax_amount) as total_tax,
|
||||
SUM(CASE WHEN status='approved' THEN 1 ELSE 0 END) as approved_count
|
||||
FROM invoices
|
||||
WHERE company_id = ? AND deleted_at IS NULL
|
||||
");
|
||||
$stmtTotals->execute([$companyId]);
|
||||
$totals = $stmtTotals->fetch();
|
||||
|
||||
json_success([
|
||||
'company' => $company,
|
||||
'monthly' => $monthly,
|
||||
'totals' => $totals
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
error_log("Company Stats Error: " . $e->getMessage());
|
||||
json_error('Server error', 500);
|
||||
}
|
||||
44
app/modules_app/invoices/download_xml.php
Normal file
44
app/modules_app/invoices/download_xml.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
/**
|
||||
* Official JoFotara XML Download
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Middleware\AuthMiddleware;
|
||||
|
||||
// 1. Auth Check
|
||||
$decoded = AuthMiddleware::check();
|
||||
$db = Database::getInstance();
|
||||
|
||||
// 2. Validate Request
|
||||
$id = $_GET['id'] ?? null;
|
||||
if (!$id) json_error('Invoice ID is required', 422);
|
||||
|
||||
$tenantId = $decoded['tenant_id'];
|
||||
|
||||
try {
|
||||
// 3. Fetch accepted submission for this invoice
|
||||
$stmt = $db->prepare("
|
||||
SELECT js.xml_payload, js.jofotara_uuid
|
||||
FROM jofotara_submissions js
|
||||
JOIN invoices i ON js.invoice_id = i.id
|
||||
WHERE i.id = ? AND i.tenant_id = ? AND js.status = 'accepted'
|
||||
ORDER BY js.created_at DESC LIMIT 1
|
||||
");
|
||||
$stmt->execute([$id, $tenantId]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row || empty($row['xml_payload'])) {
|
||||
json_error('لا يوجد XML رسمي متاح لهذه الفاتورة', 404);
|
||||
}
|
||||
|
||||
// 4. Send headers for download
|
||||
header('Content-Type: application/xml; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="invoice_' . ($row['jofotara_uuid'] ?: $id) . '.xml"');
|
||||
echo $row['xml_payload'];
|
||||
exit;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
error_log("XML Download Error: " . $e->getMessage());
|
||||
json_error('خطأ في تحميل الملف', 500);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* View Invoice Details Endpoint (with Line Items)
|
||||
* Invoice View Endpoint (Decrypted & JoFotara Aware)
|
||||
*/
|
||||
|
||||
use App\Core\Database;
|
||||
@@ -11,32 +11,24 @@ use App\Middleware\AuthMiddleware;
|
||||
$decoded = AuthMiddleware::check();
|
||||
$db = Database::getInstance();
|
||||
|
||||
$id = input('id');
|
||||
if (!$id) {
|
||||
json_error('Invoice ID is required', 422);
|
||||
}
|
||||
// 2. Validate Request
|
||||
$id = $_GET['id'] ?? null;
|
||||
if (!$id) json_error('Invoice ID is required', 422);
|
||||
|
||||
// 3. Permission Check (Multi-Tenant Isolation)
|
||||
$tenantId = $decoded['tenant_id'];
|
||||
|
||||
try {
|
||||
// 2. Fetch Invoice
|
||||
$stmt = $db->prepare("
|
||||
SELECT i.*, c.name as company_name
|
||||
SELECT i.*, c.name as company_name
|
||||
FROM invoices i
|
||||
LEFT JOIN companies c ON i.company_id = c.id
|
||||
WHERE i.id = ?
|
||||
JOIN companies c ON i.company_id = c.id
|
||||
WHERE i.id = ? AND i.tenant_id = ?
|
||||
");
|
||||
$stmt->execute([$id]);
|
||||
$stmt->execute([$id, $tenantId]);
|
||||
$invoice = $stmt->fetch();
|
||||
|
||||
if (!$invoice) {
|
||||
json_error('Invoice not found', 404);
|
||||
}
|
||||
|
||||
// 3. Authorization Check
|
||||
if ($decoded['role'] !== 'super_admin') {
|
||||
if ($invoice['tenant_id'] !== $decoded['tenant_id']) {
|
||||
json_error('Unauthorized access to this invoice', 403);
|
||||
}
|
||||
}
|
||||
if (!$invoice) json_error('Invoice not found or access denied', 404);
|
||||
|
||||
// 4. Fetch Line Items
|
||||
$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_number ASC");
|
||||
@@ -57,13 +49,30 @@ try {
|
||||
$invoice['company_name'] = $decrypt($invoice['company_name']);
|
||||
}
|
||||
|
||||
// 6. Generate Public URL for File (Assuming storage is symlinked or served)
|
||||
// For now, let's just return the relative path or a proxy route
|
||||
// We'll add a proxy route later if needed.
|
||||
$invoice['file_url'] = '/index.php?route=v1/invoices/file&id=' . $invoice['id'];
|
||||
// 6. Fetch JoFotara Submission Data
|
||||
$stmtSub = $db->prepare("
|
||||
SELECT jofotara_uuid, submitted_at, qr_code_raw, status as submission_status, response_body
|
||||
FROM jofotara_submissions
|
||||
WHERE invoice_id = ? AND status = 'accepted'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
");
|
||||
$stmtSub->execute([$id]);
|
||||
$submission = $stmtSub->fetch();
|
||||
|
||||
$invoice['jofotara'] = $submission ? [
|
||||
'uuid' => $submission['jofotara_uuid'],
|
||||
'submitted_at' => $submission['submitted_at'],
|
||||
'qr_image_uri' => $submission['qr_code_raw'] ? 'data:image/png;base64,' . $submission['qr_code_raw'] : null,
|
||||
'has_xml' => true
|
||||
] : null;
|
||||
|
||||
// 7. Generate Public URL for File
|
||||
$token = Encryption::encrypt($invoice['original_file_path']);
|
||||
$invoice['file_url'] = '/index.php?route=v1/invoices/file&file_token=' . urlencode($token);
|
||||
|
||||
json_success($invoice);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
json_error('Error fetching invoice: ' . $e->getMessage(), 500);
|
||||
error_log("Invoice View Error: " . $e->getMessage());
|
||||
json_error('Server error during invoice retrieval', 500);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ $routes = [
|
||||
'v1/invoices/file' => ['GET', 'invoices/file.php'],
|
||||
'v1/invoices/approve' => ['POST', 'invoices/approve.php'],
|
||||
'v1/invoices/upload' => ['POST', 'invoices/upload.php'],
|
||||
'v1/invoices/download_xml' => ['GET', 'invoices/download_xml.php'],
|
||||
'v1/companies/stats' => ['GET', 'companies/stats.php'],
|
||||
'v1/companies/connect' => ['POST', 'companies/connect_jofotara.php'],
|
||||
'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'],
|
||||
'v1/tenants' => ['GET', 'tenants/index.php'],
|
||||
'v1/tenants/create' => ['POST', 'tenants/create.php'],
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
</td>
|
||||
<td class="p-5 text-sm text-gray-500" x-text="c.address"></td>
|
||||
<td class="p-5 text-xs text-gray-500" x-text="c.tenant_name || '-'"></td>
|
||||
<td class="p-5">
|
||||
<td class="p-5 flex gap-2">
|
||||
<button x-show="user?.role === 'super_admin' || user?.role === 'admin'" @click="confirmDeleteCompany(c)" class="text-gray-500 hover:text-red-500 p-2 rounded-lg hover:bg-red-500/10 transition">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -324,6 +324,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Official JoFotara Submission Display -->
|
||||
<template x-if="currentInvoice?.jofotara">
|
||||
<div class="p-6 bg-emerald-950/20 border border-emerald-500/30 rounded-2xl flex items-center gap-8">
|
||||
<div class="bg-white p-2 rounded-lg shadow-xl">
|
||||
<img :src="currentInvoice.jofotara.qr_image_uri" class="w-32 h-32" alt="QR Code">
|
||||
</div>
|
||||
<div class="space-y-2 flex-1">
|
||||
<h4 class="text-emerald-500 font-bold">✅ فاتورة معتمدة رسمياً</h4>
|
||||
<p class="text-xs text-gray-400">الرقم الموحد (UUID): <span class="font-mono select-all text-gray-200" x-text="currentInvoice.jofotara.uuid"></span></p>
|
||||
<p class="text-xs text-gray-400">تاريخ الرفع: <span x-text="currentInvoice.jofotara.submitted_at"></span></p>
|
||||
<div class="pt-2 flex gap-3">
|
||||
<a :href="'/index.php?route=v1/invoices/download_xml&id=' + currentInvoice.id + '&token=' + token()"
|
||||
class="inline-flex items-center gap-2 text-[10px] bg-emerald-600/20 hover:bg-emerald-600/40 text-emerald-400 px-4 py-2 rounded-lg transition-all border border-emerald-500/30">
|
||||
⬇️ تحميل ملف XML الرسمي
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-gray-950/50 border-t border-gray-800 flex gap-4">
|
||||
@@ -335,14 +355,12 @@
|
||||
<span x-show="isApproving">جارِ الإرسال إلى جوفوترة... ⏳</span>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="currentInvoice?.status === 'approved'">
|
||||
<template x-if="currentInvoice?.status === 'approved' && !currentInvoice.jofotara">
|
||||
<div class="flex-1 flex flex-col items-center justify-center bg-gray-900 rounded-xl p-4 border border-emerald-500/20">
|
||||
<span class="text-xs text-emerald-500 font-bold mb-2">تم الاعتماد لدى جوفوترة</span>
|
||||
<template x-if="currentInvoice?.qr_code">
|
||||
<img :src="'data:image/png;base64,' + generateQRPng(currentInvoice.qr_code)" class="w-24 h-24 rounded bg-white p-1" alt="QR Code">
|
||||
</template>
|
||||
<span class="text-xs text-emerald-500 font-bold mb-2">تم الاعتماد محلياً</span>
|
||||
</div>
|
||||
</template>
|
||||
<button @click="showViewModal = false" class="px-8 py-3 border border-gray-800 rounded-xl hover:bg-gray-800 transition text-sm">إغلاق</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -383,7 +401,7 @@
|
||||
|
||||
<!-- Add User Modal -->
|
||||
<div x-show="showAddModal" x-cloak class="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-surface border border-gray-800 w-full max-w-md p-8 rounded-3xl shadow-2xl glass" @click.away="showAddModal = false">
|
||||
<div class="bg-surface border border-gray-800 w-full max-md p-8 rounded-3xl shadow-2xl glass" @click.away="showAddModal = false">
|
||||
<h3 class="text-xl font-bold mb-6">إضافة مستخدم جديد 👥</h3>
|
||||
<form @submit.prevent="createUser" class="space-y-4">
|
||||
<div><input type="text" x-model="newUser.name" placeholder="الاسم الكامل" class="w-full bg-gray-950 border border-gray-800 p-3 rounded-xl outline-none" required></div>
|
||||
@@ -444,12 +462,34 @@
|
||||
showError(msg) { this.globalError = msg; setTimeout(() => this.globalError = '', 6000); },
|
||||
token() { return localStorage.getItem('access_token'); },
|
||||
|
||||
async apiGet(route) {
|
||||
const res = await fetch('/index.php?route=' + route, { headers: { 'Authorization': 'Bearer ' + this.token() } });
|
||||
async apiRequest(route, method = 'GET', body = null) {
|
||||
const options = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + this.token(),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
if (body) options.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch('/index.php?route=' + route, options);
|
||||
|
||||
// Session Expired Check
|
||||
if (res.status === 401) {
|
||||
const json = await res.json();
|
||||
if (json.code === 'TOKEN_EXPIRED') {
|
||||
localStorage.clear();
|
||||
window.location.href = '/login.php';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.success ? json.data : (this.showError(json.message), null);
|
||||
},
|
||||
|
||||
async apiGet(route) { return this.apiRequest(route); },
|
||||
|
||||
async loadAll() {
|
||||
this.loadStats();
|
||||
this.loadCompanies();
|
||||
@@ -476,22 +516,11 @@
|
||||
if (!confirm('هل أنت متأكد من اعتماد الفاتورة وإرسالها إلى جوفوترة؟')) return;
|
||||
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) {
|
||||
const data = await this.apiRequest('v1/invoices/approve', 'POST', { id: id });
|
||||
if (data) {
|
||||
alert('✅ تم الاعتماد بنجاح!');
|
||||
this.viewInvoice(id); // Reload to show QR
|
||||
this.loadInvoices();
|
||||
} else {
|
||||
this.showError(json.message || 'فشل الاتصال بنظام جوفوترة');
|
||||
}
|
||||
} catch (e) {
|
||||
this.showError('خطأ غير متوقع: ' + e.message);
|
||||
|
||||
@@ -167,7 +167,6 @@ CREATE TABLE invoices (
|
||||
status ENUM('extracted', 'approved', 'rejected') DEFAULT 'extracted',
|
||||
jofotara_uuid VARCHAR(255) NULL,
|
||||
qr_code TEXT NULL,
|
||||
invoice_number VARCHAR(50) NULL,
|
||||
original_file_path TEXT NULL,
|
||||
invoice_category VARCHAR(20) DEFAULT 'simplified',
|
||||
validation_errors JSON NULL,
|
||||
@@ -177,12 +176,37 @@ CREATE TABLE invoices (
|
||||
ai_total_cost DECIMAL(10,6) DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_tenant (tenant_id),
|
||||
INDEX idx_company (company_id),
|
||||
INDEX idx_status (status),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_tenant (tenant_id),
|
||||
INDEX idx_company (company_id),
|
||||
INDEX idx_status (status),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- JoFotara Submissions (Audit Trail)
|
||||
CREATE TABLE jofotara_submissions (
|
||||
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||||
invoice_id CHAR(36) NOT NULL,
|
||||
company_id CHAR(36) NOT NULL,
|
||||
tenant_id CHAR(36) NOT NULL,
|
||||
xml_payload LONGTEXT NULL,
|
||||
xml_hash VARCHAR(64) NULL,
|
||||
jofotara_uuid VARCHAR(255) NULL,
|
||||
qr_code_raw TEXT NULL,
|
||||
response_code VARCHAR(20) NULL,
|
||||
response_body JSON NULL,
|
||||
status ENUM('pending','submitted','accepted','rejected','error') DEFAULT 'pending',
|
||||
error_message TEXT NULL,
|
||||
retry_count TINYINT DEFAULT 0,
|
||||
submitted_at DATETIME NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Invoice Lines
|
||||
CREATE TABLE invoice_lines (
|
||||
|
||||
Reference in New Issue
Block a user