Stage 8 — Smart Pricing Engine: contextual coefficients

Contextual multipliers layered on top of base MarkupRule pricing, applied
per work-order line based on vehicle, client and urgency.

Schema:
- pricing_coefficients (multiplier, conditions JSON, priority, stackable)
- vehicles.vehicle_class (sedan/suv/commercial/hybrid/ev/premium)
- clients.is_vip
- work_orders.urgency (normal/urgent/express)

PricingEngine::quote(Part, Vehicle?, Client?, urgency):
- base = MarkupRule on buy_price (fallback sell_price or buy×1.30)
- context: class (explicit or inferred hybrid/ev from fuel), age, vip, urgency
- stackable coefficients all multiply; non-stackable take only the highest
- returns {base, final, applied[]} breakdown

PricingCoefficient::matches(ctx) — classes/age range/vip/urgency conditions
(empty = always applies).

Filament:
- PricingCoefficientResource with condition builder (classes, age, vip, urgency)
- vehicle_class select, client is_vip toggle, WO urgency select
- "Preț inteligent" action on WO parts shows breakdown + applies sell_price

Tests (6 new):
- base-only without coefficients; age coefficient gating; VIP; express urgency;
  stackable multiply vs non-stackable highest-wins; hybrid inferred from fuel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 05:40:27 +00:00
parent 954ba8f059
commit c90c35d930
16 changed files with 580 additions and 3 deletions
@@ -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)
@@ -0,0 +1,108 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
use App\Models\Tenant\PricingCoefficient;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class PricingCoefficientResource extends Resource
{
protected static ?string $model = PricingCoefficient::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-adjustments-horizontal';
protected static ?string $navigationLabel = 'Coeficienți preț';
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
protected static ?string $modelLabel = 'coeficient';
protected static ?string $pluralModelLabel = 'coeficienți preț';
protected static ?int $navigationSort = 46;
public static function form(Schema $schema): Schema
{
return $schema->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'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
use App\Filament\Tenant\Resources\PricingCoefficientResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePricingCoefficient extends CreateRecord
{
protected static string $resource = PricingCoefficientResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
use App\Filament\Tenant\Resources\PricingCoefficientResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPricingCoefficient extends EditRecord
{
protected static string $resource = PricingCoefficientResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
use App\Filament\Tenant\Resources\PricingCoefficientResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPricingCoefficients extends ListRecords
{
protected static string $resource = PricingCoefficientResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -72,6 +72,10 @@ class VehicleResource extends Resource
'Benzină' => '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),
]),
@@ -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'))
@@ -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')
+2 -1
View File
@@ -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. */
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class PricingCoefficient extends Model
{
use BelongsToTenant;
public const VEHICLE_CLASSES = [
'sedan' => '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;
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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',
];
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Services\Pricing;
use App\Models\Tenant\Client;
use App\Models\Tenant\MarkupRule;
use App\Models\Tenant\Part;
use App\Models\Tenant\PricingCoefficient;
use App\Models\Tenant\Vehicle;
/**
* Computes a contextual sell price for a part:
* base = MarkupRule applied to buy_price (or current sell_price fallback)
* final = base × product(matching coefficient multipliers)
*
* Coefficient stacking:
* - stackable coefficients all multiply together
* - among non-stackable matches, only the single highest multiplier applies
*/
class PricingEngine
{
/**
* @return array{base: float, final: float, applied: array<int, array{name:string, multiplier:float}>}
*/
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);
}
}
@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pricing_coefficients', function (Blueprint $t) {
$t->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');
}
};
@@ -0,0 +1,28 @@
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Preț de bază (markup)</span>
<span class="font-mono">{{ number_format($quote['base'], 2) }} MDL</span>
</div>
@if (! empty($quote['applied']))
<div class="border-t border-gray-200 dark:border-gray-700 pt-2 space-y-1">
@foreach ($quote['applied'] as $a)
<div class="flex justify-between">
<span>{{ $a['name'] }}</span>
<span class="font-mono text-primary-600">×{{ rtrim(rtrim(number_format($a['multiplier'], 3), '0'), '.') }}</span>
</div>
@endforeach
</div>
@else
<div class="text-gray-400 text-xs">Niciun coeficient contextual nu se aplică (preț de bază).</div>
@endif
<div class="flex justify-between border-t-2 border-gray-300 dark:border-gray-600 pt-2 text-base font-bold">
<span>Preț recomandat</span>
<span class="text-primary-600">{{ number_format($quote['final'], 2) }} MDL</span>
</div>
@if ((float) $item->sell_price > 0)
<div class="text-xs text-gray-500">Preț curent pe linie: {{ number_format((float) $item->sell_price, 2) }} MDL</div>
@endif
</div>
+134
View File
@@ -0,0 +1,134 @@
<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\MarkupRule;
use App\Models\Tenant\Part;
use App\Models\Tenant\PricingCoefficient;
use App\Models\Tenant\Vehicle;
use App\Services\Pricing\PricingEngine;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PricingEngineTest extends TestCase
{
use RefreshDatabase;
private PricingEngine $engine;
protected function setUp(): void
{
parent::setUp();
$this->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),
]);
}
}