[ * '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' => app(AiAssistantService::class)->modelFor('claude', $company), '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); } }