420 lines
12 KiB
PHP
420 lines
12 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\BelongsToMany;
|
|
use Carbon\Carbon;
|
|
|
|
class PromotionalCampaign extends Model
|
|
{
|
|
use HasFactory;
|
|
|
|
protected $fillable = [
|
|
'user_id',
|
|
'name',
|
|
'code',
|
|
'description',
|
|
'discount_type',
|
|
'discount_value',
|
|
'start_date',
|
|
'end_date',
|
|
'start_time',
|
|
'end_time',
|
|
'is_active',
|
|
'apply_to_all_products',
|
|
'min_price',
|
|
'min_margin_percent',
|
|
'protect_against_loss',
|
|
'margin_warning_threshold',
|
|
'show_original_price',
|
|
'show_discount_badge',
|
|
'badge_text',
|
|
'badge_color',
|
|
'priority',
|
|
];
|
|
|
|
protected $casts = [
|
|
'start_date' => 'date',
|
|
'end_date' => 'date',
|
|
'is_active' => 'boolean',
|
|
'apply_to_all_products' => 'boolean',
|
|
'protect_against_loss' => 'boolean',
|
|
'show_original_price' => 'boolean',
|
|
'show_discount_badge' => 'boolean',
|
|
'discount_value' => 'decimal:2',
|
|
'min_price' => 'decimal:2',
|
|
'min_margin_percent' => 'decimal:2',
|
|
'margin_warning_threshold' => 'decimal:2',
|
|
'priority' => 'integer',
|
|
];
|
|
|
|
// Tipos de desconto
|
|
const TYPE_PERCENTAGE = 'percentage';
|
|
const TYPE_FIXED = 'fixed';
|
|
const TYPE_PRICE_OVERRIDE = 'price_override';
|
|
|
|
// Presets de campanhas
|
|
const PRESETS = [
|
|
'black_friday' => [
|
|
'name' => 'Black Friday',
|
|
'discount_type' => 'percentage',
|
|
'discount_value' => 30,
|
|
'badge_text' => 'BLACK FRIDAY',
|
|
'badge_color' => '#000000',
|
|
'show_discount_badge' => true,
|
|
],
|
|
'cyber_monday' => [
|
|
'name' => 'Cyber Monday',
|
|
'discount_type' => 'percentage',
|
|
'discount_value' => 25,
|
|
'badge_text' => 'CYBER MONDAY',
|
|
'badge_color' => '#3b82f6',
|
|
'show_discount_badge' => true,
|
|
],
|
|
'christmas' => [
|
|
'name' => 'Natal',
|
|
'discount_type' => 'percentage',
|
|
'discount_value' => 20,
|
|
'badge_text' => '🎄 NATAL',
|
|
'badge_color' => '#22c55e',
|
|
'show_discount_badge' => true,
|
|
],
|
|
'new_year' => [
|
|
'name' => 'Ano Novo',
|
|
'discount_type' => 'percentage',
|
|
'discount_value' => 15,
|
|
'badge_text' => '🎆 ANO NOVO',
|
|
'badge_color' => '#f59e0b',
|
|
'show_discount_badge' => true,
|
|
],
|
|
'summer_sale' => [
|
|
'name' => 'Liquidação de Verão',
|
|
'discount_type' => 'percentage',
|
|
'discount_value' => 40,
|
|
'badge_text' => 'LIQUIDAÇÃO',
|
|
'badge_color' => '#ef4444',
|
|
'show_discount_badge' => true,
|
|
],
|
|
'flash_sale' => [
|
|
'name' => 'Flash Sale',
|
|
'discount_type' => 'percentage',
|
|
'discount_value' => 50,
|
|
'badge_text' => '⚡ FLASH SALE',
|
|
'badge_color' => '#8b5cf6',
|
|
'show_discount_badge' => true,
|
|
],
|
|
];
|
|
|
|
/**
|
|
* Usuário dono da campanha
|
|
*/
|
|
public function user(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
/**
|
|
* Produtos na campanha
|
|
*/
|
|
public function products(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(ProductSheet::class, 'campaign_products', 'campaign_id', 'product_sheet_id')
|
|
->withPivot(['discount_type', 'discount_value', 'promotional_price', 'promo_margin', 'promo_margin_percent', 'is_profitable'])
|
|
->withTimestamps();
|
|
}
|
|
|
|
/**
|
|
* Verifica se a campanha está ativa agora
|
|
*/
|
|
public function isCurrentlyActive(): bool
|
|
{
|
|
if (!$this->is_active) {
|
|
return false;
|
|
}
|
|
|
|
$now = Carbon::now();
|
|
$today = $now->toDateString();
|
|
|
|
// Verificar data
|
|
if ($today < $this->start_date->toDateString() || $today > $this->end_date->toDateString()) {
|
|
return false;
|
|
}
|
|
|
|
// Verificar hora se definida
|
|
if ($this->start_time && $this->end_time) {
|
|
$currentTime = $now->format('H:i:s');
|
|
|
|
// Se for o primeiro dia, verificar hora de início
|
|
if ($today === $this->start_date->toDateString() && $currentTime < $this->start_time) {
|
|
return false;
|
|
}
|
|
|
|
// Se for o último dia, verificar hora de fim
|
|
if ($today === $this->end_date->toDateString() && $currentTime > $this->end_time) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Calcula o preço promocional para um produto
|
|
* COM PROTEÇÃO DE RENTABILIDADE
|
|
*/
|
|
public function calculatePromotionalPrice(ProductSheet $product, ?array $override = null): float
|
|
{
|
|
$basePrice = (float) ($product->final_price ?? $product->sale_price);
|
|
|
|
if ($basePrice <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
$discountType = $override['discount_type'] ?? $this->discount_type;
|
|
$discountValue = (float) ($override['discount_value'] ?? $this->discount_value);
|
|
|
|
$promoPrice = $basePrice;
|
|
|
|
switch ($discountType) {
|
|
case self::TYPE_PERCENTAGE:
|
|
$promoPrice = $basePrice * (1 - ($discountValue / 100));
|
|
break;
|
|
|
|
case self::TYPE_FIXED:
|
|
$promoPrice = $basePrice - $discountValue;
|
|
break;
|
|
|
|
case self::TYPE_PRICE_OVERRIDE:
|
|
$promoPrice = $discountValue;
|
|
break;
|
|
}
|
|
|
|
// === PROTEÇÃO DE RENTABILIDADE ===
|
|
|
|
// 1. Aplicar preço mínimo da CAMPANHA se definido
|
|
if ($this->min_price && $promoPrice < $this->min_price) {
|
|
$promoPrice = (float) $this->min_price;
|
|
}
|
|
|
|
// 2. Aplicar preço mínimo do PRODUTO se definido (proteção individual)
|
|
if ($product->min_price && $promoPrice < (float) $product->min_price) {
|
|
$promoPrice = (float) $product->min_price;
|
|
}
|
|
|
|
// 3. NUNCA vender abaixo do CMV (Custo) - Proteção contra prejuízo
|
|
$cmv = (float) ($product->cmv_total ?? 0);
|
|
if ($cmv > 0 && $promoPrice < $cmv) {
|
|
$promoPrice = $cmv; // Preço mínimo = custo (margem zero, sem prejuízo)
|
|
}
|
|
|
|
// 4. Aplicar margem mínima de segurança se configurada na campanha
|
|
if ($this->min_margin_percent && $cmv > 0) {
|
|
$minPriceWithMargin = $cmv * (1 + ($this->min_margin_percent / 100));
|
|
if ($promoPrice < $minPriceWithMargin) {
|
|
$promoPrice = $minPriceWithMargin;
|
|
}
|
|
}
|
|
|
|
// Não pode ser negativo
|
|
return max(0, round($promoPrice, 2));
|
|
}
|
|
|
|
/**
|
|
* Verifica se o preço promocional gera lucro
|
|
*/
|
|
public function isProfitable(ProductSheet $product): bool
|
|
{
|
|
$promoPrice = $this->calculatePromotionalPrice($product);
|
|
$cmv = (float) ($product->cmv_total ?? 0);
|
|
return $promoPrice > $cmv;
|
|
}
|
|
|
|
/**
|
|
* Calcula a margem real durante a promoção
|
|
*/
|
|
public function getPromotionalMargin(ProductSheet $product): array
|
|
{
|
|
$promoPrice = $this->calculatePromotionalPrice($product);
|
|
$cmv = (float) ($product->cmv_total ?? 0);
|
|
$originalPrice = (float) ($product->final_price ?? $product->sale_price);
|
|
|
|
$promoMargin = $promoPrice - $cmv;
|
|
$promoMarginPercent = $promoPrice > 0 ? ($promoMargin / $promoPrice) * 100 : 0;
|
|
|
|
$originalMargin = $originalPrice - $cmv;
|
|
$originalMarginPercent = $originalPrice > 0 ? ($originalMargin / $originalPrice) * 100 : 0;
|
|
|
|
return [
|
|
'promo_price' => $promoPrice,
|
|
'cmv' => $cmv,
|
|
'promo_margin' => round($promoMargin, 2),
|
|
'promo_margin_percent' => round($promoMarginPercent, 1),
|
|
'original_margin' => round($originalMargin, 2),
|
|
'original_margin_percent' => round($originalMarginPercent, 1),
|
|
'margin_reduction' => round($originalMarginPercent - $promoMarginPercent, 1),
|
|
'is_profitable' => $promoMargin > 0,
|
|
'is_protected' => $promoPrice >= $cmv,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Calcula o percentual de desconto
|
|
*/
|
|
public function getDiscountPercentage(float $originalPrice, float $promoPrice): float
|
|
{
|
|
if ($originalPrice <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return round((($originalPrice - $promoPrice) / $originalPrice) * 100, 1);
|
|
}
|
|
|
|
/**
|
|
* Retorna informações do badge para exibição
|
|
*/
|
|
public function getBadgeInfo(float $originalPrice, float $promoPrice): ?array
|
|
{
|
|
if (!$this->show_discount_badge) {
|
|
return null;
|
|
}
|
|
|
|
$discountPercent = $this->getDiscountPercentage($originalPrice, $promoPrice);
|
|
|
|
return [
|
|
'text' => $this->badge_text ?? "-{$discountPercent}%",
|
|
'color' => $this->badge_color ?? '#ef4444',
|
|
'discount_percent' => $discountPercent,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Aplica a campanha a todos os produtos do usuário
|
|
*/
|
|
public function applyToAllProducts(): int
|
|
{
|
|
if (!$this->apply_to_all_products) {
|
|
return 0;
|
|
}
|
|
|
|
$products = ProductSheet::where('user_id', $this->user_id)
|
|
->where('is_active', true)
|
|
->get();
|
|
|
|
$count = 0;
|
|
foreach ($products as $product) {
|
|
$promoPrice = $this->calculatePromotionalPrice($product);
|
|
|
|
$this->products()->syncWithoutDetaching([
|
|
$product->id => [
|
|
'promotional_price' => $promoPrice,
|
|
]
|
|
]);
|
|
|
|
$count++;
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Atualiza preços promocionais de todos os produtos da campanha
|
|
*/
|
|
public function recalculateAllPrices(): void
|
|
{
|
|
foreach ($this->products as $product) {
|
|
$override = null;
|
|
if ($product->pivot->discount_type) {
|
|
$override = [
|
|
'discount_type' => $product->pivot->discount_type,
|
|
'discount_value' => $product->pivot->discount_value,
|
|
];
|
|
}
|
|
|
|
$promoPrice = $this->calculatePromotionalPrice($product, $override);
|
|
|
|
$this->products()->updateExistingPivot($product->id, [
|
|
'promotional_price' => $promoPrice,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scope para campanhas do usuário
|
|
*/
|
|
public function scopeOfUser($query, $userId)
|
|
{
|
|
return $query->where('user_id', $userId);
|
|
}
|
|
|
|
/**
|
|
* Scope para campanhas ativas
|
|
*/
|
|
public function scopeActive($query)
|
|
{
|
|
return $query->where('is_active', true);
|
|
}
|
|
|
|
/**
|
|
* Scope para campanhas atuais (dentro do período)
|
|
*/
|
|
public function scopeCurrent($query)
|
|
{
|
|
$now = Carbon::now();
|
|
return $query->where('start_date', '<=', $now)
|
|
->where('end_date', '>=', $now);
|
|
}
|
|
|
|
/**
|
|
* Scope para campanhas futuras
|
|
*/
|
|
public function scopeUpcoming($query)
|
|
{
|
|
return $query->where('start_date', '>', Carbon::now());
|
|
}
|
|
|
|
/**
|
|
* Lista de presets disponíveis
|
|
*/
|
|
public static function getPresets(): array
|
|
{
|
|
return self::PRESETS;
|
|
}
|
|
|
|
/**
|
|
* Dias restantes da campanha
|
|
*/
|
|
public function getDaysRemainingAttribute(): int
|
|
{
|
|
if (!$this->isCurrentlyActive()) {
|
|
return 0;
|
|
}
|
|
|
|
return max(0, Carbon::now()->diffInDays($this->end_date, false));
|
|
}
|
|
|
|
/**
|
|
* Status da campanha
|
|
*/
|
|
public function getStatusAttribute(): string
|
|
{
|
|
if (!$this->is_active) {
|
|
return 'inactive';
|
|
}
|
|
|
|
$now = Carbon::now();
|
|
|
|
if ($now < $this->start_date) {
|
|
return 'scheduled';
|
|
}
|
|
|
|
if ($now > $this->end_date) {
|
|
return 'ended';
|
|
}
|
|
|
|
return 'active';
|
|
}
|
|
}
|