diff --git a/app/Filament/Tenant/Pages/AiAssistant.php b/app/Filament/Tenant/Pages/AiAssistant.php index 8c3b5ac..de6e78a 100644 --- a/app/Filament/Tenant/Pages/AiAssistant.php +++ b/app/Filament/Tenant/Pages/AiAssistant.php @@ -60,6 +60,11 @@ class AiAssistant extends Page ->get(); } + public function getUsage(): array + { + return app(AiAssistantService::class)->monthlyUsage(); + } + public function newChat(): void { $chat = AiChat::create([ diff --git a/app/Filament/Tenant/Resources/PartResource.php b/app/Filament/Tenant/Resources/PartResource.php index 2036ea5..d4f50e8 100644 --- a/app/Filament/Tenant/Resources/PartResource.php +++ b/app/Filament/Tenant/Resources/PartResource.php @@ -135,6 +135,18 @@ class PartResource extends Resource ->query(fn ($q) => $q->where('qty', '<=', 0)), ]) ->actions([ + Actions\Action::make('ai_price') + ->label('AI: preț recomandat') + ->icon('heroicon-m-sparkles') + ->color('primary') + ->modalHeading(fn (Part $r) => "AI: preț pentru {$r->name}") + ->modalSubmitAction(false) + ->modalCancelActionLabel('Închide') + ->modalContent(function (Part $r) { + [$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class) + ->suggestPrice($r); + return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]); + }), Actions\Action::make('receive') ->label('Recepție') ->icon('heroicon-m-arrow-down-tray') diff --git a/app/Filament/Tenant/Resources/VehicleResource.php b/app/Filament/Tenant/Resources/VehicleResource.php index 58fc8d2..751dc3d 100644 --- a/app/Filament/Tenant/Resources/VehicleResource.php +++ b/app/Filament/Tenant/Resources/VehicleResource.php @@ -93,6 +93,31 @@ class VehicleResource extends Resource Tables\Columns\TextColumn::make('created_at')->date()->sortable(), ]) ->actions([ + Actions\Action::make('decode_vin') + ->label('Decode VIN') + ->icon('heroicon-m-cpu-chip') + ->color('gray') + ->visible(fn (\App\Models\Tenant\Vehicle $r) => ! empty($r->vin) && strlen($r->vin) === 17) + ->modalHeading(fn (\App\Models\Tenant\Vehicle $r) => 'Decode VIN: ' . $r->vin) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Închide') + ->modalContent(function (\App\Models\Tenant\Vehicle $r) { + $info = app(\App\Services\Ai\VinDecoder::class)->decode($r->vin); + return view('filament.tenant.vin-decode', ['info' => $info, 'vehicle' => $r]); + }), + Actions\Action::make('ai_recommend') + ->label('AI: recomandări') + ->icon('heroicon-m-sparkles') + ->color('primary') + ->visible(fn (\App\Models\Tenant\Vehicle $r) => ! empty($r->vin)) + ->modalHeading('Recomandări AI') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Închide') + ->modalContent(function (\App\Models\Tenant\Vehicle $r) { + [$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class) + ->vinRecommendations($r->vin, (int) $r->mileage); + return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]); + }), Actions\EditAction::make(), Actions\DeleteAction::make(), ]) diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php b/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php index 1615a20..c6591c9 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php @@ -15,6 +15,19 @@ class EditWorkOrder extends EditRecord protected function getHeaderActions(): array { return [ + Actions\Action::make('ai_diagnose') + ->label('AI: sugerează diagnostic') + ->icon('heroicon-m-sparkles') + ->color('primary') + ->visible(fn () => ! empty($this->record->complaint)) + ->modalHeading('Diagnostic AI bazat pe plângerea clientului') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Închide') + ->modalContent(function () { + [$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class) + ->suggestDiagnosis($this->record); + return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]); + }), Actions\Action::make('tracking') ->label('Link client (QR)') ->icon('heroicon-m-qr-code') diff --git a/app/Services/Ai/AiAssistantService.php b/app/Services/Ai/AiAssistantService.php index 7795503..6343c8f 100644 --- a/app/Services/Ai/AiAssistantService.php +++ b/app/Services/Ai/AiAssistantService.php @@ -7,8 +7,12 @@ use App\Models\Tenant\AiChat; use App\Models\Tenant\AiMessage; use App\Models\Tenant\Client; use App\Models\Tenant\Lead; +use App\Models\Tenant\Part; use App\Models\Tenant\Vehicle; use App\Models\Tenant\WorkOrder; +use App\Tenancy\TenantManager; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; /** @@ -175,6 +179,235 @@ TXT; ]; } + // ─── One-shot AI helpers ───────────────────────────────────────── + + /** + * Single-prompt call without persisting a chat. Used by quick-action buttons + * (diagnose / suggest parts / suggest price / VIN recommendations). + * + * @return array{0:string, 1:array} [reply, meta] + */ + public function singleShot(string $systemPrompt, string $userPrompt, ?string $provider = null): array + { + $company = $this->currentCompany(); + if (! $company) return ['Niciun tenant rezolvat.', ['error' => 'no_tenant']]; + + $aiCfg = (array) ($company->settings['ai'] ?? []); + $provider ??= $aiCfg['default_provider'] ?? 'claude'; + $key = $aiCfg["{$provider}_key"] ?? null; + if (! $key) { + return ['⚠️ API key pentru ' . strtoupper($provider) . ' lipsește în Setări → AI.', + ['error' => 'no_api_key', 'provider' => $provider]]; + } + + $start = microtime(true); + try { + [$reply, $meta] = match ($provider) { + 'claude' => $this->postClaude($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]), + 'gpt' => $this->postOpenAI($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]), + 'gemini' => $this->postGemini($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]), + default => ['Provider necunoscut: ' . $provider, []], + }; + } catch (\Throwable $e) { + $reply = '❌ Eroare API: ' . $e->getMessage(); + $meta = ['error' => $e->getMessage()]; + } + $meta['latency_ms'] = (int) ((microtime(true) - $start) * 1000); + $meta['provider'] = $provider; + + return [$reply, $meta]; + } + + public function suggestDiagnosis(WorkOrder $wo): array + { + $vehicle = $wo->vehicle; + $vinInfo = ''; + if ($vehicle?->vin) { + $d = app(VinDecoder::class)->decode($vehicle->vin); + if (! empty($d['manufacturer'])) { + $vinInfo = " (VIN decode: {$d['manufacturer']} {$d['year']})"; + } + } + $sys = "Ești diagnostician auto cu 20 de ani experiență. Răspunzi scurt, structurat, " + . "în română. Listează în ordine de probabilitate cauzele posibile + verificările " + . "necesare. Nu inventezi date — dacă plângerea e vagă, sugerezi întrebări de clarificat."; + $user = sprintf( + "Mașina: %s %s %s%s, %s km.\nPlângere client: %s\n\nDă-mi top 3 cauze probabile și ce trebuie verificat la fiecare.", + (string) ($vehicle->make ?? '?'), + (string) ($vehicle->model ?? '?'), + (string) ($vehicle->year ?? ''), + $vinInfo, + number_format((float) ($vehicle->mileage ?? 0), 0, '.', ' '), + $wo->complaint ?: '(nu e completată)', + ); + return $this->singleShot($sys, $user); + } + + public function suggestParts(WorkOrder $wo, string $task): array + { + $vehicle = $wo->vehicle; + $sys = "Ești expert piese auto. Pentru mașina dată și operațiunea solicitată, " + . "listează piesele necesare cu coduri OEM tipice când le știi (sau familie generică). " + . "Răspunde în română, format listă."; + $user = sprintf( + "Mașina: %s %s %s.\nOperațiune: %s.\nListează piesele necesare + coduri OEM dacă există + cantități.", + (string) ($vehicle->make ?? '?'), + (string) ($vehicle->model ?? '?'), + (string) ($vehicle->year ?? ''), + $task, + ); + return $this->singleShot($sys, $user); + } + + public function suggestPrice(Part $part): array + { + $company = $this->currentCompany(); + $currency = (string) data_get($company?->settings, 'currency', 'MDL'); + $sys = "Ești manager comercial pentru un magazin de piese auto din Moldova. " + . "Sugerezi preț de vânzare bazat pe costul de achiziție, categorie și brand. " + . "Răspunde scurt cu: preț recomandat, markup %, justificare 1-2 fraze."; + $user = sprintf( + "Piesa: %s\nBrand: %s\nCategorie: %s\nCost achiziție: %.2f %s\nPreț actual: %.2f %s", + $part->name, + $part->brand ?? '?', + $part->category ?? '?', + (float) $part->buy_price, $currency, + (float) $part->sell_price, $currency, + ); + return $this->singleShot($sys, $user); + } + + public function vinRecommendations(string $vin, ?int $mileage = null): array + { + $decoded = app(VinDecoder::class)->decode($vin); + if (empty($decoded['manufacturer'])) { + return ["VIN nu poate fi decodat — verifică formatul (17 caractere).", ['error' => 'invalid_vin']]; + } + + $sys = "Ești expert service auto. Pe baza datelor mașinii și kilometrajului, " + . "sugerezi mentenanța programată recomandată de producător + verificările " + . "tipice pentru vârsta mașinii. Format listă scurtă."; + $user = sprintf( + "Mașina decodată din VIN: %s din %d (%s).\n%sCe verificări și mentenanță programată recomandăm acum?", + $decoded['manufacturer'], + $decoded['year'], + $decoded['country'] ?? '?', + $mileage ? "Kilometraj actual: " . number_format($mileage, 0, '.', ' ') . " km.\n" : '', + ); + [$reply, $meta] = $this->singleShot($sys, $user); + $meta['vin_decoded'] = $decoded; + return [$reply, $meta]; + } + + // ─── Token usage tracking ──────────────────────────────────────── + + /** + * Aggregate token spend for current month, grouped by provider. + * + * @return array + */ + public function monthlyUsage(): array + { + $start = Carbon::now()->startOfMonth(); + $rows = AiMessage::where('role', 'assistant') + ->where('created_at', '>=', $start) + ->get(['meta']); + + $out = []; + foreach ($rows as $r) { + $meta = (array) $r->meta; + $provider = (string) ($meta['provider'] ?? 'unknown'); + $out[$provider] ??= ['tokens_in' => 0, 'tokens_out' => 0, 'calls' => 0]; + $out[$provider]['tokens_in'] += (int) ($meta['tokens_in'] ?? 0); + $out[$provider]['tokens_out'] += (int) ($meta['tokens_out'] ?? 0); + $out[$provider]['calls'] += 1; + } + return $out; + } + + // ─── Provider HTTP — common form, used by both chat + single-shot ─ + + protected function postClaude(string $key, string $system, array $messages): array + { + $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' => 1024, + 'system' => $system, + 'messages' => $messages, + ]); + if (! $r->successful()) { + return ['❌ ' . ($r->json('error.message') ?? 'Anthropic ' . $r->status()), ['status' => $r->status()]]; + } + $body = $r->json(); + $text = collect($body['content'] ?? []) + ->where('type', 'text')->pluck('text')->implode("\n"); + return [$text ?: '(răspuns gol)', [ + 'model' => $body['model'] ?? null, + 'tokens_in' => $body['usage']['input_tokens'] ?? null, + 'tokens_out' => $body['usage']['output_tokens'] ?? null, + ]]; + } + + protected function postOpenAI(string $key, string $system, array $messages): array + { + $r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json']) + ->timeout(60) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => 'gpt-4o-mini', + 'messages' => array_merge([['role' => 'system', 'content' => $system]], $messages), + 'max_tokens' => 1024, + ]); + if (! $r->successful()) { + return ['❌ ' . ($r->json('error.message') ?? 'OpenAI ' . $r->status()), ['status' => $r->status()]]; + } + $body = $r->json(); + return [ + $body['choices'][0]['message']['content'] ?? '(răspuns gol)', + [ + 'model' => $body['model'] ?? null, + 'tokens_in' => $body['usage']['prompt_tokens'] ?? null, + 'tokens_out' => $body['usage']['completion_tokens'] ?? null, + ], + ]; + } + + protected function postGemini(string $key, string $system, array $messages): array + { + $contents = []; + foreach ($messages as $m) { + $contents[] = [ + 'role' => $m['role'] === 'assistant' ? 'model' : 'user', + 'parts' => [['text' => $m['content']]], + ]; + } + $r = Http::withHeaders(['content-type' => 'application/json']) + ->timeout(60) + ->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' . $key, [ + 'systemInstruction' => ['parts' => [['text' => $system]]], + 'contents' => $contents, + 'generationConfig' => ['maxOutputTokens' => 1024], + ]); + if (! $r->successful()) { + return ['❌ Gemini ' . $r->status(), ['status' => $r->status()]]; + } + $body = $r->json(); + $text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)'; + return [$text, ['model' => 'gemini-1.5-flash', 'tokens' => $body['usageMetadata'] ?? null]]; + } + + protected function currentCompany(): ?Company + { + $id = app(TenantManager::class)->currentId(); + if (! $id) return null; + return Company::withoutGlobalScopes()->find($id); + } + protected function callGemini(string $key, AiChat $chat, string $msg, Company $company): array { $contents = []; diff --git a/app/Services/Ai/VinDecoder.php b/app/Services/Ai/VinDecoder.php new file mode 100644 index 0000000..3e16118 --- /dev/null +++ b/app/Services/Ai/VinDecoder.php @@ -0,0 +1,171 @@ + [1980, 2010], 'B' => [1981, 2011], 'C' => [1982, 2012], 'D' => [1983, 2013], + 'E' => [1984, 2014], 'F' => [1985, 2015], 'G' => [1986, 2016], 'H' => [1987, 2017], + 'J' => [1988, 2018], 'K' => [1989, 2019], 'L' => [1990, 2020], 'M' => [1991, 2021], + 'N' => [1992, 2022], 'P' => [1993, 2023], 'R' => [1994, 2024], 'S' => [1995, 2025], + 'T' => [1996, 2026], 'V' => [1997, 2027], 'W' => [1998, 2028], 'X' => [1999, 2029], + 'Y' => [2000, 2030], '1' => [2001, 2031], '2' => [2002, 2032], '3' => [2003, 2033], + '4' => [2004, 2034], '5' => [2005, 2035], '6' => [2006, 2036], '7' => [2007, 2037], + '8' => [2008, 2038], '9' => [2009, 2039], + ]; + + // Region by first char (ISO 3779 broad regions). + private const REGIONS = [ + 'A' => 'Africa', 'B' => 'Africa', 'C' => 'Africa', 'D' => 'Africa', + 'E' => 'Africa', 'F' => 'Africa', 'G' => 'Africa', 'H' => 'Africa', + 'J' => 'Asia', 'K' => 'Asia', 'L' => 'Asia', 'M' => 'Asia', + 'N' => 'Asia', 'P' => 'Asia', 'R' => 'Asia', + 'S' => 'Europe', 'T' => 'Europe', 'U' => 'Europe', 'V' => 'Europe', + 'W' => 'Europe', 'X' => 'Europe', 'Y' => 'Europe', 'Z' => 'Europe', + '1' => 'North America', '2' => 'North America', '3' => 'North America', + '4' => 'North America', '5' => 'North America', + '6' => 'Oceania', '7' => 'Oceania', + '8' => 'South America', '9' => 'South America', + ]; + + // Selected WMI → manufacturer/country. Covers most common European/Asian/US + // brands relevant for a Moldova service shop. + private const WMI = [ + // Volkswagen group + 'WVW' => ['VW', 'Germany'], 'WV1' => ['VW Commercial', 'Germany'], 'WV2' => ['VW Bus', 'Germany'], + 'WAU' => ['Audi', 'Germany'], 'WA1' => ['Audi SUV', 'Germany'], + 'TRU' => ['Audi', 'Hungary'], 'WUA' => ['Audi Sport', 'Germany'], + 'VWV' => ['VW', 'Spain'], 'VSS' => ['SEAT', 'Spain'], 'TMB' => ['Škoda', 'Czechia'], + // BMW + 'WBA' => ['BMW', 'Germany'], 'WBS' => ['BMW M', 'Germany'], 'WBY' => ['BMW i', 'Germany'], + 'WBX' => ['BMW X SUV', 'USA'], 'NM0' => ['BMW Mini', 'Turkey'], + // Mercedes + 'WDB' => ['Mercedes-Benz', 'Germany'], 'WDC' => ['Mercedes-Benz SUV', 'USA'], + 'WDD' => ['Mercedes-Benz', 'Germany'], 'WDF' => ['Mercedes-Benz Van', 'Germany'], + // Porsche + 'WP0' => ['Porsche', 'Germany'], 'WP1' => ['Porsche SUV', 'Germany'], + // Opel / Vauxhall + 'W0L' => ['Opel', 'Germany'], 'W0V' => ['Opel/Vauxhall', 'Germany'], + // Ford + '1FA' => ['Ford', 'USA'], '1FT' => ['Ford Truck', 'USA'], '1FM' => ['Ford SUV', 'USA'], + 'WF0' => ['Ford Europe', 'Germany'], + // Honda + '1HG' => ['Honda', 'USA'], 'JHM' => ['Honda', 'Japan'], 'JHL' => ['Honda SUV', 'Japan'], + // Toyota + 'JT2' => ['Toyota', 'Japan'], 'JTD' => ['Toyota', 'Japan'], 'JTE' => ['Toyota', 'Japan'], + '4T1' => ['Toyota', 'USA'], '5TD' => ['Toyota', 'USA'], + // Hyundai/Kia + 'KMH' => ['Hyundai', 'Korea'], 'KNA' => ['Kia', 'Korea'], 'KND' => ['Kia SUV', 'Korea'], + // Renault/Dacia + 'VF1' => ['Renault', 'France'], 'VF6' => ['Renault Trucks', 'France'], + 'UU1' => ['Dacia', 'Romania'], 'UU3' => ['Dacia Pickup', 'Romania'], + // Peugeot/Citroën + 'VF3' => ['Peugeot', 'France'], 'VF7' => ['Citroën', 'France'], + // Fiat group + 'ZFA' => ['Fiat', 'Italy'], 'ZAR' => ['Alfa Romeo', 'Italy'], 'ZFF' => ['Ferrari', 'Italy'], + // Volvo + 'YV1' => ['Volvo Cars', 'Sweden'], 'YV4' => ['Volvo SUV', 'Sweden'], + // Nissan + 'JN1' => ['Nissan', 'Japan'], 'JN8' => ['Nissan SUV', 'Japan'], '1N4' => ['Nissan', 'USA'], + // Mazda + 'JM1' => ['Mazda', 'Japan'], 'JMZ' => ['Mazda', 'Japan'], + // Subaru + 'JF1' => ['Subaru', 'Japan'], 'JF2' => ['Subaru SUV', 'Japan'], + // Mitsubishi + 'JMB' => ['Mitsubishi', 'Japan'], 'JA3' => ['Mitsubishi', 'Japan'], + // Lada / Russian + 'XTA' => ['Lada/AvtoVAZ', 'Russia'], 'X4X' => ['UAZ', 'Russia'], + // Tesla + '5YJ' => ['Tesla', 'USA'], 'LRW' => ['Tesla', 'China'], + // Chinese brands + 'LGW' => ['Great Wall', 'China'], 'LJV' => ['JAC', 'China'], 'LSJ' => ['MG/SAIC', 'China'], + 'LB1' => ['Geely', 'China'], + ]; + + public function decode(string $raw): array + { + $vin = preg_replace('/[^A-HJ-NPR-Z0-9]/', '', strtoupper($raw)); + if (strlen($vin) !== 17) { + return [ + 'vin' => $vin, + 'valid_length' => false, + 'reason' => 'VIN must be exactly 17 characters (no I, O, Q allowed).', + ]; + } + + $wmi = substr($vin, 0, 3); + $yearCode = $vin[9]; + $plant = $vin[10]; + + [$manufacturer, $country] = self::WMI[$wmi] ?? [null, null]; + $region = self::REGIONS[$vin[0]] ?? null; + + $year = $this->resolveYear($yearCode, $vin); + + return [ + 'vin' => $vin, + 'valid_length' => true, + 'wmi' => $wmi, + 'region' => $region, + 'country' => $country, + 'manufacturer' => $manufacturer, + 'year' => $year, + 'plant' => $plant, + 'checksum_valid' => $this->validateChecksum($vin), + ]; + } + + /** + * Position 10 encodes year cyclically. Use position 7 as A-Z → 2010+, 0-9 → pre-2010 + * disambiguator for new-spec VINs (since 2010 NHTSA spec). + */ + private function resolveYear(string $code, string $vin): ?int + { + if (! isset(self::YEAR_CODES[$code])) return null; + [$old, $new] = self::YEAR_CODES[$code]; + + // Position 7 alpha → post-2010 cycle; numeric → pre-2010 + $p7 = $vin[6]; + return ctype_alpha($p7) ? $new : $old; + } + + /** + * ISO 3779 / NA-spec checksum. Position 9 = mod-11 check digit (X = 10). + * Optional — many European/Asian manufacturers don't follow the spec. + */ + private function validateChecksum(string $vin): bool + { + $weights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; + $values = [ + 'A' => 1, 'B' => 2, 'C' => 3, 'D' => 4, 'E' => 5, 'F' => 6, 'G' => 7, 'H' => 8, + 'J' => 1, 'K' => 2, 'L' => 3, 'M' => 4, 'N' => 5, 'P' => 7, 'R' => 9, + 'S' => 2, 'T' => 3, 'U' => 4, 'V' => 5, 'W' => 6, 'X' => 7, 'Y' => 8, 'Z' => 9, + ]; + + $sum = 0; + for ($i = 0; $i < 17; $i++) { + $c = $vin[$i]; + $v = ctype_digit($c) ? (int) $c : ($values[$c] ?? null); + if ($v === null) return false; + $sum += $v * $weights[$i]; + } + $check = $sum % 11; + $expected = $check === 10 ? 'X' : (string) $check; + return $vin[8] === $expected; + } +} diff --git a/resources/views/filament/tenant/ai-reply.blade.php b/resources/views/filament/tenant/ai-reply.blade.php new file mode 100644 index 0000000..198143a --- /dev/null +++ b/resources/views/filament/tenant/ai-reply.blade.php @@ -0,0 +1,26 @@ +@php + $isError = isset($meta['error']); + $provider = $meta['provider'] ?? null; + $tokensIn = $meta['tokens_in'] ?? null; + $tokensOut = $meta['tokens_out'] ?? null; + $latency = $meta['latency_ms'] ?? null; +@endphp +
+ @if ($isError) +
+ {{ $reply }} +
+ @else +
{{ $reply }}
+ @endif + + @if (! $isError && ($provider || $tokensIn !== null)) +
+ @if ($provider) Provider: {{ strtoupper($provider) }} @endif + @if ($tokensIn !== null || $tokensOut !== null) + Tokens: {{ ($tokensIn ?? 0) + ($tokensOut ?? 0) }} ({{ $tokensIn ?? 0 }} in / {{ $tokensOut ?? 0 }} out) + @endif + @if ($latency !== null) Latency: {{ $latency }} ms @endif +
+ @endif +
diff --git a/resources/views/filament/tenant/pages/ai-assistant.blade.php b/resources/views/filament/tenant/pages/ai-assistant.blade.php index 960a64c..772e1e0 100644 --- a/resources/views/filament/tenant/pages/ai-assistant.blade.php +++ b/resources/views/filament/tenant/pages/ai-assistant.blade.php @@ -86,8 +86,21 @@ @php $chat = $this->getChat(); $chats = $this->getChats(); + $usage = $this->getUsage(); + $totalTokens = collect($usage)->sum(fn ($u) => $u['tokens_in'] + $u['tokens_out']); + $totalCalls = collect($usage)->sum('calls'); @endphp + @if ($totalCalls > 0) +
+ Tokens consumate luna aceasta: + {{ number_format($totalTokens) }} tokens · {{ $totalCalls }} cereri + @foreach ($usage as $provider => $u) + · {{ strtoupper($provider) }}: {{ number_format($u['tokens_in'] + $u['tokens_out']) }} + @endforeach +
+ @endif +
{{-- Sidebar: chat history --}}
diff --git a/resources/views/filament/tenant/vin-decode.blade.php b/resources/views/filament/tenant/vin-decode.blade.php new file mode 100644 index 0000000..19eeb0a --- /dev/null +++ b/resources/views/filament/tenant/vin-decode.blade.php @@ -0,0 +1,43 @@ +@php + $valid = $info['valid_length'] ?? false; +@endphp +
+ @if (! $valid) +
+ {{ $info['reason'] ?? 'VIN invalid.' }} +
+ @else +
+
VIN
+
{{ $info['vin'] }}
+ +
Regiune
+
{{ $info['region'] ?? '—' }}
+ +
Țară
+
{{ $info['country'] ?? '—' }}
+ +
Producător
+
{{ $info['manufacturer'] ?? '— (WMI necunoscut)' }}
+ +
An model
+
{{ $info['year'] ?? '—' }}
+ +
Cod uzină
+
{{ $info['plant'] ?? '—' }}
+ +
Checksum (ISO)
+
+ @if ($info['checksum_valid']) + ✓ valid + @else + — (multe VIN-uri europene nu respectă checksum-ul NA) + @endif +
+
+ +
+ Detalii granulare (model, motorizare) necesită bază TecDoc/NHTSA. Folosește butonul „AI: recomandări" pentru sugestii bazate pe an + producător. +
+ @endif +
diff --git a/tests/Feature/AiHelpersTest.php b/tests/Feature/AiHelpersTest.php new file mode 100644 index 0000000..77e8085 --- /dev/null +++ b/tests/Feature/AiHelpersTest.php @@ -0,0 +1,199 @@ +makeCtx(provider: 'claude', key: 'sk-fake'); + + Http::fake([ + 'api.anthropic.com/*' => Http::response([ + 'content' => [['type' => 'text', 'text' => 'Verifică pompa de apă și termostatul.']], + 'usage' => ['input_tokens' => 100, 'output_tokens' => 30], + 'model' => 'claude-sonnet-4-5', + ]), + ]); + + $wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($ctx['company']->id), + 'client_id' => $ctx['client']->id, + 'vehicle_id' => $ctx['vehicle']->id, + 'opened_at' => now(), + 'status' => 'diagnosis', + 'complaint' => 'Motorul se supraîncălzește în trafic.', + ]); + + [$reply, $meta] = app(AiAssistantService::class)->suggestDiagnosis($wo); + + $this->assertStringContainsString('termostat', $reply); + $this->assertEquals('claude', $meta['provider']); + $this->assertEquals(100, $meta['tokens_in']); + + Http::assertSent(function ($req) { + $body = json_decode($req->body(), true); + return str_contains($req->url(), 'anthropic.com') + && str_contains($body['messages'][0]['content'], 'supraîncălzește'); + }); + } + + public function test_suggest_price_includes_buy_and_sell(): void + { + $ctx = $this->makeCtx(provider: 'gpt', key: 'sk-openai'); + + Http::fake([ + 'api.openai.com/*' => Http::response([ + 'choices' => [['message' => ['content' => 'Markup 40% → preț 70 MDL.']]], + 'usage' => ['prompt_tokens' => 80, 'completion_tokens' => 15], + 'model' => 'gpt-4o-mini', + ]), + ]); + + $part = Part::create([ + 'name' => 'Filtru ulei MANN W811/80', + 'brand' => 'MANN', + 'category' => 'Filtre', + 'buy_price' => 50, + 'sell_price' => 65, + 'qty' => 5, + 'unit' => 'buc', + 'is_active' => true, + ]); + + [$reply, $meta] = app(AiAssistantService::class)->suggestPrice($part); + + $this->assertStringContainsString('Markup', $reply); + Http::assertSent(function ($req) { + $body = json_decode($req->body(), true); + $userMsg = collect($body['messages'])->firstWhere('role', 'user'); + return str_contains($userMsg['content'], '50.00') + && str_contains($userMsg['content'], 'MANN'); + }); + } + + public function test_vin_recommendations_includes_decoded_data(): void + { + $ctx = $this->makeCtx(provider: 'claude', key: 'sk-fake'); + + Http::fake([ + 'api.anthropic.com/*' => Http::response([ + 'content' => [['type' => 'text', 'text' => 'Recomandări mentenanță pentru Honda 2003']], + 'usage' => ['input_tokens' => 50, 'output_tokens' => 20], + ]), + ]); + + [$reply, $meta] = app(AiAssistantService::class) + ->vinRecommendations('1HGCM82633A123456', 150000); + + $this->assertStringContainsString('Honda', $reply); + $this->assertEquals('Honda', $meta['vin_decoded']['manufacturer']); + $this->assertEquals(2003, $meta['vin_decoded']['year']); + } + + public function test_no_api_key_returns_friendly_message(): void + { + $this->makeCtx(provider: 'claude', key: null); + + $part = Part::create([ + 'name' => 'X', 'unit' => 'buc', + 'buy_price' => 1, 'sell_price' => 2, + 'qty' => 0, 'is_active' => true, + ]); + + [$reply, $meta] = app(AiAssistantService::class)->suggestPrice($part); + $this->assertStringContainsString('API key', $reply); + $this->assertEquals('no_api_key', $meta['error']); + Http::assertNothingSent(); + } + + public function test_monthly_usage_aggregates_by_provider(): void + { + $ctx = $this->makeCtx(provider: 'claude', key: 'sk-fake'); + + $user = \App\Models\Tenant\User::create([ + 'company_id' => $ctx['company']->id, + 'name' => 'AI User', + 'email' => 'ai-' . uniqid() . '@example.com', + 'password' => bcrypt('x'), + 'role' => 'admin', + 'status' => 'active', + ]); + + $chat = AiChat::create([ + 'company_id' => $ctx['company']->id, + 'user_id' => $user->id, + 'provider' => 'claude', + ]); + + AiMessage::create([ + 'company_id' => $ctx['company']->id, + 'ai_chat_id' => $chat->id, + 'role' => 'assistant', + 'content' => 'X', + 'meta' => ['provider' => 'claude', 'tokens_in' => 100, 'tokens_out' => 50], + ]); + AiMessage::create([ + 'company_id' => $ctx['company']->id, + 'ai_chat_id' => $chat->id, + 'role' => 'assistant', + 'content' => 'Y', + 'meta' => ['provider' => 'gpt', 'tokens_in' => 200, 'tokens_out' => 80], + ]); + + $usage = app(AiAssistantService::class)->monthlyUsage(); + + $this->assertEquals(100, $usage['claude']['tokens_in']); + $this->assertEquals(50, $usage['claude']['tokens_out']); + $this->assertEquals(1, $usage['claude']['calls']); + $this->assertEquals(280, $usage['gpt']['tokens_in'] + $usage['gpt']['tokens_out']); + } + + private function makeCtx(string $provider, ?string $key): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'Test', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, + 'slug' => 'ai-' . uniqid(), + 'name' => 'AI Service', + 'city' => 'Chișinău', + 'status' => 'active', + 'settings' => [ + 'ai' => [ + 'default_provider' => $provider, + "{$provider}_key" => $key, + ], + ], + ]); + app(TenantManager::class)->setCurrent($company); + + $client = Client::create([ + 'name' => 'C', 'phone' => '+37399' . random_int(100000, 999999), + 'type' => 'individual', 'status' => 'active', + ]); + $vehicle = Vehicle::create([ + 'client_id' => $client->id, + 'make' => 'VW', 'model' => 'Golf', 'year' => 2015, + 'plate' => 'AI' . random_int(100, 999), + 'mileage' => 120000, + ]); + + return compact('company', 'client', 'vehicle'); + } +} diff --git a/tests/Unit/VinDecoderTest.php b/tests/Unit/VinDecoderTest.php new file mode 100644 index 0000000..6d4b65d --- /dev/null +++ b/tests/Unit/VinDecoderTest.php @@ -0,0 +1,78 @@ +d = new VinDecoder; + } + + public function test_rejects_invalid_length(): void + { + $r = $this->d->decode('SHORTVIN'); + $this->assertFalse($r['valid_length']); + } + + public function test_decodes_honda_vin_2003(): void + { + $r = $this->d->decode('1HGCM82633A123456'); + $this->assertTrue($r['valid_length']); + $this->assertEquals('1HG', $r['wmi']); + $this->assertEquals('Honda', $r['manufacturer']); + $this->assertEquals('USA', $r['country']); + $this->assertEquals('North America', $r['region']); + $this->assertEquals(2003, $r['year']); + } + + public function test_decodes_vw_european_vin(): void + { + // VW Bora 1999 + $r = $this->d->decode('WVWZZZ1JZXW000001'); + $this->assertEquals('VW', $r['manufacturer']); + $this->assertEquals('Germany', $r['country']); + $this->assertEquals(1999, $r['year']); + } + + public function test_decodes_audi_post_2010(): void + { + // Audi A4 2014 — year code E (pos 10), pos 7 alpha → 2014 not 1984 + $r = $this->d->decode('WAUZZZF40EA123456'); + $this->assertEquals('Audi', $r['manufacturer']); + $this->assertEquals(2014, $r['year']); + } + + public function test_decodes_dacia(): void + { + $r = $this->d->decode('UU1KSDAAH50123456'); + $this->assertEquals('Dacia', $r['manufacturer']); + $this->assertEquals('Romania', $r['country']); + } + + public function test_unknown_wmi_returns_null_manufacturer(): void + { + $r = $this->d->decode('ZZZZZZZZZZZZZZZZZ'); + $this->assertNull($r['manufacturer']); + } + + public function test_strips_lowercase_and_dashes(): void + { + $r = $this->d->decode('1hg-cm82-633a123456'); + $this->assertTrue($r['valid_length']); + $this->assertEquals('Honda', $r['manufacturer']); + } + + public function test_rejects_forbidden_chars_iqo(): void + { + // I, O, Q are stripped by VIN normalization; result is shorter, so invalid length. + $r = $this->d->decode('1HGCM82633AIOQ1234'); + $this->assertFalse($r['valid_length']); + } +}