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:
2026-05-27 20:24:09 +00:00
parent 85ef2f6e00
commit 1ff888131f
11 changed files with 818 additions and 0 deletions
+199
View File
@@ -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');
}
}