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>
This commit is contained in:
@@ -3,8 +3,15 @@
|
|||||||
namespace App\Filament\Tenant\Resources\PurchaseResource\Pages;
|
namespace App\Filament\Tenant\Resources\PurchaseResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\PurchaseResource;
|
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\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class ListPurchases extends ListRecords
|
class ListPurchases extends ListRecords
|
||||||
{
|
{
|
||||||
@@ -12,6 +19,78 @@ class ListPurchases extends ListRecords
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
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(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Central\Plan;
|
||||||
|
use App\Services\Ai\OcrInvoiceService;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class OcrInvoiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_extract_parses_clean_json_response(): void
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user