Files
autocrm/app/Services/Pricing/PricingEngine.php
T
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

100 lines
3.2 KiB
PHP
Raw 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 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);
}
}