From fca4f75e9c8bd3bcfb453027bc5ab7a7f2ad7a85 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Wed, 3 Jun 2026 06:00:45 +0000 Subject: [PATCH] feat: OCR invoice import via Claude Vision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload an invoice photo → Claude extracts {supplier_name, date, currency, items, total} as JSON → auto-create a draft Purchase + PurchaseItems → redirect to edit so the user reviews before confirming/receiving. OcrInvoiceService: - Validates supported MIME (jpg/png/webp/gif) - Reads tenant Claude key (settings.ai.claude_key) — friendly error if missing - Calls /v1/messages with image content block + structured-output system prompt - Tolerant parser: strips ```json fences, falls back to first {…} block - normalize(): computes per-item total when absent, fills overall total - All return shapes: {ok:bool, data?, error?, raw?, tokens?} Filament: - "Import factură (OCR)" header action on Purchases list - Image file upload → service → matches Supplier by case-insensitive name (notes the unmapped name if no match) → creates draft Purchase + items → redirects to the Edit page Tests (6 new): - clean JSON parses; markdown fences stripped; malformed → graceful error; missing key → friendly message + no HTTP; unsupported MIME rejected; item total computed when missing Full suite: 123 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PurchaseResource/Pages/ListPurchases.php | 81 ++++++++- app/Services/Ai/OcrInvoiceService.php | 165 ++++++++++++++++++ tests/Feature/OcrInvoiceTest.php | 146 ++++++++++++++++ 3 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 app/Services/Ai/OcrInvoiceService.php create mode 100644 tests/Feature/OcrInvoiceTest.php diff --git a/app/Filament/Tenant/Resources/PurchaseResource/Pages/ListPurchases.php b/app/Filament/Tenant/Resources/PurchaseResource/Pages/ListPurchases.php index 2d01379..7505698 100644 --- a/app/Filament/Tenant/Resources/PurchaseResource/Pages/ListPurchases.php +++ b/app/Filament/Tenant/Resources/PurchaseResource/Pages/ListPurchases.php @@ -3,8 +3,15 @@ namespace App\Filament\Tenant\Resources\PurchaseResource\Pages; use App\Filament\Tenant\Resources\PurchaseResource; +use App\Models\Tenant\Purchase; +use App\Models\Tenant\PurchaseItem; +use App\Models\Tenant\Supplier; +use App\Services\Ai\OcrInvoiceService; use Filament\Actions; +use Filament\Forms; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; +use Illuminate\Support\Facades\Storage; class ListPurchases extends ListRecords { @@ -12,6 +19,78 @@ class ListPurchases extends ListRecords protected function getHeaderActions(): array { - return [Actions\CreateAction::make()]; + return [ + Actions\Action::make('ocr') + ->label('Import factură (OCR)') + ->icon('heroicon-m-document-arrow-up') + ->color('gray') + ->modalHeading('Import factură via OCR') + ->modalDescription('Încarcă o poză cu factura. AI-ul extrage furnizorul, data și liniile. Verifici și salvezi.') + ->schema([ + Forms\Components\FileUpload::make('invoice') + ->label('Foto factură') + ->image() + ->disk('local') + ->directory('ocr-imports') + ->required() + ->maxSize(5120), + ]) + ->action(function (array $data) { + $abs = Storage::disk('local')->path($data['invoice']); + $result = app(OcrInvoiceService::class)->extract($abs); + + if (! ($result['ok'] ?? false)) { + Notification::make() + ->title('OCR eșuat') + ->body($result['error'] ?? 'Eroare necunoscută.') + ->danger()->send(); + @unlink($abs); + return; + } + + $payload = $result['data']; + + // Match supplier by case-insensitive name. + $supplierId = null; + if ($payload['supplier_name']) { + $supplierId = Supplier::whereRaw('LOWER(name) = ?', [mb_strtolower($payload['supplier_name'])]) + ->value('id'); + } + + $purchase = Purchase::create([ + 'number' => Purchase::generateNumber( + app(\App\Tenancy\TenantManager::class)->currentId() + ), + 'supplier_id' => $supplierId, + 'order_date' => $payload['date'] ?? today()->toDateString(), + 'status' => 'draft', + 'notes' => 'Importat OCR' . ($payload['supplier_name'] && ! $supplierId + ? " · furnizor nemap-uit: „{$payload['supplier_name']}”" + : ''), + ]); + + foreach ($payload['items'] as $item) { + PurchaseItem::create([ + 'purchase_id' => $purchase->id, + 'name' => $item['name'], + 'qty' => $item['qty'], + 'unit' => 'buc', + 'buy_price' => $item['unit_price'], + ]); + } + $purchase->refresh()->recalcTotal(); + + @unlink($abs); + + Notification::make() + ->title('Factură importată') + ->body(sprintf('%d linii, total %.2f. Verifică și ajustează înainte de a confirma.', + count($payload['items']), (float) $purchase->total)) + ->success()->send(); + + $this->redirect(PurchaseResource::getUrl('edit', ['record' => $purchase])); + }), + Actions\CreateAction::make(), + ]; } } diff --git a/app/Services/Ai/OcrInvoiceService.php b/app/Services/Ai/OcrInvoiceService.php new file mode 100644 index 0000000..eda25dc --- /dev/null +++ b/app/Services/Ai/OcrInvoiceService.php @@ -0,0 +1,165 @@ + [ + * 'supplier_name' => string|null, + * 'date' => string|null (YYYY-MM-DD), + * 'currency' => string|null, + * 'items' => [{name, qty, unit_price, total?}], + * 'total' => float|null, + * ] + */ +class OcrInvoiceService +{ + public const SUPPORTED_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + + public function extract(string $absPath, ?string $mime = null): array + { + if (! is_file($absPath)) { + return ['ok' => false, 'error' => 'Fișierul nu există.']; + } + + $mime ??= mime_content_type($absPath) ?: 'image/jpeg'; + if (! in_array($mime, self::SUPPORTED_MIME, true)) { + return ['ok' => false, 'error' => "Tip fișier neacceptat: {$mime}. Folosește JPG/PNG/WebP."]; + } + + $company = $this->currentCompany(); + if (! $company) { + return ['ok' => false, 'error' => 'Tenant nerezolvat.']; + } + + $key = data_get($company->settings, 'ai.claude_key'); + if (! $key) { + return ['ok' => false, 'error' => '⚠️ Lipsește cheia Claude în Setări → AI.']; + } + + $b64 = base64_encode(file_get_contents($absPath)); + + $r = Http::withHeaders([ + 'x-api-key' => $key, + 'anthropic-version' => '2023-06-01', + 'content-type' => 'application/json', + ]) + ->timeout(60) + ->post('https://api.anthropic.com/v1/messages', [ + 'model' => 'claude-sonnet-4-5', + 'max_tokens' => 2048, + 'system' => $this->systemPrompt(), + 'messages' => [[ + 'role' => 'user', + 'content' => [ + ['type' => 'image', 'source' => [ + 'type' => 'base64', 'media_type' => $mime, 'data' => $b64, + ]], + ['type' => 'text', 'text' => 'Extrage datele din această factură. Răspunde DOAR cu obiectul JSON, fără text suplimentar și fără ```.'], + ], + ]], + ]); + + if (! $r->successful()) { + Log::warning('ocr.extract API error', ['status' => $r->status(), 'body' => $r->body()]); + return ['ok' => false, 'error' => 'API Claude: ' . ($r->json('error.message') ?? $r->status())]; + } + + $body = $r->json(); + $text = collect($body['content'] ?? []) + ->where('type', 'text')->pluck('text')->implode("\n"); + $text = trim($text); + + $data = $this->parseJson($text); + if ($data === null) { + return ['ok' => false, 'error' => 'Răspuns AI ne-parsabil ca JSON.', 'raw' => $text]; + } + + return [ + 'ok' => true, + 'data' => $this->normalize($data), + 'raw' => $text, + 'tokens' => [ + 'in' => $body['usage']['input_tokens'] ?? null, + 'out' => $body['usage']['output_tokens'] ?? null, + ], + ]; + } + + private function systemPrompt(): string + { + return << $name, 'qty' => $qty, 'unit_price' => $unit, 'total' => $total]; + } + + return [ + 'supplier_name' => $d['supplier_name'] ?? null, + 'date' => $d['date'] ?? null, + 'currency' => $d['currency'] ?? null, + 'items' => $items, + 'total' => isset($d['total']) ? (float) $d['total'] : array_sum(array_column($items, 'total')), + ]; + } + + private function currentCompany(): ?Company + { + $id = app(TenantManager::class)->currentId(); + if (! $id) return null; + return Company::withoutGlobalScopes()->find($id); + } +} diff --git a/tests/Feature/OcrInvoiceTest.php b/tests/Feature/OcrInvoiceTest.php new file mode 100644 index 0000000..5c3b920 --- /dev/null +++ b/tests/Feature/OcrInvoiceTest.php @@ -0,0 +1,146 @@ +makeCtx(claudeKey: 'sk-fake'); + + Http::fake(['api.anthropic.com/*' => Http::response([ + 'content' => [['type' => 'text', 'text' => json_encode([ + 'supplier_name' => 'Acme Parts SRL', + 'date' => '2026-05-15', + 'currency' => 'MDL', + 'items' => [ + ['name' => 'Filtru ulei', 'qty' => 2, 'unit_price' => 75, 'total' => 150], + ['name' => 'Filtru aer', 'qty' => 1, 'unit_price' => 90, 'total' => 90], + ], + 'total' => 240, + ])]], + 'usage' => ['input_tokens' => 1500, 'output_tokens' => 120], + ])]); + + $img = $this->fakeImage(); + $r = app(OcrInvoiceService::class)->extract($img); + + $this->assertTrue($r['ok']); + $this->assertEquals('Acme Parts SRL', $r['data']['supplier_name']); + $this->assertEquals('2026-05-15', $r['data']['date']); + $this->assertCount(2, $r['data']['items']); + $this->assertEquals(240.0, $r['data']['total']); + @unlink($img); + } + + public function test_extract_strips_markdown_fences(): void + { + $this->makeCtx(claudeKey: 'sk-fake'); + + $payload = json_encode(['supplier_name' => 'X', 'items' => [ + ['name' => 'Y', 'qty' => 1, 'unit_price' => 50], + ]]); + Http::fake(['api.anthropic.com/*' => Http::response([ + 'content' => [['type' => 'text', 'text' => "```json\n{$payload}\n```"]], + 'usage' => ['input_tokens' => 100, 'output_tokens' => 30], + ])]); + + $img = $this->fakeImage(); + $r = app(OcrInvoiceService::class)->extract($img); + + $this->assertTrue($r['ok']); + $this->assertEquals('X', $r['data']['supplier_name']); + @unlink($img); + } + + public function test_extract_handles_malformed_response(): void + { + $this->makeCtx(claudeKey: 'sk-fake'); + + Http::fake(['api.anthropic.com/*' => Http::response([ + 'content' => [['type' => 'text', 'text' => 'this is not json at all']], + 'usage' => ['input_tokens' => 50, 'output_tokens' => 10], + ])]); + + $img = $this->fakeImage(); + $r = app(OcrInvoiceService::class)->extract($img); + + $this->assertFalse($r['ok']); + $this->assertStringContainsString('ne-parsabil', $r['error']); + @unlink($img); + } + + public function test_missing_api_key_returns_friendly_error(): void + { + $this->makeCtx(claudeKey: null); + + $img = $this->fakeImage(); + $r = app(OcrInvoiceService::class)->extract($img); + + $this->assertFalse($r['ok']); + $this->assertStringContainsString('Setări', $r['error']); + Http::assertNothingSent(); + @unlink($img); + } + + public function test_unsupported_mime_rejected(): void + { + $this->makeCtx(claudeKey: 'sk-fake'); + + $path = tempnam(sys_get_temp_dir(), 'ocr'); + file_put_contents($path, "%PDF-not really"); + + $r = app(OcrInvoiceService::class)->extract($path, 'application/pdf'); + $this->assertFalse($r['ok']); + $this->assertStringContainsString('Tip fișier', $r['error']); + @unlink($path); + } + + public function test_normalize_computes_item_total_when_missing(): void + { + $this->makeCtx(claudeKey: 'sk-fake'); + Http::fake(['api.anthropic.com/*' => Http::response([ + 'content' => [['type' => 'text', 'text' => json_encode([ + 'items' => [['name' => 'A', 'qty' => 3, 'unit_price' => 25]], + ])]], + 'usage' => ['input_tokens' => 1, 'output_tokens' => 1], + ])]); + + $img = $this->fakeImage(); + $r = app(OcrInvoiceService::class)->extract($img); + $this->assertEquals(75.0, $r['data']['items'][0]['total']); + $this->assertEquals(75.0, $r['data']['total']); + @unlink($img); + } + + private function makeCtx(?string $claudeKey): Company + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'ocr-' . uniqid(), + 'name' => 'OCR Co', 'status' => 'active', + 'settings' => ['ai' => ['claude_key' => $claudeKey]], + ]); + app(TenantManager::class)->setCurrent($company); + return $company; + } + + private function fakeImage(): string + { + // Minimal 1x1 PNG so mime_content_type returns image/png. + $bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg=='); + $path = tempnam(sys_get_temp_dir(), 'ocr') . '.png'; + file_put_contents($path, $bytes); + return $path; + } +}