Files
autocrm/app/Services/Ai/OcrInvoiceService.php
T
Vasyka fca4f75e9c feat: OCR invoice import via Claude Vision
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) <noreply@anthropic.com>
2026-06-03 06:00:45 +00:00

166 lines
5.7 KiB
PHP

<?php
namespace App\Services\Ai;
use App\Models\Central\Company;
use App\Tenancy\TenantManager;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Extracts structured invoice data from an uploaded image using Claude Vision.
* Output shape (when ok=true):
* data => [
* '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 <<<TXT
Ești expert OCR pentru facturi auto-service / piese auto din România/Moldova.
Primești o imagine de factură. Extragi datele într-un obiect JSON STRICT cu schema:
{
"supplier_name": string | null,
"date": string | null, // format YYYY-MM-DD
"currency": string | null, // MDL / EUR / USD / RON
"items": [
{ "name": string, "qty": number, "unit_price": number, "total": number | null }
],
"total": number | null
}
Reguli:
- Răspunde DOAR cu JSON-ul, fără cod de ghilimele markdown.
- Dacă o valoare nu poate fi citită clar, pune null (NU ghicești).
- Cantitățile și prețurile sunt numere zecimale (punct, nu virgulă).
- Numele piesei = scurt, fără TVA/observații.
TXT;
}
/** Tolerate markdown code fences + leading/trailing text around the JSON object. */
private function parseJson(string $text): ?array
{
// Strip markdown fences if present.
$clean = preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $text);
$clean = trim($clean);
$data = json_decode($clean, true);
if (is_array($data)) return $data;
// Fallback: find first {...} block.
if (preg_match('/\{.*\}/s', $clean, $m)) {
$data = json_decode($m[0], true);
if (is_array($data)) return $data;
}
return null;
}
private function normalize(array $d): array
{
$items = [];
foreach ((array) ($d['items'] ?? []) as $it) {
if (! is_array($it)) continue;
$name = trim((string) ($it['name'] ?? ''));
if ($name === '') continue;
$qty = (float) ($it['qty'] ?? 1);
$unit = (float) ($it['unit_price'] ?? 0);
$total = isset($it['total']) ? (float) $it['total'] : round($qty * $unit, 2);
$items[] = ['name' => $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);
}
}