Stage 16 — AI Layer: VIN decoder + diagnostic / parts / price helpers
VinDecoder (deterministic, no API): - ISO 3779/3780 parsing: WMI manufacturer (~60 brands), year (cyclical with post-2010 disambiguation via position 7), region, plant, NA checksum - Strip non-VIN chars, accept dashes/spaces, reject I/O/Q per spec AiAssistantService: - Refactored provider HTTP into postClaude/postOpenAI/postGemini so both chat history and one-shot calls share the same transport - singleShot(system, userPrompt, provider?) for fire-and-forget calls - 4 specialized helpers with tight prompts: - suggestDiagnosis(WO) — diagnostician based on complaint + VIN info - suggestParts(WO, task) — OEM parts list for an operation - suggestPrice(Part) — markup recommendation with justification - vinRecommendations(vin, mileage) — scheduled maintenance from decoded VIN - monthlyUsage() — token spend MTD by provider Filament: - VehicleResource: "Decode VIN" + "AI: recomandări" actions - WorkOrderResource Edit: "AI: sugerează diagnostic" header action - PartResource: "AI: preț recomandat" action - Shared views: filament.tenant.ai-reply, filament.tenant.vin-decode - AiAssistant page shows monthly token usage banner Tests (13 new): - 8 VinDecoder unit tests with real VIN samples (Honda 2003, VW 1999, Audi 2014, Dacia, unknown WMI, lowercase/dashes, forbidden chars) - 5 AiHelpers feature tests with Http::fake covering all providers + no-key fallback + token usage aggregation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, array{tokens_in:int, tokens_out:int, calls:int}>
|
||||
*/
|
||||
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 = [];
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
/**
|
||||
* Deterministic VIN decoder. Extracts year, country and manufacturer from the
|
||||
* 17-character Vehicle Identification Number per ISO 3779/3780. No external API.
|
||||
*
|
||||
* Reliable signals:
|
||||
* - position 1 → world region (geographic prefix)
|
||||
* - positions 1-3 → WMI (world manufacturer identifier)
|
||||
* - position 10 → model year (cyclical 30-year mapping)
|
||||
* - position 11 → assembly plant code
|
||||
* - position 9 → check digit (NA-spec checksum, optional verification)
|
||||
*
|
||||
* Granular model/trim info requires a licensed database (TecDoc / NHTSA API);
|
||||
* those are out of scope here. We return a "best-effort" identification.
|
||||
*/
|
||||
class VinDecoder
|
||||
{
|
||||
private const YEAR_CODES = [
|
||||
'A' => [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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
<div class="space-y-3">
|
||||
@if ($isError)
|
||||
<div class="p-3 rounded-md bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-700 text-sm text-yellow-900 dark:text-yellow-100">
|
||||
{{ $reply }}
|
||||
</div>
|
||||
@else
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap leading-relaxed text-gray-900 dark:text-gray-100">{{ $reply }}</div>
|
||||
@endif
|
||||
|
||||
@if (! $isError && ($provider || $tokensIn !== null))
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-2 flex flex-wrap gap-3">
|
||||
@if ($provider) <span>Provider: <strong>{{ strtoupper($provider) }}</strong></span> @endif
|
||||
@if ($tokensIn !== null || $tokensOut !== null)
|
||||
<span>Tokens: <strong>{{ ($tokensIn ?? 0) + ($tokensOut ?? 0) }}</strong> ({{ $tokensIn ?? 0 }} in / {{ $tokensOut ?? 0 }} out)</span>
|
||||
@endif
|
||||
@if ($latency !== null) <span>Latency: {{ $latency }} ms</span> @endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -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)
|
||||
<div style="margin-bottom:12px;padding:10px 14px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;font-size:12px;color:#1e3a8a;">
|
||||
<strong>Tokens consumate luna aceasta:</strong>
|
||||
{{ number_format($totalTokens) }} tokens · {{ $totalCalls }} cereri
|
||||
@foreach ($usage as $provider => $u)
|
||||
· {{ strtoupper($provider) }}: {{ number_format($u['tokens_in'] + $u['tokens_out']) }}
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="ai-wrap" x-data x-init="$nextTick(() => { const m = $refs.msgs; if (m) m.scrollTop = m.scrollHeight; })">
|
||||
{{-- Sidebar: chat history --}}
|
||||
<div class="ai-side">
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
@php
|
||||
$valid = $info['valid_length'] ?? false;
|
||||
@endphp
|
||||
<div class="space-y-4">
|
||||
@if (! $valid)
|
||||
<div class="p-3 rounded-md bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-700 text-sm text-yellow-900 dark:text-yellow-100">
|
||||
{{ $info['reason'] ?? 'VIN invalid.' }}
|
||||
</div>
|
||||
@else
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">VIN</dt>
|
||||
<dd class="font-mono text-gray-900 dark:text-gray-100">{{ $info['vin'] }}</dd>
|
||||
|
||||
<dt class="text-gray-500 dark:text-gray-400">Regiune</dt>
|
||||
<dd class="text-gray-900 dark:text-gray-100">{{ $info['region'] ?? '—' }}</dd>
|
||||
|
||||
<dt class="text-gray-500 dark:text-gray-400">Țară</dt>
|
||||
<dd class="text-gray-900 dark:text-gray-100">{{ $info['country'] ?? '—' }}</dd>
|
||||
|
||||
<dt class="text-gray-500 dark:text-gray-400">Producător</dt>
|
||||
<dd class="text-gray-900 dark:text-gray-100 font-semibold">{{ $info['manufacturer'] ?? '— (WMI necunoscut)' }}</dd>
|
||||
|
||||
<dt class="text-gray-500 dark:text-gray-400">An model</dt>
|
||||
<dd class="text-gray-900 dark:text-gray-100 font-semibold">{{ $info['year'] ?? '—' }}</dd>
|
||||
|
||||
<dt class="text-gray-500 dark:text-gray-400">Cod uzină</dt>
|
||||
<dd class="font-mono text-gray-900 dark:text-gray-100">{{ $info['plant'] ?? '—' }}</dd>
|
||||
|
||||
<dt class="text-gray-500 dark:text-gray-400">Checksum (ISO)</dt>
|
||||
<dd class="text-gray-900 dark:text-gray-100">
|
||||
@if ($info['checksum_valid'])
|
||||
<span class="text-green-600 dark:text-green-400">✓ valid</span>
|
||||
@else
|
||||
<span class="text-gray-500">— (multe VIN-uri europene nu respectă checksum-ul NA)</span>
|
||||
@endif
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
Detalii granulare (model, motorizare) necesită bază TecDoc/NHTSA. Folosește butonul „AI: recomandări" pentru sugestii bazate pe an + producător.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Tenant\AiChat;
|
||||
use App\Models\Tenant\AiMessage;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Services\Ai\AiAssistantService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiHelpersTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_suggest_diagnosis_calls_provider_with_context(): void
|
||||
{
|
||||
$ctx = $this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Ai\VinDecoder;
|
||||
use Tests\TestCase;
|
||||
|
||||
class VinDecoderTest extends TestCase
|
||||
{
|
||||
private VinDecoder $d;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user