51917bcbaf
Rate limiting: - Shop POST endpoints get per-IP throttles with distinct prefixes so login, register, password-email, and password-reset have separate buckets: login/register/pw-reset = 5/min, pw-email = 3/min - OcrInvoiceService gates per-tenant via RateLimiter (30/hour) so a runaway uploader can't burn Claude Vision spend Health monitor (poor-man's monitoring): - HealthCheckCommand probes DB (SELECT 1), cache write/read, public storage write/read, and most-recent backup age. On any failure, pushes a Telegram alert via HEALTH_ALERT_BOT_TOKEN/HEALTH_ALERT_CHAT_ID. Dedups identical failures within a 30-min window via cache. - Scheduled every 10 min. Pair with external uptime monitoring (UptimeRobot, Better Stack hitting /up) for total-outage coverage. - .env.example documents the two new env vars. VAPID secret hygiene: - credentials.md no longer stores the VAPID_PRIVATE_KEY; the source of truth is the Coolify env on the autocrm app. Doc points to where to read it (UI or API). Mitigates accidental git leak. Tests (4 new): - shop login throttles after 5 attempts (6th = 429); register throttle is independent of login (separate prefix); health command runs clean; dedup cache path exercised Full suite: 138 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
6.3 KiB
PHP
175 lines
6.3 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.'];
|
|
}
|
|
|
|
// Per-tenant rate limit — caps Claude Vision spend even if a user
|
|
// accidentally (or maliciously) submits many invoices.
|
|
$key = 'ocr-invoice:' . $company->id;
|
|
if (\Illuminate\Support\Facades\RateLimiter::tooManyAttempts($key, 30)) {
|
|
$retry = \Illuminate\Support\Facades\RateLimiter::availableIn($key);
|
|
return ['ok' => false, 'error' => "Prea multe importuri OCR. Reîncearcă în {$retry} sec."];
|
|
}
|
|
\Illuminate\Support\Facades\RateLimiter::hit($key, 3600); // 30 / hour
|
|
|
|
$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' => app(AiAssistantService::class)->modelFor('claude', $company),
|
|
'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);
|
|
}
|
|
}
|