diff --git a/app/Filament/Tenant/Resources/ClientResource.php b/app/Filament/Tenant/Resources/ClientResource.php index 45ee540..b31f837 100644 --- a/app/Filament/Tenant/Resources/ClientResource.php +++ b/app/Filament/Tenant/Resources/ClientResource.php @@ -65,6 +65,9 @@ class ClientResource extends Resource ]) ->default('active') ->required(), + Forms\Components\Toggle::make('is_vip') + ->label('Client VIP') + ->helperText('Activează coeficienții de preț VIP pe fișele acestui client.'), ]), Schemas\Components\Section::make('Contacte') ->columns(2) diff --git a/app/Filament/Tenant/Resources/PricingCoefficientResource.php b/app/Filament/Tenant/Resources/PricingCoefficientResource.php new file mode 100644 index 0000000..ddbf8f4 --- /dev/null +++ b/app/Filament/Tenant/Resources/PricingCoefficientResource.php @@ -0,0 +1,108 @@ +components([ + Schemas\Components\Section::make('Coeficient') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('name')->label('Denumire')->required() + ->placeholder('ex: Mașină veche, Client VIP, Express')->columnSpanFull(), + Forms\Components\TextInput::make('multiplier') + ->label('Multiplicator') + ->numeric() + ->required() + ->default(1.10) + ->helperText('1.15 = +15% peste prețul de bază. 0.95 = -5%.'), + Forms\Components\TextInput::make('priority')->label('Prioritate')->numeric()->default(100), + Forms\Components\Toggle::make('stackable') + ->label('Cumulabil') + ->default(true) + ->helperText('Cumulabil = se înmulțește cu alți coeficienți. Necumulabil = doar cel mai mare necumulabil se aplică.'), + Forms\Components\Toggle::make('is_active')->label('Activ')->default(true), + ]), + Schemas\Components\Section::make('Condiții (toate trebuie îndeplinite)') + ->description('Lasă gol = se aplică mereu. Combină condițiile pentru a ținti situații specifice.') + ->columns(2) + ->schema([ + Forms\Components\CheckboxList::make('conditions.classes') + ->label('Clase auto') + ->options(PricingCoefficient::VEHICLE_CLASSES) + ->columns(2) + ->columnSpanFull(), + Forms\Components\TextInput::make('conditions.age_min')->label('Vârstă min (ani)')->numeric(), + Forms\Components\TextInput::make('conditions.age_max')->label('Vârstă max (ani)')->numeric(), + Forms\Components\Toggle::make('conditions.client_vip')->label('Doar clienți VIP'), + Forms\Components\CheckboxList::make('conditions.urgency') + ->label('Urgență') + ->options(PricingCoefficient::URGENCY) + ->columns(3) + ->columnSpanFull(), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('priority')->label('Prio')->sortable()->alignRight(), + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('multiplier') + ->label('Multiplicator') + ->formatStateUsing(fn ($s) => '×' . rtrim(rtrim(number_format((float) $s, 3), '0'), '.')) + ->alignRight() + ->color(fn ($s) => (float) $s >= 1 ? 'success' : 'warning'), + Tables\Columns\IconColumn::make('stackable')->label('Cumul.')->boolean(), + Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_active')->label('Active'), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->emptyStateHeading('Niciun coeficient') + ->emptyStateDescription('Adaugă reguli care ajustează prețul în funcție de vârsta mașinii, clasă (SUV, comercial, hibrid), client VIP sau urgență. Se aplică peste markup-ul de bază pe fișele de lucru.') + ->emptyStateIcon('heroicon-o-adjustments-horizontal') + ->defaultSort('priority'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPricingCoefficients::route('/'), + 'create' => Pages\CreatePricingCoefficient::route('/create'), + 'edit' => Pages\EditPricingCoefficient::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/PricingCoefficientResource/Pages/CreatePricingCoefficient.php b/app/Filament/Tenant/Resources/PricingCoefficientResource/Pages/CreatePricingCoefficient.php new file mode 100644 index 0000000..ac00e95 --- /dev/null +++ b/app/Filament/Tenant/Resources/PricingCoefficientResource/Pages/CreatePricingCoefficient.php @@ -0,0 +1,11 @@ + 'Benzină', 'Diesel' => 'Diesel', 'Hybrid' => 'Hybrid', 'EV' => 'Electric', 'GPL' => 'GPL', 'GNC' => 'GNC', ]), + Forms\Components\Select::make('vehicle_class') + ->label('Clasă (pentru pricing)') + ->options(\App\Models\Tenant\PricingCoefficient::VEHICLE_CLASSES) + ->helperText('Folosită de coeficienții de preț. Hibrid/EV se deduc și din combustibil.'), Forms\Components\TextInput::make('mileage')->label('Kilometraj')->numeric()->default(0), Forms\Components\TextInput::make('color')->maxLength(40), ]), diff --git a/app/Filament/Tenant/Resources/WorkOrderResource.php b/app/Filament/Tenant/Resources/WorkOrderResource.php index 76bad9c..bcdbd64 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource.php @@ -72,6 +72,11 @@ class WorkOrderResource extends Resource ->options(WorkOrder::STATUSES) ->default('new') ->required(), + Forms\Components\Select::make('urgency') + ->label('Urgență') + ->options(\App\Models\Tenant\PricingCoefficient::URGENCY) + ->default('normal') + ->required(), Forms\Components\Select::make('client_id') ->label('Client') ->options(fn () => Client::pluck('name', 'id')) diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php index 8588750..d634e2c 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php @@ -85,6 +85,32 @@ class PartsRelationManager extends RelationManager Actions\CreateAction::make(), ]) ->actions([ + Actions\Action::make('smart_price') + ->label('Preț inteligent') + ->icon('heroicon-m-sparkles') + ->color('primary') + ->visible(fn (WorkOrderPart $r) => (bool) $r->part_id) + ->modalHeading('Preț contextual') + ->modalSubmitActionLabel('Aplică prețul') + ->modalContent(function (WorkOrderPart $r) { + $wo = $r->workOrder; + $part = $r->part; + $quote = app(\App\Services\Pricing\PricingEngine::class)->quote( + $part, $wo?->vehicle, $wo?->client, $wo?->urgency ?? 'normal' + ); + return view('filament.tenant.smart-price', ['quote' => $quote, 'item' => $r]); + }) + ->action(function (WorkOrderPart $r) { + $wo = $r->workOrder; + $quote = app(\App\Services\Pricing\PricingEngine::class)->quote( + $r->part, $wo?->vehicle, $wo?->client, $wo?->urgency ?? 'normal' + ); + $r->sell_price = $quote['final']; + $r->save(); + Notification::make() + ->title('Preț actualizat: ' . number_format($quote['final'], 2) . ' MDL') + ->success()->send(); + }), Actions\Action::make('issue_now') ->label('Eliberează') ->icon('heroicon-m-arrow-up-on-square') diff --git a/app/Models/Tenant/Client.php b/app/Models/Tenant/Client.php index 07e6267..634c20d 100644 --- a/app/Models/Tenant/Client.php +++ b/app/Models/Tenant/Client.php @@ -18,7 +18,7 @@ class Client extends Model 'phone', 'phone_alt', 'email', 'telegram', 'telegram_chat_id', 'whatsapp', 'viber', 'notify_prefs', - 'source', 'marketing_channel', 'status', + 'source', 'marketing_channel', 'status', 'is_vip', 'balance', 'discount_pct', 'notes', 'assigned_to', 'last_contact_at', ]; @@ -28,6 +28,7 @@ class Client extends Model 'discount_pct' => 'decimal:2', 'last_contact_at' => 'datetime', 'notify_prefs' => 'array', + 'is_vip' => 'boolean', ]; /** Normalize a phone number to E.164-ish digits for matching. */ diff --git a/app/Models/Tenant/PricingCoefficient.php b/app/Models/Tenant/PricingCoefficient.php new file mode 100644 index 0000000..e01e642 --- /dev/null +++ b/app/Models/Tenant/PricingCoefficient.php @@ -0,0 +1,79 @@ + 'Sedan / Hatchback', + 'suv' => 'SUV / Crossover', + 'commercial' => 'Comercial (van/camion)', + 'hybrid' => 'Hibrid', + 'ev' => 'Electric (EV)', + 'premium' => 'Premium / Lux', + ]; + + public const URGENCY = [ + 'normal' => 'Normal', + 'urgent' => 'Urgent', + 'express' => 'Express', + ]; + + protected $fillable = [ + 'company_id', 'name', 'multiplier', 'conditions', + 'priority', 'stackable', 'is_active', + ]; + + protected $casts = [ + 'multiplier' => 'decimal:3', + 'conditions' => 'array', + 'stackable' => 'boolean', + 'is_active' => 'boolean', + ]; + + /** + * Does this coefficient apply to the given pricing context? + * + * @param array{class?:?string, age?:?int, vip?:bool, urgency?:string} $ctx + */ + public function matches(array $ctx): bool + { + $c = (array) $this->conditions; + + // Vehicle class — if rule lists classes, context class must be among them. + $classes = (array) ($c['classes'] ?? []); + if (! empty($classes)) { + if (empty($ctx['class']) || ! in_array($ctx['class'], $classes, true)) { + return false; + } + } + + // Vehicle age range. + if (isset($c['age_min']) && $c['age_min'] !== null && $c['age_min'] !== '') { + if (($ctx['age'] ?? null) === null || $ctx['age'] < (int) $c['age_min']) return false; + } + if (isset($c['age_max']) && $c['age_max'] !== null && $c['age_max'] !== '') { + if (($ctx['age'] ?? null) === null || $ctx['age'] > (int) $c['age_max']) return false; + } + + // VIP requirement (true = only VIP, false/null = ignore). + if (! empty($c['client_vip'])) { + if (empty($ctx['vip'])) return false; + } + + // Urgency — if rule lists urgencies, context must match. + $urg = (array) ($c['urgency'] ?? []); + if (! empty($urg)) { + if (empty($ctx['urgency']) || ! in_array($ctx['urgency'], $urg, true)) { + return false; + } + } + + return true; + } +} diff --git a/app/Models/Tenant/Vehicle.php b/app/Models/Tenant/Vehicle.php index 00b1adb..338c902 100644 --- a/app/Models/Tenant/Vehicle.php +++ b/app/Models/Tenant/Vehicle.php @@ -15,7 +15,7 @@ class Vehicle extends Model protected $fillable = [ 'company_id', 'client_id', 'make', 'model', 'year', 'vin', 'plate', - 'engine', 'gearbox', 'fuel', 'mileage', 'color', 'notes', + 'engine', 'gearbox', 'fuel', 'vehicle_class', 'mileage', 'color', 'notes', ]; public function client(): BelongsTo diff --git a/app/Models/Tenant/WorkOrder.php b/app/Models/Tenant/WorkOrder.php index 0b5b185..5669001 100644 --- a/app/Models/Tenant/WorkOrder.php +++ b/app/Models/Tenant/WorkOrder.php @@ -38,7 +38,7 @@ class WorkOrder extends Model implements HasMedia 'client_id', 'vehicle_id', 'master_id', 'deal_id', 'appointment_id', 'opened_at', 'closed_at', 'mileage_in', 'mileage_out', 'complaint', 'diagnosis', 'recommendations', - 'status', 'pay_status', 'approved', 'approved_at', + 'status', 'urgency', 'pay_status', 'approved', 'approved_at', 'discount_pct', 'total', 'eta_at', 'tracking_token', ]; diff --git a/app/Services/Pricing/PricingEngine.php b/app/Services/Pricing/PricingEngine.php new file mode 100644 index 0000000..244eae8 --- /dev/null +++ b/app/Services/Pricing/PricingEngine.php @@ -0,0 +1,99 @@ +} + */ + public function quote( + Part $part, + ?Vehicle $vehicle = null, + ?Client $client = null, + string $urgency = 'normal', + ): array { + $base = $this->basePrice($part); + + $ctx = [ + 'class' => $this->vehicleClass($vehicle), + 'age' => $this->vehicleAge($vehicle), + 'vip' => (bool) ($client?->is_vip), + 'urgency' => $urgency ?: 'normal', + ]; + + $coefficients = PricingCoefficient::where('is_active', true) + ->orderBy('priority') + ->get() + ->filter(fn (PricingCoefficient $c) => $c->matches($ctx)); + + $applied = []; + $factor = 1.0; + + // Stackable: multiply all. + foreach ($coefficients->where('stackable', true) as $c) { + $factor *= (float) $c->multiplier; + $applied[] = ['name' => $c->name, 'multiplier' => (float) $c->multiplier]; + } + + // Non-stackable: take only the strongest one. + $nonStack = $coefficients->where('stackable', false) + ->sortByDesc(fn ($c) => (float) $c->multiplier) + ->first(); + if ($nonStack) { + $factor *= (float) $nonStack->multiplier; + $applied[] = ['name' => $nonStack->name, 'multiplier' => (float) $nonStack->multiplier]; + } + + return [ + 'base' => round($base, 2), + 'final' => round($base * $factor, 2), + 'applied' => $applied, + ]; + } + + private function basePrice(Part $part): float + { + $rule = MarkupRule::bestForPart($part); + if ($rule) { + return (float) $part->buy_price * (1 + (float) $rule->markup_pct / 100); + } + // Fall back to existing sell_price, or buy_price + 30%. + if ((float) $part->sell_price > 0) return (float) $part->sell_price; + return (float) $part->buy_price * 1.30; + } + + /** Explicit vehicle_class, else inferred from fuel (hybrid/EV). */ + private function vehicleClass(?Vehicle $vehicle): ?string + { + if (! $vehicle) return null; + if ($vehicle->vehicle_class) return $vehicle->vehicle_class; + + $fuel = mb_strtolower((string) $vehicle->fuel); + if (str_contains($fuel, 'hybrid') || str_contains($fuel, 'hibrid')) return 'hybrid'; + if (str_contains($fuel, 'electric') || $fuel === 'ev' || str_contains($fuel, 'electr')) return 'ev'; + + return null; + } + + private function vehicleAge(?Vehicle $vehicle): ?int + { + if (! $vehicle || ! $vehicle->year) return null; + return max(0, (int) date('Y') - (int) $vehicle->year); + } +} diff --git a/database/migrations/2026_05_28_150000_create_pricing_engine.php b/database/migrations/2026_05_28_150000_create_pricing_engine.php new file mode 100644 index 0000000..ede09f5 --- /dev/null +++ b/database/migrations/2026_05_28_150000_create_pricing_engine.php @@ -0,0 +1,45 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->string('name'); + $t->decimal('multiplier', 6, 3)->default(1); // 1.15 = +15% + $t->json('conditions')->nullable(); // {classes:[], age_min, age_max, client_vip, urgency:[]} + $t->unsignedSmallInteger('priority')->default(100); + $t->boolean('stackable')->default(true); + $t->boolean('is_active')->default(true); + $t->timestamps(); + + $t->index(['company_id', 'is_active', 'priority']); + }); + + Schema::table('vehicles', function (Blueprint $t) { + $t->string('vehicle_class', 24)->nullable()->after('fuel'); + }); + + Schema::table('clients', function (Blueprint $t) { + $t->boolean('is_vip')->default(false)->after('status'); + }); + + Schema::table('work_orders', function (Blueprint $t) { + $t->string('urgency', 16)->default('normal')->after('status'); // normal / urgent / express + }); + } + + public function down(): void + { + Schema::table('work_orders', fn (Blueprint $t) => $t->dropColumn('urgency')); + Schema::table('clients', fn (Blueprint $t) => $t->dropColumn('is_vip')); + Schema::table('vehicles', fn (Blueprint $t) => $t->dropColumn('vehicle_class')); + Schema::dropIfExists('pricing_coefficients'); + } +}; diff --git a/resources/views/filament/tenant/smart-price.blade.php b/resources/views/filament/tenant/smart-price.blade.php new file mode 100644 index 0000000..aecbf5e --- /dev/null +++ b/resources/views/filament/tenant/smart-price.blade.php @@ -0,0 +1,28 @@ +
+
+ Preț de bază (markup) + {{ number_format($quote['base'], 2) }} MDL +
+ + @if (! empty($quote['applied'])) +
+ @foreach ($quote['applied'] as $a) +
+ {{ $a['name'] }} + ×{{ rtrim(rtrim(number_format($a['multiplier'], 3), '0'), '.') }} +
+ @endforeach +
+ @else +
Niciun coeficient contextual nu se aplică (preț de bază).
+ @endif + +
+ Preț recomandat + {{ number_format($quote['final'], 2) }} MDL +
+ + @if ((float) $item->sell_price > 0) +
Preț curent pe linie: {{ number_format((float) $item->sell_price, 2) }} MDL
+ @endif +
diff --git a/tests/Feature/PricingEngineTest.php b/tests/Feature/PricingEngineTest.php new file mode 100644 index 0000000..5d5435c --- /dev/null +++ b/tests/Feature/PricingEngineTest.php @@ -0,0 +1,134 @@ +engine = app(PricingEngine::class); + $this->makeCompany('pricing'); + } + + public function test_base_markup_only_when_no_coefficients(): void + { + MarkupRule::create(['type' => 'category', 'key' => 'Frâne', 'markup_pct' => 50, 'priority' => 10, 'is_active' => true]); + $part = Part::create(['name' => 'Disc', 'category' => 'Frâne', 'buy_price' => 100, 'sell_price' => 0, 'qty' => 1, 'unit' => 'buc', 'is_active' => true]); + + $q = $this->engine->quote($part); + $this->assertEquals(150.0, $q['base']); + $this->assertEquals(150.0, $q['final']); + $this->assertEmpty($q['applied']); + } + + public function test_old_vehicle_age_coefficient_applies(): void + { + $part = Part::create(['name' => 'Filtru', 'buy_price' => 100, 'sell_price' => 130, 'qty' => 1, 'unit' => 'buc', 'is_active' => true]); + PricingCoefficient::create([ + 'name' => 'Mașină veche', 'multiplier' => 1.20, 'priority' => 10, + 'stackable' => true, 'is_active' => true, + 'conditions' => ['age_min' => 10], + ]); + + $oldCar = $this->makeVehicle(year: (int) date('Y') - 15); + $q = $this->engine->quote($part, $oldCar); + $this->assertEqualsWithDelta(156.0, $q['final'], 0.01); // 130 × 1.20 + + $newCar = $this->makeVehicle(year: (int) date('Y') - 2); + $q2 = $this->engine->quote($part, $newCar); + $this->assertEqualsWithDelta(130.0, $q2['final'], 0.01); // no coefficient + } + + public function test_vip_client_coefficient(): void + { + $part = Part::create(['name' => 'P', 'buy_price' => 100, 'sell_price' => 100, 'qty' => 1, 'unit' => 'buc', 'is_active' => true]); + PricingCoefficient::create([ + 'name' => 'Discount VIP', 'multiplier' => 0.90, 'priority' => 10, + 'stackable' => true, 'is_active' => true, + 'conditions' => ['client_vip' => true], + ]); + + $vip = Client::create(['name' => 'VIP', 'phone' => '+37360000001', 'type' => 'individual', 'status' => 'active', 'is_vip' => true]); + $regular = Client::create(['name' => 'Reg', 'phone' => '+37360000002', 'type' => 'individual', 'status' => 'active', 'is_vip' => false]); + + $this->assertEqualsWithDelta(90.0, $this->engine->quote($part, null, $vip)['final'], 0.01); + $this->assertEqualsWithDelta(100.0, $this->engine->quote($part, null, $regular)['final'], 0.01); + } + + public function test_express_urgency_coefficient(): void + { + $part = Part::create(['name' => 'P', 'buy_price' => 100, 'sell_price' => 100, 'qty' => 1, 'unit' => 'buc', 'is_active' => true]); + PricingCoefficient::create([ + 'name' => 'Express', 'multiplier' => 1.30, 'priority' => 10, + 'stackable' => true, 'is_active' => true, + 'conditions' => ['urgency' => ['express']], + ]); + + $this->assertEqualsWithDelta(130.0, $this->engine->quote($part, null, null, 'express')['final'], 0.01); + $this->assertEqualsWithDelta(100.0, $this->engine->quote($part, null, null, 'normal')['final'], 0.01); + } + + public function test_stackable_multiply_nonstackable_take_highest(): void + { + $part = Part::create(['name' => 'P', 'buy_price' => 100, 'sell_price' => 100, 'qty' => 1, 'unit' => 'buc', 'is_active' => true]); + + // Two stackable + two non-stackable, all matching (no conditions). + PricingCoefficient::create(['name' => 'S1', 'multiplier' => 1.10, 'stackable' => true, 'is_active' => true, 'priority' => 1]); + PricingCoefficient::create(['name' => 'S2', 'multiplier' => 1.20, 'stackable' => true, 'is_active' => true, 'priority' => 2]); + PricingCoefficient::create(['name' => 'N1', 'multiplier' => 1.50, 'stackable' => false, 'is_active' => true, 'priority' => 3]); + PricingCoefficient::create(['name' => 'N2', 'multiplier' => 1.30, 'stackable' => false, 'is_active' => true, 'priority' => 4]); + + // 100 × 1.10 × 1.20 × max(1.50,1.30) = 100 × 1.32 × 1.50 = 198 + $q = $this->engine->quote($part); + $this->assertEqualsWithDelta(198.0, $q['final'], 0.01); + $this->assertCount(3, $q['applied']); // S1, S2, N1 (only strongest non-stackable) + } + + public function test_hybrid_inferred_from_fuel(): void + { + $part = Part::create(['name' => 'P', 'buy_price' => 100, 'sell_price' => 100, 'qty' => 1, 'unit' => 'buc', 'is_active' => true]); + PricingCoefficient::create([ + 'name' => 'Hibrid', 'multiplier' => 1.25, 'stackable' => true, 'is_active' => true, 'priority' => 1, + 'conditions' => ['classes' => ['hybrid']], + ]); + + // No explicit vehicle_class, but fuel = Hybrid. + $car = $this->makeVehicle(year: 2020, fuel: 'Hybrid'); + $this->assertEqualsWithDelta(125.0, $this->engine->quote($part, $car)['final'], 0.01); + } + + private function makeCompany(string $slug): Company + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create(['plan_id' => $plan->id, 'slug' => $slug, 'name' => 'P', 'status' => 'active']); + app(TenantManager::class)->setCurrent($company); + return $company; + } + + private function makeVehicle(int $year, ?string $fuel = null): Vehicle + { + $client = Client::create(['name' => 'C', 'phone' => '+3736' . random_int(1000000, 9999999), 'type' => 'individual', 'status' => 'active']); + return Vehicle::create([ + 'client_id' => $client->id, + 'make' => 'X', 'model' => 'Y', 'year' => $year, + 'fuel' => $fuel, 'plate' => 'P' . random_int(100, 999), + ]); + } +}