Files
autocrm/tests/Feature/PricingEngineTest.php
Vasyka c90c35d930 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>
2026-05-28 05:40:27 +00:00

135 lines
6.1 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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),
]);
}
}