getMessage()); return false; } try { // Fetch the queue item and its batch info $stmt = $db->prepare(" SELECT q.*, b.tenant_id, b.company_id, b.uploaded_by, b.total_images FROM invoice_processing_queue q JOIN invoice_batches b ON q.batch_id = b.id WHERE q.id = ? AND q.status = 'pending' "); $stmt->execute([$queueId]); $item = $stmt->fetch(); if (!$item) { self::log("Queue ID $queueId: Not found or not pending. Skipping."); return false; } $batchId = $item['batch_id']; $tenantId = $item['tenant_id']; $companyId = $item['company_id']; $userId = $item['uploaded_by']; $imagePath = $item['image_path']; self::log("Queue ID $queueId: Image=$imagePath, Batch=$batchId"); // Mark as processing $db->prepare("UPDATE invoice_processing_queue SET status = 'processing' WHERE id = ?")->execute([$queueId]); // Check file exists if (!file_exists($imagePath)) { self::log("Queue ID $queueId: FILE NOT FOUND: $imagePath"); $db->prepare("UPDATE invoice_processing_queue SET status = 'failed', error_message = 'File not found' WHERE id = ?")->execute([$queueId]); return false; } self::log("Queue ID $queueId: File exists (" . filesize($imagePath) . " bytes). Starting AI extraction..."); $mimeType = mime_content_type($imagePath) ?: 'image/jpeg'; $fileContent = file_get_contents($imagePath); $base64Data = base64_encode($fileContent); // AI Extraction (this takes ~5-15 seconds) $extracted = AI::extractInvoiceData($base64Data, $mimeType); if (!$extracted) { self::log("Queue ID $queueId: AI extraction returned NULL (failed)."); $db->prepare("UPDATE invoice_processing_queue SET status = 'failed', error_message = 'AI failed to extract data from image' WHERE id = ?")->execute([$queueId]); return false; } self::log("Queue ID $queueId: AI extraction successful. Saving to DB..."); // Save to database in a transaction $db->beginTransaction(); try { $invoiceId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); $supplierTin = $extracted['supplier']['tin'] ?? ''; $invoiceNum = $extracted['invoice_number'] ?? ''; $invoiceDate = $extracted['invoice_date'] ?? ''; $validDate = (!empty($invoiceDate) && strtotime($invoiceDate)) ? $invoiceDate : null; $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, ai_provider, created_at ) VALUES ( ?, ?, ?, ?, ?, 'extracted', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW() ) "); $rawJsonSnippet = substr(json_encode($extracted, JSON_UNESCAPED_UNICODE), 0, 500); $stmt->execute([ $invoiceId, $tenantId, $companyId, $userId, $imagePath, $invoiceNum, $validDate, $extracted['invoice_type'] ?? 'cash', $extracted['invoice_category'] ?? 'simplified', Encryption::encrypt($supplierTin), Encryption::encrypt($extracted['supplier']['name'] ?? ''), Encryption::encrypt($extracted['supplier']['address'] ?? ''), Encryption::encrypt($extracted['buyer']['tin'] ?? ''), Encryption::encrypt($extracted['buyer']['name'] ?? ''), Encryption::encrypt($extracted['buyer']['national_id'] ?? ''), $extracted['subtotal'] ?? 0, $extracted['tax_amount'] ?? 0, $extracted['discount_total'] ?? 0, $extracted['grand_total'] ?? 0, $extracted['currency_code'] ?? 'JOD', $rawJsonSnippet ]); // Save invoice line items if (!empty($extracted['lines'])) { $lineStmt = $db->prepare("INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total) VALUES (?,?,?,?,?,?,?,?)"); foreach ($extracted['lines'] as $idx => $line) { $lineStmt->execute([ vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)), $invoiceId, $line['line_number'] ?? ($idx + 1), $line['description'] ?? '', $line['quantity'] ?? 1, $line['unit_price'] ?? 0, $line['tax_rate'] ?? 0, $line['line_total'] ?? $line['total_amount'] ?? 0 ]); } self::log("Queue ID $queueId: Saved " . count($extracted['lines']) . " line items."); } // Mark queue item done $db->prepare("UPDATE invoice_processing_queue SET status = 'done', invoice_id = ?, processed_at = NOW() WHERE id = ?")->execute([$invoiceId, $queueId]); // Update batch progress $db->prepare("UPDATE invoice_batches SET processed_images = processed_images + 1 WHERE id = ?")->execute([$batchId]); // Increment quota QuotaMiddleware::incrementInvoiceUsage($tenantId); $db->commit(); self::log("Queue ID $queueId: ✓ Invoice $invoiceId created and committed."); } catch (\Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } self::log("Queue ID $queueId: DB ERROR: " . $e->getMessage()); try { $db->prepare("UPDATE invoice_processing_queue SET status = 'failed', error_message = ? WHERE id = ?")->execute([$e->getMessage(), $queueId]); } catch (\Throwable $e2) {} return false; } // Check if entire batch is complete self::checkBatchCompletion($batchId); // Progress/Completion Push try { $stmt = $db->prepare("SELECT total_images, processed_images, uploaded_by FROM invoice_batches WHERE id = ?"); $stmt->execute([$batchId]); $currentBatch = $stmt->fetch(); if ($currentBatch) { $notifier = new NotificationService(); // Send data notification with invoice_id for auto-navigation $notifier->sendDataNotification($currentBatch['uploaded_by'], [ 'type' => 'invoice_processed', 'batch_id' => $batchId, 'invoice_id' => $invoiceId, 'processed' => $currentBatch['processed_images'], 'total' => $currentBatch['total_images'] ]); } } catch (\Throwable $pushErr) { self::log("Queue ID $queueId: Push notification failed (non-critical): " . $pushErr->getMessage()); } return true; } catch (\Throwable $e) { self::log("Queue ID $queueId: UNHANDLED EXCEPTION: " . $e->getMessage() . "\n" . $e->getTraceAsString()); return false; } } public static function checkBatchCompletion(string $batchId): void { try { $db = Database::getInstance(); $stmt = $db->prepare("SELECT total_images, processed_images, uploaded_by FROM invoice_batches WHERE id = ?"); $stmt->execute([$batchId]); $batch = $stmt->fetch(); if ($batch && $batch['processed_images'] >= $batch['total_images']) { $db->prepare("UPDATE invoice_batches SET status = 'done', completed_at = NOW() WHERE id = ?")->execute([$batchId]); self::log("Batch $batchId: COMPLETE ({$batch['processed_images']}/{$batch['total_images']})"); try { // Try to get the last invoice_id for this batch for completion navigation $invStmt = $db->prepare("SELECT id FROM invoices WHERE original_file_path IN (SELECT image_path FROM invoice_processing_queue WHERE batch_id = ?) ORDER BY created_at DESC LIMIT 1"); $invStmt->execute([$batchId]); $lastInvoiceId = $invStmt->fetchColumn(); $notifier = new NotificationService(); $notifier->sendNotification( $batch['uploaded_by'], "اكتملت معالجة الدفعة", "تمت معالجة جميع الفواتير بنجاح. يمكنك الآن مراجعتها وتدقيقها.", [ 'type' => 'batch_complete', 'batch_id' => $batchId, 'invoice_id' => $lastInvoiceId ?: '' ] ); } catch (\Throwable $e) { self::log("Batch $batchId: Completion notification failed: " . $e->getMessage()); } } } catch (\Throwable $e) { self::log("Batch $batchId: checkBatchCompletion error: " . $e->getMessage()); } } }