1ff888131f
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>
200 lines
6.9 KiB
PHP
200 lines
6.9 KiB
PHP
<?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');
|
|
}
|
|
}
|