282 lines
8.1 KiB
PHP
282 lines
8.1 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||
|
||
class ServiceSheet extends Model
|
||
{
|
||
use HasFactory, SoftDeletes;
|
||
|
||
protected $fillable = [
|
||
'user_id',
|
||
'business_setting_id',
|
||
'name',
|
||
'code',
|
||
'category',
|
||
'description',
|
||
'duration_minutes',
|
||
'total_csv',
|
||
'fixed_cost_portion',
|
||
'calculated_price',
|
||
'contribution_margin',
|
||
'margin_percentage',
|
||
'competitor_price',
|
||
'min_price',
|
||
'max_price',
|
||
'premium_multiplier',
|
||
'pricing_strategy',
|
||
'target_margin',
|
||
'psychological_pricing',
|
||
'is_active',
|
||
];
|
||
|
||
protected $casts = [
|
||
'duration_minutes' => 'decimal:2',
|
||
'total_csv' => 'decimal:2',
|
||
'fixed_cost_portion' => 'decimal:2',
|
||
'calculated_price' => 'decimal:2',
|
||
'contribution_margin' => 'decimal:2',
|
||
'margin_percentage' => 'decimal:2',
|
||
'competitor_price' => 'decimal:2',
|
||
'min_price' => 'decimal:2',
|
||
'max_price' => 'decimal:2',
|
||
'premium_multiplier' => 'decimal:4',
|
||
'target_margin' => 'decimal:2',
|
||
'psychological_pricing' => 'boolean',
|
||
'is_active' => 'boolean',
|
||
];
|
||
|
||
/**
|
||
* Relacionamento com usuário
|
||
*/
|
||
public function user(): BelongsTo
|
||
{
|
||
return $this->belongsTo(User::class);
|
||
}
|
||
|
||
/**
|
||
* Relacionamento com configuração de negócio
|
||
*/
|
||
public function businessSetting(): BelongsTo
|
||
{
|
||
return $this->belongsTo(BusinessSetting::class);
|
||
}
|
||
|
||
/**
|
||
* Itens/componentes do serviço (insumos)
|
||
*/
|
||
public function items(): HasMany
|
||
{
|
||
return $this->hasMany(ServiceSheetItem::class);
|
||
}
|
||
|
||
/**
|
||
* Calcula o CSV (Custo do Serviço Vendido) somando todos os itens
|
||
*
|
||
* @return float
|
||
*/
|
||
public function calculateTotalCsv(): float
|
||
{
|
||
return $this->items->sum(function ($item) {
|
||
return $item->unit_cost * $item->quantity_used;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Calcula o gasto fixo proporcional ao tempo do serviço
|
||
*
|
||
* @return float
|
||
*/
|
||
public function calculateFixedCostPortion(): float
|
||
{
|
||
$setting = $this->businessSetting;
|
||
|
||
if (!$setting) {
|
||
return 0;
|
||
}
|
||
|
||
return round($setting->fixed_cost_per_minute * $this->duration_minutes, 2);
|
||
}
|
||
|
||
/**
|
||
* Calcula o preço de venda usando a fórmula de serviços:
|
||
* Preço = (Gasto Fixo/Tempo + CSV) × Markup
|
||
*
|
||
* @return float
|
||
*/
|
||
public function calculatePrice(): float
|
||
{
|
||
$setting = $this->businessSetting;
|
||
|
||
if (!$setting) {
|
||
return 0;
|
||
}
|
||
|
||
$basePrice = $setting->calculateServicePrice(
|
||
(float) $this->duration_minutes,
|
||
(float) $this->total_csv
|
||
);
|
||
|
||
// Aplicar multiplicador premium
|
||
$price = $basePrice * (float) ($this->premium_multiplier ?? 1);
|
||
|
||
// Aplicar estratégia de preço baseada no competidor
|
||
if ($this->competitor_price && $this->competitor_price > 0) {
|
||
switch ($this->pricing_strategy) {
|
||
case 'aggressive':
|
||
$strategicPrice = $this->competitor_price * 0.95;
|
||
$price = min($price, $strategicPrice);
|
||
break;
|
||
case 'premium':
|
||
$strategicPrice = $this->competitor_price * 1.10;
|
||
$price = max($price, $strategicPrice);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Aplicar limites min/max
|
||
if ($this->min_price && $price < $this->min_price) {
|
||
$price = (float) $this->min_price;
|
||
}
|
||
if ($this->max_price && $price > $this->max_price) {
|
||
$price = (float) $this->max_price;
|
||
}
|
||
|
||
// Aplicar redondeo psicológico
|
||
if ($this->psychological_pricing) {
|
||
$price = $this->applyPsychologicalPricing($price);
|
||
}
|
||
|
||
return round($price, 2);
|
||
}
|
||
|
||
/**
|
||
* Aplica redondeo psicológico (€26.04 → €25.99)
|
||
*/
|
||
protected function applyPsychologicalPricing(float $price): float
|
||
{
|
||
$intPart = floor($price);
|
||
$decimal = $price - $intPart;
|
||
|
||
if ($decimal > 0 && $decimal <= 0.50) {
|
||
return $intPart - 0.01;
|
||
} elseif ($decimal > 0.50) {
|
||
return $intPart + 0.99;
|
||
}
|
||
|
||
return $price - 0.01;
|
||
}
|
||
|
||
/**
|
||
* Calcula a margem de contribuição
|
||
* Margem = Preço - CSV - Gasto Fixo Proporcional - Deduções
|
||
*/
|
||
public function calculateContributionMargin(): float
|
||
{
|
||
$price = (float) $this->calculated_price;
|
||
$csv = (float) $this->total_csv;
|
||
$fixedCost = (float) $this->fixed_cost_portion;
|
||
|
||
$setting = $this->businessSetting;
|
||
if (!$setting) {
|
||
return 0;
|
||
}
|
||
|
||
// Para B2C, remover IVA do preço antes de calcular margem
|
||
if ($setting->price_includes_tax) {
|
||
$vatRate = (float) ($setting->vat_rate ?? 0);
|
||
$price = $price / (1 + $vatRate / 100);
|
||
}
|
||
|
||
// Deduções variáveis
|
||
$deductions = $price * ($setting->total_variable_costs / 100);
|
||
|
||
return round($price - $csv - $fixedCost - $deductions, 2);
|
||
}
|
||
|
||
/**
|
||
* Recalcula todos os valores e salva
|
||
*/
|
||
public function recalculate(): self
|
||
{
|
||
$this->total_csv = $this->calculateTotalCsv();
|
||
$this->fixed_cost_portion = $this->calculateFixedCostPortion();
|
||
$this->calculated_price = $this->calculatePrice();
|
||
$this->contribution_margin = $this->calculateContributionMargin();
|
||
|
||
// Calcular margem percentual
|
||
if ($this->calculated_price > 0) {
|
||
$this->margin_percentage = round(
|
||
($this->contribution_margin / $this->calculated_price) * 100,
|
||
2
|
||
);
|
||
} else {
|
||
$this->margin_percentage = 0;
|
||
}
|
||
|
||
$this->save();
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Retorna o breakdown de custos para exibição
|
||
*/
|
||
public function getCostBreakdownAttribute(): array
|
||
{
|
||
$setting = $this->businessSetting;
|
||
$price = (float) $this->calculated_price;
|
||
$csv = (float) $this->total_csv;
|
||
$fixedCost = (float) $this->fixed_cost_portion;
|
||
|
||
// Calcular valores
|
||
$vatAmount = 0;
|
||
$priceWithoutVat = $price;
|
||
|
||
if ($setting && $setting->price_includes_tax) {
|
||
$vatRate = (float) ($setting->vat_rate ?? 0);
|
||
$priceWithoutVat = $price / (1 + $vatRate / 100);
|
||
$vatAmount = $price - $priceWithoutVat;
|
||
}
|
||
|
||
$deductions = $setting ? $priceWithoutVat * ($setting->total_variable_costs / 100) : 0;
|
||
|
||
return [
|
||
'duration_minutes' => (float) $this->duration_minutes,
|
||
'duration_hours' => round($this->duration_minutes / 60, 2),
|
||
'csv' => $csv,
|
||
'fixed_cost_portion' => $fixedCost,
|
||
'base_cost' => $csv + $fixedCost,
|
||
'markup' => $setting ? (float) $setting->markup_factor : 0,
|
||
'price_without_vat' => round($priceWithoutVat, 2),
|
||
'vat_amount' => round($vatAmount, 2),
|
||
'vat_rate' => $setting && $setting->price_includes_tax ? (float) $setting->vat_rate : 0,
|
||
'final_price' => $price,
|
||
'deductions' => round($deductions, 2),
|
||
'contribution_margin' => (float) $this->contribution_margin,
|
||
'margin_percentage' => (float) $this->margin_percentage,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Scope para buscar serviços de um usuário
|
||
*/
|
||
public function scopeOfUser($query, $userId)
|
||
{
|
||
return $query->where('user_id', $userId);
|
||
}
|
||
|
||
/**
|
||
* Scope para buscar serviços ativos
|
||
*/
|
||
public function scopeActive($query)
|
||
{
|
||
return $query->where('is_active', true);
|
||
}
|
||
}
|