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