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;
|
||||
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user