fca4f75e9c
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>
97 lines
3.9 KiB
PHP
97 lines
3.9 KiB
PHP
<?php
|
|
|
|
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
|
|
{
|
|
protected static string $resource = PurchaseResource::class;
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
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(),
|
|
];
|
|
}
|
|
}
|