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:
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
+17
@@ -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()];
|
||||
}
|
||||
}
|
||||
+17
@@ -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'))
|
||||
|
||||
+26
@@ -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')
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user