diff --git a/app/Core/AI.php b/app/Core/AI.php index 0f69e47..556e274 100644 --- a/app/Core/AI.php +++ b/app/Core/AI.php @@ -10,7 +10,7 @@ use App\Services\InvoiceExtractionService; */ 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; @@ -25,8 +25,7 @@ class AI return null; } - $service = new InvoiceExtractionService(); - $prompt = $service->buildExtractionPrompt(); + $prompt = AIConfig::getExtractionPrompt(); $payload = [ "contents" => [ diff --git a/app/Core/AIConfig.php b/app/Core/AIConfig.php new file mode 100644 index 0000000..2a7cb12 --- /dev/null +++ b/app/Core/AIConfig.php @@ -0,0 +1,22 @@ +buildExtractionPrompt(); + } +} diff --git a/app/Services/InvoiceExtractionService.php b/app/Services/InvoiceExtractionService.php index b1cf3eb..3dfb78d 100644 --- a/app/Services/InvoiceExtractionService.php +++ b/app/Services/InvoiceExtractionService.php @@ -123,42 +123,46 @@ class InvoiceExtractionService ## البيانات المطلوبة — أعد 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": [ + "invoices": [ { - "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 + "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 } - ], - "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; } diff --git a/app/modules_app/invoices/upload.php b/app/modules_app/invoices/upload.php index ca7d94b..b62aae6 100644 --- a/app/modules_app/invoices/upload.php +++ b/app/modules_app/invoices/upload.php @@ -104,106 +104,125 @@ try { // 6. Save Extracted Data $db->beginTransaction(); - $supplierTin = $extracted['supplier']['tin'] ?? ''; - $invoiceNum = $extracted['invoice_number'] ?? ''; - $invoiceDate = $extracted['invoice_date'] ?? ''; + $extractedInvoices = $extracted['invoices'] ?? [$extracted]; + $savedIds = []; - $invoiceHash = null; - if (!empty($supplierTin) && !empty($invoiceNum) && !empty($invoiceDate)) { - $rawHashString = $companyId . '_' . $supplierTin . '_' . $invoiceNum . '_' . $invoiceDate; - $invoiceHash = hash('sha256', strtolower($rawHashString)); + foreach ($extractedInvoices as $inv) { + $supplierTin = $inv['supplier']['tin'] ?? ''; + $invoiceNum = $inv['invoice_number'] ?? ''; + $invoiceDate = $inv['invoice_date'] ?? ''; - $checkStmt = $db->prepare("SELECT id FROM invoices WHERE company_id = ? AND invoice_hash = ? AND deleted_at IS NULL"); - $checkStmt->execute([$companyId, $invoiceHash]); - if ($checkStmt->fetch()) { - $db->rollBack(); - json_error('هذه الفاتورة تم رفعها مسبقاً لهذه الشركة (رقم الفاتورة مكرر لنفس المورد والتاريخ).', 409); - exit; + $invoiceHash = null; + if (!empty($supplierTin) && !empty($invoiceNum) && !empty($invoiceDate)) { + $rawHashString = $companyId . '_' . $supplierTin . '_' . $invoiceNum . '_' . $invoiceDate; + $invoiceHash = hash('sha256', strtolower($rawHashString)); + + $checkStmt = $db->prepare("SELECT id FROM invoices WHERE company_id = ? AND invoice_hash = ? AND deleted_at IS NULL"); + $checkStmt->execute([$companyId, $invoiceHash]); + if ($checkStmt->fetch()) { + continue; // Skip duplicates in multi-page files + } } - } - $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)); + $validDate = (!empty($invoiceDate) && strtotime($invoiceDate)) ? $invoiceDate : null; + + $subtotal = is_numeric($inv['subtotal'] ?? null) ? $inv['subtotal'] : 0; + $tax = is_numeric($inv['tax_amount'] ?? null) ? $inv['tax_amount'] : 0; + $disc = is_numeric($inv['discount_total'] ?? null) ? $inv['discount_total'] : 0; + $total = is_numeric($inv['grand_total'] ?? null) ? $inv['grand_total'] : 0; - // معالجة القيم الفارغة لمنع انهيار قاعدة البيانات (Strict Mode) - $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; - $disc = is_numeric($extracted['discount_total'] ?? null) ? $extracted['discount_total'] : 0; - $total = is_numeric($extracted['grand_total'] ?? null) ? $extracted['grand_total'] : 0; - - $stmt = $db->prepare(" - INSERT INTO invoices ( - id, 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, - invoice_hash, validation_warnings, - created_at - ) VALUES ( - :id, :tenant_id, :company_id, :uploaded_by, :path, 'extracted', - :num, :date, :type, :cat, :s_tin, :s_name, :s_addr, :b_tin, :b_name, :b_nid, - :sub, :tax, :disc, :total, :cur, - :hash, :warnings, - NOW() - ) - "); - - $stmt->execute([ - 'id' => $invoiceId, - 'tenant_id' => $tenantId, - 'company_id' => $companyId, - 'uploaded_by' => $userId, - 'path' => $targetFile, - 'num' => !empty($invoiceNum) ? $invoiceNum : null, - 'date' => $validDate, - 'type' => !empty($extracted['invoice_type']) ? $extracted['invoice_type'] : 'cash', - 'cat' => !empty($extracted['invoice_category']) ? $extracted['invoice_category'] : 'simplified', - 's_tin' => Encryption::encrypt($supplierTin), - 's_name' => Encryption::encrypt($extracted['supplier']['name'] ?? ''), - 's_addr' => Encryption::encrypt($extracted['supplier']['address'] ?? ''), - 'b_tin' => Encryption::encrypt($extracted['buyer']['tin'] ?? ''), - 'b_name' => Encryption::encrypt($extracted['buyer']['name'] ?? ''), - 'b_nid' => Encryption::encrypt($extracted['buyer']['national_id'] ?? ''), - 'sub' => $subtotal, - 'tax' => $tax, - 'disc' => $disc, - 'total' => $total, - 'cur' => !empty($extracted['currency_code']) ? $extracted['currency_code'] : 'JOD', - 'hash' => $invoiceHash, - 'warnings' => !empty($extracted['validation_warnings']) ? json_encode($extracted['validation_warnings']) : null - ]); - - // Save Line Items - if (!empty($extracted['lines']) && is_array($extracted['lines'])) { - $lineStmt = $db->prepare(" - INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + $stmt = $db->prepare(" + INSERT INTO invoices ( + id, 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, + invoice_hash, validation_warnings, + created_at + ) VALUES ( + :id, :tenant_id, :company_id, :uploaded_by, :path, 'extracted', + :num, :date, :type, :cat, :s_tin, :s_name, :s_addr, :b_tin, :b_name, :b_nid, + :sub, :tax, :disc, :total, :cur, + :hash, :warnings, + NOW() + ) "); - foreach ($extracted['lines'] as $index => $item) { - $lineId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); - $lineStmt->execute([ - $lineId, - $invoiceId, - $item['line_number'] ?? ($index + 1), - $item['description'] ?? 'بدون وصف', - is_numeric($item['quantity'] ?? null) ? $item['quantity'] : 1, - is_numeric($item['unit_price'] ?? null) ? $item['unit_price'] : 0, - is_numeric($item['tax_rate'] ?? null) ? $item['tax_rate'] : 0.16, - is_numeric($item['line_total'] ?? null) ? $item['line_total'] : 0 - ]); + + $stmt->execute([ + 'id' => $invoiceId, + 'tenant_id' => $tenantId, + 'company_id' => $companyId, + 'uploaded_by' => $userId, + 'path' => $targetFile, + 'num' => !empty($invoiceNum) ? $invoiceNum : null, + 'date' => $validDate, + 'type' => !empty($inv['invoice_type']) ? $inv['invoice_type'] : 'cash', + 'cat' => !empty($inv['invoice_category']) ? $inv['invoice_category'] : 'simplified', + 's_tin' => Encryption::encrypt($supplierTin), + 's_name' => Encryption::encrypt($inv['supplier']['name'] ?? ''), + 's_addr' => Encryption::encrypt($inv['supplier']['address'] ?? ''), + 'b_tin' => Encryption::encrypt($inv['buyer']['tin'] ?? ''), + 'b_name' => Encryption::encrypt($inv['buyer']['name'] ?? ''), + 'b_nid' => Encryption::encrypt($inv['buyer']['national_id'] ?? ''), + 'sub' => $subtotal, + 'tax' => $tax, + 'disc' => $disc, + 'total' => $total, + 'cur' => !empty($inv['currency_code']) ? $inv['currency_code'] : 'JOD', + 'hash' => $invoiceHash, + 'warnings' => !empty($inv['validation_warnings']) ? json_encode($inv['validation_warnings']) : null + ]); + + // Save Line Items + if (!empty($inv['lines']) && is_array($inv['lines'])) { + $lineStmt = $db->prepare(" + INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "); + foreach ($inv['lines'] as $index => $item) { + $lineId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); + $lineStmt->execute([ + $lineId, + $invoiceId, + $item['line_number'] ?? ($index + 1), + $item['description'] ?? 'بدون وصف', + is_numeric($item['quantity'] ?? null) ? $item['quantity'] : 1, + is_numeric($item['unit_price'] ?? null) ? $item['unit_price'] : 0, + is_numeric($item['tax_rate'] ?? null) ? $item['tax_rate'] : 0.16, + is_numeric($item['line_total'] ?? null) ? $item['line_total'] : 0 + ]); + } } + + $savedIds[] = $invoiceId; + QuotaMiddleware::incrementInvoiceUsage($tenantId); } $db->commit(); - // --- INCREMENT QUOTA --- - QuotaMiddleware::incrementInvoiceUsage($tenantId); + if (empty($savedIds)) { + json_error('لم يتم حفظ أي فواتير جديدة من هذا الملف (قد تكون مكررة)', 409); + exit; + } + + // --- NOTIFICATIONS & GAMIFICATION (for first invoice only for simplicity) --- \App\Services\SmartNotifications::checkQuotaWarning($tenantId); \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; } catch (\PDOException $e) { diff --git a/public/shell.php b/public/shell.php index d65e547..844b6d7 100644 --- a/public/shell.php +++ b/public/shell.php @@ -2093,13 +2093,18 @@ -
رمز QR الضريبي
- QR Code + +
+ جاري توليد الرمز... +
@@ -2412,15 +2417,27 @@ getQrSrc(inv) { if (!inv) return ''; 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 { const qr = new QRious({ - value: inv.qr_code, - size: 250 + value: qrData, + size: 300, + level: 'M' }); return qr.toDataURL(); - } catch (e) { return ''; } + } catch (e) { + console.error('QR Gen Error:', e); + return ''; + } } return ''; }, diff --git a/scripts/generate_jsonl.php b/scripts/generate_jsonl.php new file mode 100644 index 0000000..d53da6f --- /dev/null +++ b/scripts/generate_jsonl.php @@ -0,0 +1,58 @@ + $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";