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:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user