webmoney/backend/app/Http/Controllers/Api/PromotionalCampaignController.php

558 lines
21 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PromotionalCampaign;
use App\Models\ProductSheet;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Carbon\Carbon;
class PromotionalCampaignController extends Controller
{
/**
* Lista todas as campanhas do usuário
*/
public function index(Request $request): JsonResponse
{
$userId = $request->user()->id;
$query = PromotionalCampaign::ofUser($userId)
->withCount('products');
// Filtro por status
if ($request->has('status')) {
switch ($request->status) {
case 'active':
$query->active()->current();
break;
case 'scheduled':
$query->active()->upcoming();
break;
case 'ended':
$query->where('end_date', '<', Carbon::now());
break;
case 'inactive':
$query->where('is_active', false);
break;
}
}
$campaigns = $query->orderBy('start_date', 'desc')->get()->map(function ($campaign) {
return array_merge($campaign->toArray(), [
'status' => $campaign->status,
'days_remaining' => $campaign->days_remaining,
'is_currently_active' => $campaign->isCurrentlyActive(),
]);
});
return response()->json($campaigns);
}
/**
* Cria uma nova campanha
*/
public function store(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'code' => 'nullable|string|max:50',
'description' => 'nullable|string',
'discount_type' => 'required|in:percentage,fixed,price_override',
'discount_value' => 'required|numeric|min:0',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_active' => 'boolean',
'apply_to_all_products' => 'boolean',
'min_price' => 'nullable|numeric|min:0',
'show_original_price' => 'boolean',
'show_discount_badge' => 'boolean',
'badge_text' => 'nullable|string|max:50',
'badge_color' => 'nullable|string|max:20',
'priority' => 'integer|min:0',
'product_ids' => 'nullable|array',
'product_ids.*' => 'exists:product_sheets,id',
'preset' => 'nullable|string', // Para usar presets
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$userId = $request->user()->id;
$data = $validator->validated();
// Aplicar preset se fornecido
if (!empty($data['preset']) && isset(PromotionalCampaign::PRESETS[$data['preset']])) {
$preset = PromotionalCampaign::PRESETS[$data['preset']];
$data = array_merge($preset, $data);
}
unset($data['product_ids'], $data['preset']);
$data['user_id'] = $userId;
DB::beginTransaction();
try {
$campaign = PromotionalCampaign::create($data);
// Adicionar produtos se fornecidos
if ($request->has('product_ids') && is_array($request->product_ids)) {
foreach ($request->product_ids as $productId) {
$product = ProductSheet::ofUser($userId)->find($productId);
if ($product) {
$promoPrice = $campaign->calculatePromotionalPrice($product);
$campaign->products()->attach($productId, [
'promotional_price' => $promoPrice,
]);
}
}
}
// Se apply_to_all_products, adicionar todos
if ($campaign->apply_to_all_products) {
$campaign->applyToAllProducts();
}
DB::commit();
$campaign->load('products');
return response()->json(array_merge($campaign->toArray(), [
'status' => $campaign->status,
'products_count' => $campaign->products->count(),
]), 201);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['message' => 'Erro ao criar campanha: ' . $e->getMessage()], 500);
}
}
/**
* Exibe uma campanha específica
*/
public function show(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)
->with(['products' => function ($query) {
$query->select('product_sheets.*')
->withPivot(['discount_type', 'discount_value', 'promotional_price', 'promo_margin', 'promo_margin_percent', 'is_profitable']);
}])
->findOrFail($id);
// Adicionar informações de cada produto COM RENTABILIDADE
$totalProfit = 0;
$unprofitableCount = 0;
$productsWithInfo = $campaign->products->map(function ($product) use ($campaign, &$totalProfit, &$unprofitableCount) {
$originalPrice = (float) ($product->final_price ?? $product->sale_price);
$promoPrice = (float) $product->pivot->promotional_price;
$cmv = (float) ($product->cmv_total ?? 0);
// Calcular rentabilidade
$marginInfo = $campaign->getPromotionalMargin($product);
if (!$marginInfo['is_profitable']) {
$unprofitableCount++;
}
$totalProfit += $marginInfo['promo_margin'];
return array_merge($product->toArray(), [
'original_price' => $originalPrice,
'promotional_price' => $promoPrice,
'cmv' => $cmv,
'discount_percent' => $campaign->getDiscountPercentage($originalPrice, $promoPrice),
'savings' => round($originalPrice - $promoPrice, 2),
'badge' => $campaign->getBadgeInfo($originalPrice, $promoPrice),
// Dados de rentabilidade
'promo_margin' => $marginInfo['promo_margin'],
'promo_margin_percent' => $marginInfo['promo_margin_percent'],
'original_margin' => $marginInfo['original_margin'],
'original_margin_percent' => $marginInfo['original_margin_percent'],
'margin_reduction' => $marginInfo['margin_reduction'],
'is_profitable' => $marginInfo['is_profitable'],
'is_protected' => $marginInfo['is_protected'],
]);
});
return response()->json(array_merge($campaign->toArray(), [
'status' => $campaign->status,
'days_remaining' => $campaign->days_remaining,
'is_currently_active' => $campaign->isCurrentlyActive(),
'products' => $productsWithInfo,
// Resumo de rentabilidade
'profitability_summary' => [
'total_profit' => round($totalProfit, 2),
'unprofitable_count' => $unprofitableCount,
'all_profitable' => $unprofitableCount === 0,
],
]));
}
/**
* Atualiza uma campanha
*/
public function update(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id);
$validator = Validator::make($request->all(), [
'name' => 'sometimes|string|max:255',
'code' => 'nullable|string|max:50',
'description' => 'nullable|string',
'discount_type' => 'sometimes|in:percentage,fixed,price_override',
'discount_value' => 'sometimes|numeric|min:0',
'start_date' => 'sometimes|date',
'end_date' => 'sometimes|date|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_active' => 'boolean',
'apply_to_all_products' => 'boolean',
'min_price' => 'nullable|numeric|min:0',
'show_original_price' => 'boolean',
'show_discount_badge' => 'boolean',
'badge_text' => 'nullable|string|max:50',
'badge_color' => 'nullable|string|max:20',
'priority' => 'integer|min:0',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$campaign->update($validator->validated());
// Recalcular preços se desconto mudou
if ($request->hasAny(['discount_type', 'discount_value', 'min_price'])) {
$campaign->recalculateAllPrices();
}
// Se mudou para apply_to_all_products
if ($request->has('apply_to_all_products') && $campaign->apply_to_all_products) {
$campaign->applyToAllProducts();
}
$campaign->load('products');
return response()->json(array_merge($campaign->toArray(), [
'status' => $campaign->status,
'products_count' => $campaign->products->count(),
]));
}
/**
* Remove uma campanha
*/
public function destroy(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id);
$campaign->delete();
return response()->json(['message' => 'Campanha excluída com sucesso']);
}
/**
* Adiciona produtos à campanha
*/
public function addProducts(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id);
$validator = Validator::make($request->all(), [
'product_ids' => 'required|array',
'product_ids.*' => 'exists:product_sheets,id',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$userId = $request->user()->id;
$added = 0;
foreach ($request->product_ids as $productId) {
$product = ProductSheet::ofUser($userId)->find($productId);
if ($product && !$campaign->products->contains($productId)) {
$promoPrice = $campaign->calculatePromotionalPrice($product);
$campaign->products()->attach($productId, [
'promotional_price' => $promoPrice,
]);
$added++;
}
}
return response()->json([
'message' => "{$added} produto(s) adicionado(s) à campanha",
'products_count' => $campaign->products()->count(),
]);
}
/**
* Remove produtos da campanha
*/
public function removeProducts(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id);
$validator = Validator::make($request->all(), [
'product_ids' => 'required|array',
'product_ids.*' => 'exists:product_sheets,id',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$campaign->products()->detach($request->product_ids);
return response()->json([
'message' => 'Produto(s) removido(s) da campanha',
'products_count' => $campaign->products()->count(),
]);
}
/**
* Atualiza desconto específico de um produto na campanha
*/
public function updateProductDiscount(Request $request, $campaignId, $productId): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($campaignId);
$validator = Validator::make($request->all(), [
'discount_type' => 'nullable|in:percentage,fixed,price_override',
'discount_value' => 'nullable|numeric|min:0',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$product = $campaign->products()->where('product_sheet_id', $productId)->first();
if (!$product) {
return response()->json(['message' => 'Produto não encontrado na campanha'], 404);
}
$override = null;
if ($request->discount_type) {
$override = [
'discount_type' => $request->discount_type,
'discount_value' => $request->discount_value,
];
}
$promoPrice = $campaign->calculatePromotionalPrice($product, $override);
$campaign->products()->updateExistingPivot($productId, [
'discount_type' => $request->discount_type,
'discount_value' => $request->discount_value,
'promotional_price' => $promoPrice,
]);
return response()->json([
'promotional_price' => $promoPrice,
'message' => 'Desconto atualizado',
]);
}
/**
* Retorna presets disponíveis
*/
public function presets(): JsonResponse
{
return response()->json(PromotionalCampaign::getPresets());
}
/**
* Duplica uma campanha
*/
public function duplicate(Request $request, $id): JsonResponse
{
$campaign = PromotionalCampaign::ofUser($request->user()->id)
->with('products')
->findOrFail($id);
DB::beginTransaction();
try {
$newCampaign = $campaign->replicate();
$newCampaign->name = $campaign->name . ' (Cópia)';
$newCampaign->is_active = false;
$newCampaign->start_date = Carbon::now();
$newCampaign->end_date = Carbon::now()->addDays(7);
$newCampaign->save();
// Copiar produtos
foreach ($campaign->products as $product) {
$newCampaign->products()->attach($product->id, [
'discount_type' => $product->pivot->discount_type,
'discount_value' => $product->pivot->discount_value,
'promotional_price' => $product->pivot->promotional_price,
]);
}
DB::commit();
return response()->json(array_merge($newCampaign->toArray(), [
'status' => $newCampaign->status,
'products_count' => $newCampaign->products()->count(),
]), 201);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['message' => 'Erro ao duplicar: ' . $e->getMessage()], 500);
}
}
/**
* Preview de preços para uma campanha COM ANÁLISE DE RENTABILIDADE
*/
public function preview(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'discount_type' => 'required|in:percentage,fixed,price_override',
'discount_value' => 'required|numeric|min:0',
'min_price' => 'nullable|numeric|min:0',
'min_margin_percent' => 'nullable|numeric|min:0',
'protect_against_loss' => 'nullable|boolean',
'product_ids' => 'required|array|min:1',
'product_ids.*' => 'exists:product_sheets,id',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$userId = $request->user()->id;
$products = ProductSheet::ofUser($userId)
->whereIn('id', $request->product_ids)
->get();
$protectAgainstLoss = $request->protect_against_loss !== false; // default true
$minMarginPercent = $request->min_margin_percent;
$unprofitableCount = 0;
$protectedCount = 0;
$preview = $products->map(function ($product) use ($request, $protectAgainstLoss, $minMarginPercent, &$unprofitableCount, &$protectedCount) {
$originalPrice = (float) ($product->final_price ?? $product->sale_price);
$cmv = (float) ($product->cmv_total ?? 0);
$promoPrice = $originalPrice;
// Calcular desconto
switch ($request->discount_type) {
case 'percentage':
$promoPrice = $originalPrice * (1 - ($request->discount_value / 100));
break;
case 'fixed':
$promoPrice = $originalPrice - $request->discount_value;
break;
case 'price_override':
$promoPrice = $request->discount_value;
break;
}
$wasProtected = false;
$protectionReason = null;
// Aplicar preço mínimo da campanha
if ($request->min_price && $promoPrice < $request->min_price) {
$promoPrice = $request->min_price;
$wasProtected = true;
$protectionReason = 'campaign_min_price';
}
// Aplicar preço mínimo do produto
if ($product->min_price && $promoPrice < (float) $product->min_price) {
$promoPrice = (float) $product->min_price;
$wasProtected = true;
$protectionReason = 'product_min_price';
}
// Proteção contra prejuízo (nunca vender abaixo do CMV)
if ($protectAgainstLoss && $cmv > 0 && $promoPrice < $cmv) {
$promoPrice = $cmv;
$wasProtected = true;
$protectionReason = 'cmv_protection';
$protectedCount++;
}
// Margem mínima obrigatória
if ($minMarginPercent && $cmv > 0) {
$minPriceWithMargin = $cmv * (1 + ($minMarginPercent / 100));
if ($promoPrice < $minPriceWithMargin) {
$promoPrice = $minPriceWithMargin;
$wasProtected = true;
$protectionReason = 'min_margin';
}
}
$promoPrice = max(0, round($promoPrice, 2));
// Calcular margens
$discountPercent = $originalPrice > 0
? round((($originalPrice - $promoPrice) / $originalPrice) * 100, 1)
: 0;
$promoMargin = $promoPrice - $cmv;
$promoMarginPercent = $promoPrice > 0 ? round(($promoMargin / $promoPrice) * 100, 1) : 0;
$originalMargin = $originalPrice - $cmv;
$originalMarginPercent = $originalPrice > 0 ? round(($originalMargin / $originalPrice) * 100, 1) : 0;
$isProfitable = $promoMargin > 0;
if (!$isProfitable) {
$unprofitableCount++;
}
return [
'id' => $product->id,
'name' => $product->name,
'sku' => $product->sku,
'cmv' => $cmv,
'original_price' => $originalPrice,
'promotional_price' => $promoPrice,
'discount_percent' => $discountPercent,
'savings' => round($originalPrice - $promoPrice, 2),
// Rentabilidade
'promo_margin' => round($promoMargin, 2),
'promo_margin_percent' => $promoMarginPercent,
'original_margin' => round($originalMargin, 2),
'original_margin_percent' => $originalMarginPercent,
'margin_reduction' => round($originalMarginPercent - $promoMarginPercent, 1),
'is_profitable' => $isProfitable,
// Proteção
'was_protected' => $wasProtected,
'protection_reason' => $protectionReason,
];
});
$totals = [
'products_count' => $preview->count(),
'total_original' => round($preview->sum('original_price'), 2),
'total_promotional' => round($preview->sum('promotional_price'), 2),
'total_savings' => round($preview->sum('savings'), 2),
'avg_discount' => round($preview->avg('discount_percent'), 1),
// Rentabilidade
'total_cmv' => round($preview->sum('cmv'), 2),
'total_promo_margin' => round($preview->sum('promo_margin'), 2),
'avg_promo_margin_percent' => round($preview->avg('promo_margin_percent'), 1),
'avg_original_margin_percent' => round($preview->avg('original_margin_percent'), 1),
// Alertas
'unprofitable_count' => $unprofitableCount,
'protected_count' => $protectedCount,
'all_profitable' => $unprofitableCount === 0,
];
return response()->json([
'products' => $preview,
'totals' => $totals,
]);
}
}