Update: 2026-05-09 17:09:49

This commit is contained in:
Hamza-Ayed
2026-05-09 17:09:49 +03:00
parent 47df9253f9
commit 32b9d829eb
6 changed files with 249 additions and 130 deletions

View File

@@ -10,7 +10,7 @@ use App\Services\InvoiceExtractionService;
*/ */
class AI class AI
{ {
private static string $baseUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent"; private static string $baseUrl = "https://generativelanguage.googleapis.com/v1beta/models/" . AIConfig::MODEL_NAME . ":generateContent";
private static int $maxRetries = 3; private static int $maxRetries = 3;
@@ -25,8 +25,7 @@ class AI
return null; return null;
} }
$service = new InvoiceExtractionService(); $prompt = AIConfig::getExtractionPrompt();
$prompt = $service->buildExtractionPrompt();
$payload = [ $payload = [
"contents" => [ "contents" => [

22
app/Core/AIConfig.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace App\Core;
use App\Services\InvoiceExtractionService;
class AIConfig
{
/**
* The model name preferred by the user
*/
public const MODEL_NAME = "gemini-flash-lite-latest";
/**
* Centralized prompt for invoice extraction
*/
public static function getExtractionPrompt(): string
{
$service = new InvoiceExtractionService();
return $service->buildExtractionPrompt();
}
}

View File

@@ -122,6 +122,8 @@ class InvoiceExtractionService
════════════════════════════════════════ ════════════════════════════════════════
## البيانات المطلوبة — أعد JSON فقط بدون أي نص: ## البيانات المطلوبة — أعد JSON فقط بدون أي نص:
════════════════════════════════════════ ════════════════════════════════════════
{
"invoices": [
{ {
"invoice_number": "string | null", "invoice_number": "string | null",
"invoice_date": "YYYY-MM-DD | null", "invoice_date": "YYYY-MM-DD | null",
@@ -160,6 +162,8 @@ class InvoiceExtractionService
"validation_warnings": [], "validation_warnings": [],
"ai_confidence": 0.95 "ai_confidence": 0.95
} }
]
}
PROMPT; PROMPT;
} }
} }

View File

@@ -104,9 +104,13 @@ try {
// 6. Save Extracted Data // 6. Save Extracted Data
$db->beginTransaction(); $db->beginTransaction();
$supplierTin = $extracted['supplier']['tin'] ?? ''; $extractedInvoices = $extracted['invoices'] ?? [$extracted];
$invoiceNum = $extracted['invoice_number'] ?? ''; $savedIds = [];
$invoiceDate = $extracted['invoice_date'] ?? '';
foreach ($extractedInvoices as $inv) {
$supplierTin = $inv['supplier']['tin'] ?? '';
$invoiceNum = $inv['invoice_number'] ?? '';
$invoiceDate = $inv['invoice_date'] ?? '';
$invoiceHash = null; $invoiceHash = null;
if (!empty($supplierTin) && !empty($invoiceNum) && !empty($invoiceDate)) { if (!empty($supplierTin) && !empty($invoiceNum) && !empty($invoiceDate)) {
@@ -116,20 +120,17 @@ try {
$checkStmt = $db->prepare("SELECT id FROM invoices WHERE company_id = ? AND invoice_hash = ? AND deleted_at IS NULL"); $checkStmt = $db->prepare("SELECT id FROM invoices WHERE company_id = ? AND invoice_hash = ? AND deleted_at IS NULL");
$checkStmt->execute([$companyId, $invoiceHash]); $checkStmt->execute([$companyId, $invoiceHash]);
if ($checkStmt->fetch()) { if ($checkStmt->fetch()) {
$db->rollBack(); continue; // Skip duplicates in multi-page files
json_error('هذه الفاتورة تم رفعها مسبقاً لهذه الشركة (رقم الفاتورة مكرر لنفس المورد والتاريخ).', 409);
exit;
} }
} }
$invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); $invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
// معالجة القيم الفارغة لمنع انهيار قاعدة البيانات (Strict Mode)
$validDate = (!empty($invoiceDate) && strtotime($invoiceDate)) ? $invoiceDate : null; $validDate = (!empty($invoiceDate) && strtotime($invoiceDate)) ? $invoiceDate : null;
$subtotal = is_numeric($extracted['subtotal'] ?? null) ? $extracted['subtotal'] : 0;
$tax = is_numeric($extracted['tax_amount'] ?? null) ? $extracted['tax_amount'] : 0; $subtotal = is_numeric($inv['subtotal'] ?? null) ? $inv['subtotal'] : 0;
$disc = is_numeric($extracted['discount_total'] ?? null) ? $extracted['discount_total'] : 0; $tax = is_numeric($inv['tax_amount'] ?? null) ? $inv['tax_amount'] : 0;
$total = is_numeric($extracted['grand_total'] ?? null) ? $extracted['grand_total'] : 0; $disc = is_numeric($inv['discount_total'] ?? null) ? $inv['discount_total'] : 0;
$total = is_numeric($inv['grand_total'] ?? null) ? $inv['grand_total'] : 0;
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO invoices ( INSERT INTO invoices (
@@ -157,30 +158,30 @@ try {
'path' => $targetFile, 'path' => $targetFile,
'num' => !empty($invoiceNum) ? $invoiceNum : null, 'num' => !empty($invoiceNum) ? $invoiceNum : null,
'date' => $validDate, 'date' => $validDate,
'type' => !empty($extracted['invoice_type']) ? $extracted['invoice_type'] : 'cash', 'type' => !empty($inv['invoice_type']) ? $inv['invoice_type'] : 'cash',
'cat' => !empty($extracted['invoice_category']) ? $extracted['invoice_category'] : 'simplified', 'cat' => !empty($inv['invoice_category']) ? $inv['invoice_category'] : 'simplified',
's_tin' => Encryption::encrypt($supplierTin), 's_tin' => Encryption::encrypt($supplierTin),
's_name' => Encryption::encrypt($extracted['supplier']['name'] ?? ''), 's_name' => Encryption::encrypt($inv['supplier']['name'] ?? ''),
's_addr' => Encryption::encrypt($extracted['supplier']['address'] ?? ''), 's_addr' => Encryption::encrypt($inv['supplier']['address'] ?? ''),
'b_tin' => Encryption::encrypt($extracted['buyer']['tin'] ?? ''), 'b_tin' => Encryption::encrypt($inv['buyer']['tin'] ?? ''),
'b_name' => Encryption::encrypt($extracted['buyer']['name'] ?? ''), 'b_name' => Encryption::encrypt($inv['buyer']['name'] ?? ''),
'b_nid' => Encryption::encrypt($extracted['buyer']['national_id'] ?? ''), 'b_nid' => Encryption::encrypt($inv['buyer']['national_id'] ?? ''),
'sub' => $subtotal, 'sub' => $subtotal,
'tax' => $tax, 'tax' => $tax,
'disc' => $disc, 'disc' => $disc,
'total' => $total, 'total' => $total,
'cur' => !empty($extracted['currency_code']) ? $extracted['currency_code'] : 'JOD', 'cur' => !empty($inv['currency_code']) ? $inv['currency_code'] : 'JOD',
'hash' => $invoiceHash, 'hash' => $invoiceHash,
'warnings' => !empty($extracted['validation_warnings']) ? json_encode($extracted['validation_warnings']) : null 'warnings' => !empty($inv['validation_warnings']) ? json_encode($inv['validation_warnings']) : null
]); ]);
// Save Line Items // Save Line Items
if (!empty($extracted['lines']) && is_array($extracted['lines'])) { if (!empty($inv['lines']) && is_array($inv['lines'])) {
$lineStmt = $db->prepare(" $lineStmt = $db->prepare("
INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total) INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"); ");
foreach ($extracted['lines'] as $index => $item) { foreach ($inv['lines'] as $index => $item) {
$lineId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); $lineId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4));
$lineStmt->execute([ $lineStmt->execute([
$lineId, $lineId,
@@ -195,15 +196,33 @@ try {
} }
} }
$savedIds[] = $invoiceId;
QuotaMiddleware::incrementInvoiceUsage($tenantId);
}
$db->commit(); $db->commit();
// --- INCREMENT QUOTA --- if (empty($savedIds)) {
QuotaMiddleware::incrementInvoiceUsage($tenantId); json_error('لم يتم حفظ أي فواتير جديدة من هذا الملف (قد تكون مكررة)', 409);
exit;
}
// --- NOTIFICATIONS & GAMIFICATION (for first invoice only for simplicity) ---
\App\Services\SmartNotifications::checkQuotaWarning($tenantId); \App\Services\SmartNotifications::checkQuotaWarning($tenantId);
\App\Services\GamificationService::award($userId, $tenantId, 'invoice_uploaded'); \App\Services\GamificationService::award($userId, $tenantId, 'invoice_uploaded');
// ----------------------- // -----------------------
json_success(['id' => $invoiceId], 'تم رفع الفاتورة واستخراج البيانات بنجاح'); $response = [
'ids' => $savedIds,
'message' => 'تم استخراج وحفظ ' . count($savedIds) . ' فواتير من الملف بنجاح'
];
// Backward compatibility for Flutter (expecting a single 'id')
if (count($savedIds) === 1) {
$response['id'] = $savedIds[0];
}
json_success($response);
exit; exit;
} catch (\PDOException $e) { } catch (\PDOException $e) {

View File

@@ -2093,13 +2093,18 @@
</div> </div>
<!-- QR Code --> <!-- QR Code -->
<div x-show="currentInvoice?.jofotara?.qr_image_uri || currentInvoice?.qr_code" <div x-show="currentInvoice?.status === 'approved' || currentInvoice?.qr_code || currentInvoice?.jofotara?.qr_image_uri"
style="background:white; border:1px solid var(--border); border-radius:12px; padding:16px; display:flex; flex-direction:column; align-items:center; gap:8px;"> style="background:white; border:1px solid var(--border); border-radius:12px; padding:16px; display:flex; flex-direction:column; align-items:center; gap:8px;">
<div <div
style="font-size:11px; font-weight:700; color:var(--text-3); text-transform:uppercase; letter-spacing:0.07em;"> style="font-size:11px; font-weight:700; color:var(--text-3); text-transform:uppercase; letter-spacing:0.07em;">
رمز QR الضريبي</div> رمز QR الضريبي</div>
<template x-if="getQrSrc(currentInvoice)">
<img :src="getQrSrc(currentInvoice)" style="width:140px; height:140px; object-fit:contain;" <img :src="getQrSrc(currentInvoice)" style="width:140px; height:140px; object-fit:contain;"
alt="QR Code"> alt="QR Code">
</template>
<div x-show="!getQrSrc(currentInvoice)" style="font-size:12px; color:var(--text-3); text-align:center;">
جاري توليد الرمز...
</div>
</div> </div>
</div> </div>
@@ -2412,15 +2417,27 @@
getQrSrc(inv) { getQrSrc(inv) {
if (!inv) return ''; if (!inv) return '';
if (inv.jofotara?.qr_image_uri) return inv.jofotara.qr_image_uri; if (inv.jofotara?.qr_image_uri) return inv.jofotara.qr_image_uri;
if (inv.qr_code) {
if (inv.qr_code.startsWith('data:')) return inv.qr_code; let qrData = inv.qr_code;
// If no QR data in DB but approved, generate a fallback data string
if (!qrData && inv.status === 'approved') {
qrData = `Invoice: ${inv.invoice_number || 'N/A'}\nSupplier: ${inv.supplier_name || 'N/A'}\nTotal: ${inv.grand_total || '0'} JOD\nDate: ${inv.invoice_date || ''}`;
}
if (qrData) {
if (qrData.startsWith('data:')) return qrData;
try { try {
const qr = new QRious({ const qr = new QRious({
value: inv.qr_code, value: qrData,
size: 250 size: 300,
level: 'M'
}); });
return qr.toDataURL(); return qr.toDataURL();
} catch (e) { return ''; } } catch (e) {
console.error('QR Gen Error:', e);
return '';
}
} }
return ''; return '';
}, },

View File

@@ -0,0 +1,58 @@
<?php
/**
* JSONL Generator for Gemini Batch API
* This script scans a directory of images and generates a .jsonl file
* ready to be uploaded for Gemini Batch Processing.
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
$sourceDir = __DIR__ . '/../storage/batch_input';
$outputFile = __DIR__ . '/../storage/batch_requests.jsonl';
$model = \App\Core\AIConfig::MODEL_NAME;
$prompt = \App\Core\AIConfig::getExtractionPrompt();
if (!is_dir($sourceDir)) {
mkdir($sourceDir, 0755, true);
die("Please put your invoice images in: $sourceDir and run again.\n");
}
$files = glob($sourceDir . '/*.{jpg,jpeg,png,pdf}', GLOB_BRACE);
$handle = fopen($outputFile, 'w');
echo "Generating JSONL for " . count($files) . " files...\n";
foreach ($files as $index => $filePath) {
$mimeType = mime_content_type($filePath);
$base64Data = base64_encode(file_get_contents($filePath));
// Build the request object for this line
$request = [
"custom_id" => "inv_" . ($index + 1),
"method" => "POST",
"url" => "/v1/models/$model:generateContent",
"body" => [
"contents" => [
[
"parts" => [
["text" => $prompt],
[
"inline_data" => [
"mime_type" => $mimeType,
"data" => $base64Data
]
]
]
]
],
"generationConfig" => [
"response_mime_type" => "application/json"
]
]
];
fwrite($handle, json_encode($request) . "\n");
}
fclose($handle);
echo "Done! File saved to: $outputFile\n";