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:
2026-06-03 06:00:45 +00:00
parent 75386c354a
commit fca4f75e9c
3 changed files with 391 additions and 1 deletions
@@ -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(),
];
}
}