prepare(" UPDATE invoice_processing_queue SET status = 'processing', attempts = attempts + 1, claimed_at = NOW(), error_message = NULL WHERE id = ? AND status = 'pending' AND attempts < COALESCE(max_attempts, 3) "); $claim->execute([$queueId]); if ($claim->rowCount() !== 1) { return null; } $stmt = $db->prepare(" SELECT q.*, b.tenant_id AS batch_tenant_id, b.company_id AS batch_company_id, b.uploaded_by, b.total_images, b.status AS batch_status FROM invoice_processing_queue q JOIN invoice_batches b ON q.batch_id = b.id WHERE q.id = ? "); $stmt->execute([$queueId]); $item = $stmt->fetch(); return $item ?: null; } /** * Release a claimed item back to the queue for a later retry, or bury it as * permanently failed once max_attempts is exhausted. * * Only a permanent failure counts towards the batch's failed_images, so a * transient AI hiccup does not prematurely "complete" a batch. */ private static function failQueueItem(\PDO $db, array $item, string $reason): void { $queueId = (int)$item['id']; $attempts = (int)($item['attempts'] ?? 1); $maxAttempts = (int)($item['max_attempts'] ?? 3); $batchId = (string)$item['batch_id']; if ($attempts < $maxAttempts) { $db->prepare(" UPDATE invoice_processing_queue SET status = 'pending', error_message = ? WHERE id = ? ")->execute([$reason, $queueId]); self::log("Queue ID $queueId: will retry ($attempts/$maxAttempts) - $reason"); return; } $db->prepare(" UPDATE invoice_processing_queue SET status = 'failed', error_message = ?, processed_at = NOW() WHERE id = ? ")->execute([$reason, $queueId]); $db->prepare(" UPDATE invoice_batches SET failed_images = failed_images + 1 WHERE id = ? ")->execute([$batchId]); self::log("Queue ID $queueId: PERMANENTLY FAILED after $attempts attempt(s) - $reason"); self::checkBatchCompletion($batchId); } /** * Processes a single invoice queue item by its ID. */ public static function processQueueItem(int $queueId): bool { self::log("Starting processQueueItem($queueId)"); try { $db = Database::getInstance(); } catch (\Throwable $e) { self::log("FATAL: Cannot connect to DB: " . $e->getMessage()); return false; } $item = null; // Once the invoice is committed the item must never be released back to // 'pending', or a later worker would re-insert the same invoice. $committed = false; try { $item = self::claimQueueItem($db, $queueId); if (!$item) { self::log("Queue ID $queueId: not claimable (already taken, not pending, or attempts exhausted). Skipping."); return false; } $batchId = (string)$item['batch_id']; $tenantId = (string)$item['batch_tenant_id']; $companyId = (string)$item['batch_company_id']; $userId = $item['uploaded_by']; $imagePath = (string)$item['image_path']; self::log("Queue ID $queueId: Image=$imagePath, Batch=$batchId"); // Check file exists. A missing file will never appear, so bury it // immediately rather than burning the remaining retries. if (!file_exists($imagePath)) { self::log("Queue ID $queueId: FILE NOT FOUND: $imagePath"); $item['attempts'] = $item['max_attempts'] ?? 3; self::failQueueItem($db, $item, 'File not found'); return false; } // Refuse to spend AI credit the tenant no longer has. if (!QuotaMiddleware::hasInvoiceQuota($tenantId)) { self::log("Queue ID $queueId: tenant $tenantId is out of invoice quota."); $item['attempts'] = $item['max_attempts'] ?? 3; self::failQueueItem($db, $item, 'تم استنفاد رصيد الفواتير لهذا الشهر'); 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) AI::setTenantContext($tenantId); $extractedInvoices = AI::extractInvoices($base64Data, $mimeType); AI::setTenantContext(null); if (empty($extractedInvoices)) { self::log("Queue ID $queueId: AI extraction returned nothing."); self::failQueueItem($db, $item, 'AI failed to extract data from image'); return false; } self::log("Queue ID $queueId: AI extracted " . count($extractedInvoices) . " invoice(s). Saving to DB..."); $createdInvoiceIds = []; // Save to database in a transaction $db->beginTransaction(); try { foreach ($extractedInvoices as $extracted) { $createdInvoiceIds[] = self::insertInvoice( $db, $extracted, $batchId, $tenantId, $companyId, $userId, $imagePath ); } $primaryInvoiceId = $createdInvoiceIds[0]; // Mark queue item done $db->prepare("UPDATE invoice_processing_queue SET status = 'done', invoice_id = ?, error_message = NULL, processed_at = NOW() WHERE id = ?") ->execute([$primaryInvoiceId, $queueId]); // Update batch progress $db->prepare("UPDATE invoice_batches SET processed_images = processed_images + 1 WHERE id = ?") ->execute([$batchId]); $db->commit(); $committed = true; self::log("Queue ID $queueId: OK - invoice(s) " . implode(', ', $createdInvoiceIds) . " committed."); } catch (\Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } self::log("Queue ID $queueId: DB ERROR: " . $e->getMessage()); self::failQueueItem($db, $item, $e->getMessage()); return false; } // Charge quota only for invoices that are actually committed. This runs // outside the transaction so a quota-write failure cannot roll back // (or be rolled back by) the invoice itself. // // Wrapped separately: letting this bubble to the outer catch would // release an ALREADY-COMMITTED item back to 'pending' and the next // worker would insert the same invoice a second time. try { foreach ($createdInvoiceIds as $_) { QuotaMiddleware::incrementInvoiceUsage($tenantId); } } catch (\Throwable $quotaErr) { self::log("Queue ID $queueId: quota increment failed (invoice already saved): " . $quotaErr->getMessage()); } // Check if entire batch is complete self::checkBatchCompletion($batchId); // Progress push (silent data message + Live Activity update) try { $stmt = $db->prepare("SELECT total_images, processed_images, failed_images, status, uploaded_by FROM invoice_batches WHERE id = ?"); $stmt->execute([$batchId]); $currentBatch = $stmt->fetch(); if ($currentBatch) { $notifier = new NotificationService(); // 'batch_progress' is the type NotificationService recognises for // iOS Live Activity / Android Live Update payloads. $notifier->sendDataNotification($currentBatch['uploaded_by'], [ 'type' => 'batch_progress', 'batch_id' => $batchId, 'invoice_id' => $createdInvoiceIds[0], 'processed' => $currentBatch['processed_images'], 'failed' => $currentBatch['failed_images'], 'total' => $currentBatch['total_images'], 'is_done' => in_array($currentBatch['status'], ['done', 'partial_fail', 'failed'], true) ? 1 : 0, ]); } } 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()); // Never leave a claimed row stuck in 'processing' - unless the invoice // was already committed, in which case retrying would duplicate it. if ($item !== null && !$committed) { try { self::failQueueItem($db, $item, $e->getMessage()); } catch (\Throwable $e2) { self::log("Queue ID $queueId: could not release item: " . $e2->getMessage()); } } return false; } } /** * Insert one extracted invoice (plus its line items) and return its id. */ private static function insertInvoice( \PDO $db, array $extracted, string $batchId, string $tenantId, string $companyId, ?string $userId, string $imagePath ): string { $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, batch_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, created_at ) VALUES ( ?, ?, ?, ?, ?, ?, 'extracted', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW() ) "); $stmt->execute([ $invoiceId, $tenantId, $companyId, $batchId, $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' ]); // 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, tax_amount, discount_amount, net_total, line_total, tax_category ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) "); foreach ($extracted['lines'] as $idx => $line) { $quantity = (float)($line['quantity'] ?? 1); $unitPrice = (float)($line['unit_price'] ?? 0); $taxRate = (float)($line['tax_rate'] ?? 0); $discount = (float)($line['discount'] ?? $line['discount_amount'] ?? 0); $subtotal = $quantity * $unitPrice; $taxAmount = (float)($line['tax_amount'] ?? ($subtotal * $taxRate)); $netTotal = (float)($line['net_total'] ?? ($line['line_total'] ?? ($subtotal + $taxAmount - $discount))); $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'] ?? '', $quantity, $unitPrice, $taxRate, $taxAmount, $discount, $netTotal, $netTotal, // line_total $line['tax_category'] ?? 'standard' ]); } self::log("Invoice $invoiceId: saved " . count($extracted['lines']) . " line items."); } return $invoiceId; } /** * Close a batch once every image has reached a terminal state. * * Two rules matter here: * 1. A batch still in 'uploading' is NEVER completed. total_images grows as * each image arrives, so a worker that processed the first image would * otherwise see 1 >= 1 and lock the batch while the user is still * uploading - after which upload-image and finalize both reject with a * misleading "already processed" error. * 2. Permanent failures count towards completion, otherwise one bad photo * leaves the batch (and the app's progress bar) hanging forever. */ public static function checkBatchCompletion(string $batchId): void { try { $db = Database::getInstance(); $stmt = $db->prepare(" SELECT total_images, processed_images, failed_images, status, uploaded_by FROM invoice_batches WHERE id = ? "); $stmt->execute([$batchId]); $batch = $stmt->fetch(); if (!$batch) { return; } // Rule 1: the user is still uploading - nothing to close yet. if ($batch['status'] === 'uploading') { self::log("Batch $batchId: still uploading, completion check deferred."); return; } // Already terminal. if (in_array($batch['status'], ['done', 'partial_fail', 'failed'], true)) { return; } $total = (int)$batch['total_images']; $processed = (int)$batch['processed_images']; $failed = (int)$batch['failed_images']; // Rule 2: terminal means done OR permanently failed. if ($total <= 0 || ($processed + $failed) < $total) { return; } if ($failed === 0) { $finalStatus = 'done'; } elseif ($processed === 0) { $finalStatus = 'failed'; } else { $finalStatus = 'partial_fail'; } $db->prepare("UPDATE invoice_batches SET status = ?, completed_at = NOW() WHERE id = ?") ->execute([$finalStatus, $batchId]); self::log("Batch $batchId: $finalStatus ($processed ok / $failed failed / $total total)"); try { // invoices.batch_id is populated now, so this is a direct lookup. $invStmt = $db->prepare("SELECT id FROM invoices WHERE batch_id = ? ORDER BY created_at DESC LIMIT 1"); $invStmt->execute([$batchId]); $lastInvoiceId = $invStmt->fetchColumn(); [$title, $body] = match ($finalStatus) { 'done' => [ 'اكتملت معالجة الدفعة', 'تمت معالجة جميع الفواتير بنجاح. يمكنك الآن مراجعتها وتدقيقها.', ], 'partial_fail' => [ 'اكتملت المعالجة مع أخطاء', "تمت معالجة {$processed} فاتورة بنجاح، وفشلت {$failed}. يمكنك إعادة تصوير الفواتير الفاشلة.", ], default => [ 'فشلت معالجة الدفعة', "لم نتمكن من استخراج البيانات من {$failed} فاتورة. يرجى إعادة التصوير بإضاءة أفضل.", ], }; $notifier = new NotificationService(); $notifier->sendNotification( $batch['uploaded_by'], $title, $body, [ 'type' => 'batch_complete', 'batch_id' => $batchId, 'status' => $finalStatus, 'processed' => $processed, 'failed' => $failed, 'total' => $total, 'is_done' => 1, '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()); } } }