diff --git a/app/Http/Controllers/App/ClientController.php b/app/Http/Controllers/App/ClientController.php index 6a6effa..bcbdb48 100644 --- a/app/Http/Controllers/App/ClientController.php +++ b/app/Http/Controllers/App/ClientController.php @@ -4,10 +4,17 @@ use Illuminate\Http\Request; use App\Http\Controllers\Controller; use App\Models\Client; +use App\Services\ClientTierService; use Notsoweb\ApiResponse\Enums\ApiResponse; class ClientController extends Controller { + protected ClientTierService $clientTierService; + + public function __construct(ClientTierService $clientTierService) + { + $this->clientTierService = $clientTierService; + } public function index(Request $request) { $query = Client::query(); @@ -52,14 +59,21 @@ public function store(Request $request) ]); try{ - - $client = Client::create($request->only([ + $data = $request->only([ 'name', 'email', 'phone', 'address', 'rfc', - ])); + ]); + + // Generar client_number automáticamente + $data['client_number'] = $this->clientTierService->generateClientNumber(); + + $client = Client::create($data); + + // Cargar relación tier + $client->load('tier'); return ApiResponse::OK->response([ 'client' => $client, @@ -124,4 +138,84 @@ public function destroy(Client $client) ]); } } + + /** + * Obtener estadísticas de compra del cliente + */ + public function stats(Client $client) + { + try { + $stats = $this->clientTierService->getClientStats($client); + + return ApiResponse::OK->response([ + 'stats' => $stats + ]); + } catch(\Exception $e) { + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'Error al obtener estadísticas del cliente.' + ]); + } + } + + /** + * Obtener historial de cambios de tier del cliente + */ + public function tierHistory(Client $client) + { + try { + $history = $client->tierHistory() + ->with(['oldTier', 'newTier']) + ->orderBy('changed_at', 'desc') + ->get(); + + return ApiResponse::OK->response([ + 'history' => $history + ]); + } catch(\Exception $e) { + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'Error al obtener historial del cliente.' + ]); + } + } + + /** + * Obtener ventas con descuento de un cliente + */ + public function salesWithDiscounts(Client $client, Request $request) + { + try { + $query = $client->sales() + ->where('discount_amount', '>', 0) + ->with(['details', 'user:id,name']) + ->orderBy('created_at', 'desc'); + + // Filtro por rango de fechas + if ($request->has('date_from')) { + $query->where('created_at', '>=', $request->date_from); + } + + if ($request->has('date_to')) { + $query->where('created_at', '<=', $request->date_to); + } + + $sales = $query->paginate($request->per_page ?? 15); + + // Calcular totales + $totals = [ + 'total_sales' => $client->sales()->where('discount_amount', '>', 0)->count(), + 'total_amount' => $client->sales()->where('discount_amount', '>', 0)->sum('total'), + 'total_discounts' => $client->sales()->where('discount_amount', '>', 0)->sum('discount_amount'), + ]; + + return ApiResponse::OK->response([ + 'sales' => $sales, + 'totals' => $totals + ]); + } catch(\Exception $e) { + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'Error al obtener ventas con descuentos.', + 'error' => $e->getMessage() + ]); + } + } } diff --git a/app/Http/Controllers/App/ClientTierController.php b/app/Http/Controllers/App/ClientTierController.php new file mode 100644 index 0000000..84b6708 --- /dev/null +++ b/app/Http/Controllers/App/ClientTierController.php @@ -0,0 +1,110 @@ +orderBy('min_purchase_amount') + ->get(); + + return ApiResponse::OK->response([ + 'tiers' => $tiers + ]); + } + + /** + * Store a newly created tier + */ + public function store(ClientTierStoreRequest $request) + { + $tier = ClientTier::create($request->validated()); + + return ApiResponse::OK->response([ + 'tier' => $tier, + 'message' => 'Tier creado correctamente.' + ]); + } + + /** + * Display the specified tier + */ + public function show(ClientTier $tier) + { + $tier->loadCount('clients'); + + return ApiResponse::OK->response([ + 'tier' => $tier + ]); + } + + /** + * Update the specified tier + */ + public function update(ClientTierUpdateRequest $request, ClientTier $tier) + { + $tier->update($request->validated()); + + return ApiResponse::OK->response([ + 'tier' => $tier, + 'message' => 'Tier actualizado correctamente.' + ]); + } + + /** + * Remove the specified tier (soft delete if has clients, else force delete) + */ + public function destroy(ClientTier $tier) + { + // Verificar si el tier tiene clientes asignados + if ($tier->clients()->count() > 0) { + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'No se puede eliminar el tier porque tiene clientes asignados.' + ]); + } + + $tier->delete(); + + return ApiResponse::OK->response([ + 'message' => 'Tier eliminado correctamente.' + ]); + } + + /** + * Toggle tier active status + */ + public function toggleActive(ClientTier $tier) + { + $tier->update(['is_active' => !$tier->is_active]); + + return ApiResponse::OK->response([ + 'tier' => $tier, + 'message' => 'Estado del tier actualizado correctamente.' + ]); + } + + /** + * Get active tiers only + */ + public function active() + { + $tiers = ClientTier::active() + ->orderBy('min_purchase_amount') + ->get(); + + return ApiResponse::OK->response([ + 'tiers' => $tiers + ]); + } +} diff --git a/app/Http/Controllers/App/ExcelController.php b/app/Http/Controllers/App/ExcelController.php new file mode 100644 index 0000000..f4f3dec --- /dev/null +++ b/app/Http/Controllers/App/ExcelController.php @@ -0,0 +1,226 @@ +validate([ + 'fecha_inicio' => 'required|date', + 'fecha_fin' => 'required|date|after_or_equal:fecha_inicio', + 'tier_id' => 'nullable|exists:client_tiers,id', + ]); + + $fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay(); + $fechaFin = Carbon::parse($request->fecha_fin)->endOfDay(); + $tierId = $request->tier_id; + + // 2. OBTENER DATOS DE CLIENTES CON DESCUENTOS + $clients = Client::whereNotNull('tier_id') + ->with('tier:id,tier_name,discount_percentage') + ->whereHas('sales', function($q) use ($fechaInicio, $fechaFin) { + $q->where('discount_amount', '>', 0) + ->whereBetween('created_at', [$fechaInicio, $fechaFin]); + }) + ->when($tierId, function($query) use ($tierId) { + $query->where('tier_id', $tierId); + }) + ->get(); + + if ($clients->isEmpty()) { + return response()->json(['message' => 'No se encontraron registros en el periodo especificado'], 404); + } + + // 3. MAPEO DE DATOS + $data = $clients->map(function($client) use ($fechaInicio, $fechaFin) { + $sales = $client->sales() + ->where('discount_amount', '>', 0) + ->whereBetween('created_at', [$fechaInicio, $fechaFin]) + ->get(); + + return [ + 'numero' => $client->client_number, + 'nombre' => $client->name, + 'email' => $client->email ?? 'N/A', + 'telefono' => $client->phone ?? 'N/A', + 'tier' => $client->tier?->tier_name ?? 'N/A', + 'descuento_porcentaje' => $client->tier?->discount_percentage ?? 0, + 'total_compras' => $client->total_purchases, + 'ventas_con_descuento' => $sales->count(), + 'descuentos_recibidos' => $sales->sum('discount_amount'), + 'promedio_descuento' => $sales->count() > 0 ? $sales->avg('discount_amount') : 0, + ]; + }); + + // 4. CONFIGURACIÓN EXCEL Y ESTILOS + $fileName = 'Reporte_Descuentos_Clientes_' . $fechaInicio->format('Ymd') . '.xlsx'; + $filePath = storage_path('app/temp/' . $fileName); + if (!file_exists(dirname($filePath))) mkdir(dirname($filePath), 0755, true); + + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + // Fuente Global + $sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial'); + $sheet->getParent()->getDefaultStyle()->getFont()->setSize(10); + + // Estilos Comunes + $styleBox = [ + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '000000']]], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER] + ]; + $styleLabel = [ + 'font' => ['size' => 12, 'bold' => true], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT, 'vertical' => Alignment::VERTICAL_CENTER] + ]; + $styleTableHeader = [ + 'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '4472C4']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true], + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]] + ]; + + // --- ESTRUCTURA DEL DOCUMENTO --- + $sheet->getRowDimension(2)->setRowHeight(10); + $sheet->getRowDimension(3)->setRowHeight(25); + $sheet->getRowDimension(5)->setRowHeight(30); + + // --- TÍTULO PRINCIPAL --- + $sheet->mergeCells('A3:J3'); + $sheet->setCellValue('A3', 'REPORTE DE DESCUENTOS A CLIENTES'); + $sheet->getStyle('A3')->applyFromArray([ + 'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '000000']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], + ]); + + // --- INFORMACIÓN DEL PERIODO --- + Carbon::setLocale('es'); + if ($fechaInicio->format('m/Y') === $fechaFin->format('m/Y')) { + $periodoTexto = 'del ' . $fechaInicio->format('d') . ' al ' . $fechaFin->format('d') . ' de ' . $fechaFin->translatedFormat('F \d\e Y'); + } else { + $periodoTexto = 'del ' . $fechaInicio->format('d/m/Y') . ' al ' . $fechaFin->format('d/m/Y'); + } + + $sheet->mergeCells('A5:B5'); + $sheet->setCellValue('A5', 'PERÍODO:'); + $sheet->getStyle('A5')->applyFromArray($styleLabel); + + $sheet->mergeCells('C5:J5'); + $sheet->setCellValue('C5', $periodoTexto); + $sheet->getStyle('C5:J5')->applyFromArray($styleBox); + $sheet->getStyle('C5')->getFont()->setSize(12); + + // --- RESUMEN DE TOTALES --- + $totalClientes = $data->count(); + $totalVentas = $data->sum('ventas_con_descuento'); + $totalDescuentos = $data->sum('descuentos_recibidos'); + + $row = 7; + $sheet->setCellValue('A' . $row, 'TOTAL CLIENTES CON DESCUENTOS:'); + $sheet->setCellValue('C' . $row, $totalClientes); + $sheet->setCellValue('E' . $row, 'TOTAL VENTAS:'); + $sheet->setCellValue('G' . $row, $totalVentas); + $sheet->setCellValue('I' . $row, 'TOTAL DESCUENTOS:'); + $sheet->setCellValue('J' . $row, '$' . number_format($totalDescuentos, 2)); + + $sheet->getStyle('A' . $row . ':J' . $row)->getFont()->setBold(true); + $sheet->getStyle('C' . $row)->getFont()->setSize(12)->getColor()->setRGB('4472C4'); + $sheet->getStyle('G' . $row)->getFont()->setSize(12)->getColor()->setRGB('4472C4'); + $sheet->getStyle('J' . $row)->getFont()->setSize(12)->getColor()->setRGB('008000'); + + // --- ENCABEZADOS DE TABLA --- + $h = 9; + $headers = [ + 'A' => 'No.', + 'B' => "NÚMERO\nCLIENTE", + 'C' => 'NOMBRE', + 'D' => 'EMAIL', + 'E' => 'TELÉFONO', + 'F' => 'TIER', + 'G' => "DESCUENTO\n(%)", + 'H' => "VENTAS CON\nDESCUENTO", + 'I' => "DESCUENTOS\nRECIBIDOS", + 'J' => "PROMEDIO\nDESCUENTO" + ]; + + foreach ($headers as $col => $text) { + $sheet->setCellValue("{$col}{$h}", $text); + } + + $sheet->getStyle("A{$h}:J{$h}")->applyFromArray($styleTableHeader); + $sheet->getRowDimension($h)->setRowHeight(35); + + // --- LLENADO DE DATOS --- + $row = 10; + $i = 1; + + foreach ($data as $item) { + $sheet->setCellValue('A' . $row, $i); + $sheet->setCellValue('B' . $row, $item['numero']); + $sheet->setCellValue('C' . $row, $item['nombre']); + $sheet->setCellValue('D' . $row, $item['email']); + $sheet->setCellValue('E' . $row, $item['telefono']); + $sheet->setCellValue('F' . $row, $item['tier']); + $sheet->setCellValue('G' . $row, $item['descuento_porcentaje'] . '%'); + $sheet->setCellValue('H' . $row, $item['ventas_con_descuento']); + $sheet->setCellValue('I' . $row, '$' . number_format($item['descuentos_recibidos'], 2)); + $sheet->setCellValue('J' . $row, '$' . number_format($item['promedio_descuento'], 2)); + + // Estilos de fila + $sheet->getStyle("A{$row}:J{$row}")->applyFromArray([ + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], + 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], + 'font' => ['size' => 10] + ]); + + // Centrados + $sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("B{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("G{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("H{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + + // Color alterno de filas + if ($i % 2 == 0) { + $sheet->getStyle("A{$row}:J{$row}")->getFill() + ->setFillType(Fill::FILL_SOLID) + ->getStartColor()->setRGB('F2F2F2'); + } + + $row++; + $i++; + } + + // --- ANCHOS DE COLUMNA --- + $sheet->getColumnDimension('A')->setWidth(5); + $sheet->getColumnDimension('B')->setWidth(15); + $sheet->getColumnDimension('C')->setWidth(25); + $sheet->getColumnDimension('D')->setWidth(25); + $sheet->getColumnDimension('E')->setWidth(15); + $sheet->getColumnDimension('F')->setWidth(12); + $sheet->getColumnDimension('G')->setWidth(12); + $sheet->getColumnDimension('H')->setWidth(15); + $sheet->getColumnDimension('I')->setWidth(18); + $sheet->getColumnDimension('J')->setWidth(18); + + $writer = new Xlsx($spreadsheet); + $writer->save($filePath); + + return response()->download($filePath, $fileName)->deleteFileAfterSend(true); + } +} diff --git a/app/Http/Requests/ClientTiers/ClientTierStoreRequest.php b/app/Http/Requests/ClientTiers/ClientTierStoreRequest.php new file mode 100644 index 0000000..13998c4 --- /dev/null +++ b/app/Http/Requests/ClientTiers/ClientTierStoreRequest.php @@ -0,0 +1,47 @@ + ['required', 'string', 'max:100', 'unique:client_tiers,tier_name'], + 'min_purchase_amount' => ['required', 'numeric', 'min:0'], + 'max_purchase_amount' => ['nullable', 'numeric', 'gt:min_purchase_amount'], + 'discount_percentage' => ['required', 'numeric', 'min:0', 'max:100'], + 'is_active' => ['boolean'], + ]; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'tier_name.required' => 'El nombre del tier es requerido', + 'tier_name.unique' => 'Ya existe un tier con este nombre', + 'min_purchase_amount.required' => 'El monto mínimo es requerido', + 'min_purchase_amount.min' => 'El monto mínimo debe ser mayor o igual a 0', + 'max_purchase_amount.gt' => 'El monto máximo debe ser mayor al monto mínimo', + 'discount_percentage.required' => 'El porcentaje de descuento es requerido', + 'discount_percentage.min' => 'El descuento debe ser mayor o igual a 0', + 'discount_percentage.max' => 'El descuento no puede ser mayor a 100%', + ]; + } +} diff --git a/app/Http/Requests/ClientTiers/ClientTierUpdateRequest.php b/app/Http/Requests/ClientTiers/ClientTierUpdateRequest.php new file mode 100644 index 0000000..a8c8c0c --- /dev/null +++ b/app/Http/Requests/ClientTiers/ClientTierUpdateRequest.php @@ -0,0 +1,56 @@ +route('tier')->id ?? $this->route('id'); + + return [ + 'tier_name' => [ + 'sometimes', + 'required', + 'string', + 'max:100', + Rule::unique('client_tiers', 'tier_name')->ignore($tierId), + ], + 'min_purchase_amount' => ['sometimes', 'required', 'numeric', 'min:0'], + 'max_purchase_amount' => ['nullable', 'numeric', 'gt:min_purchase_amount'], + 'discount_percentage' => ['sometimes', 'required', 'numeric', 'min:0', 'max:100'], + 'is_active' => ['boolean'], + ]; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'tier_name.required' => 'El nombre del tier es requerido', + 'tier_name.unique' => 'Ya existe un tier con este nombre', + 'min_purchase_amount.required' => 'El monto mínimo es requerido', + 'min_purchase_amount.min' => 'El monto mínimo debe ser mayor o igual a 0', + 'max_purchase_amount.gt' => 'El monto máximo debe ser mayor al monto mínimo', + 'discount_percentage.required' => 'El porcentaje de descuento es requerido', + 'discount_percentage.min' => 'El descuento debe ser mayor o igual a 0', + 'discount_percentage.max' => 'El descuento no puede ser mayor a 100%', + ]; + } +} diff --git a/app/Models/CashRegister.php b/app/Models/CashRegister.php index fec2d5b..a618271 100644 --- a/app/Models/CashRegister.php +++ b/app/Models/CashRegister.php @@ -20,6 +20,7 @@ class CashRegister extends Model 'cash_returns', 'card_returns', 'returns_count', + 'total_discounts', 'notes', 'status', ]; @@ -35,6 +36,7 @@ class CashRegister extends Model 'total_returns' => 'decimal:2', 'cash_returns' => 'decimal:2', 'card_returns' => 'decimal:2', + 'total_discounts' => 'decimal:2', ]; public function user() diff --git a/app/Models/Client.php b/app/Models/Client.php index cc029e7..003e64a 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -1,11 +1,14 @@ 'decimal:2', + 'total_transactions' => 'integer', + 'lifetime_returns' => 'decimal:2', + 'last_purchase_at' => 'datetime', + ]; + + public function sales(): HasMany { return $this->hasMany(Sale::class); } + + /** + * Nivel o rango del cliente + */ + public function tier(): BelongsTo + { + return $this->belongsTo(ClientTier::class, 'tier_id'); + } + + /** + * Historial de cambios de nivel del cliente + */ + public function tierHistory(): HasMany + { + return $this->hasMany(ClientTierHistory::class)->orderBy('changed_at', 'desc'); + } + + /** + * Descuento aplicable según el nivel del cliente + */ + public function getDiscountPercentageAttribute(): float + { + return $this->tier?->discount_percentage ?? 0; + } + + /** + * Compras netas del cliente (total compras - devoluciones) + */ + public function getNetPurchasesAttribute(): float + { + return $this->total_purchases - $this->lifetime_returns; + } } diff --git a/app/Models/ClientTier.php b/app/Models/ClientTier.php new file mode 100644 index 0000000..56e6708 --- /dev/null +++ b/app/Models/ClientTier.php @@ -0,0 +1,76 @@ + 'decimal:2', + 'max_purchase_amount' => 'decimal:2', + 'discount_percentage' => 'decimal:2', + 'is_active' => 'boolean', + ]; + + /** + * Clientes que pertenecen a este nivel + */ + public function clients(): HasMany + { + return $this->hasMany(Client::class, 'tier_id'); + } + + /** + * Niveles nuevos en el historial + */ + public function tierHistories(): HasMany + { + return $this->hasMany(ClientTierHistory::class, 'new_tier_id'); + } + + /** + * Niveles antiguos en el historial + */ + public function oldTierHistories(): HasMany + { + return $this->hasMany(ClientTierHistory::class, 'old_tier_id'); + } + + /** + * Alcance de consulta para niveles activos + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Verifica si un monto de compra califica para este nivel + */ + public function qualifies(float $amount): bool + { + if (!$this->is_active) { + return false; + } + + if ($amount < $this->min_purchase_amount) { + return false; + } + + if ($this->max_purchase_amount !== null && $amount > $this->max_purchase_amount) { + return false; + } + + return true; + } +} diff --git a/app/Models/ClientTierHistory.php b/app/Models/ClientTierHistory.php new file mode 100644 index 0000000..9c007ad --- /dev/null +++ b/app/Models/ClientTierHistory.php @@ -0,0 +1,51 @@ + 'decimal:2', + 'changed_at' => 'datetime', + ]; + + /** + * Cliente con historial de niveles + */ + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + /** + * Nivel anterior + */ + public function oldTier(): BelongsTo + { + return $this->belongsTo(ClientTier::class, 'old_tier_id'); + } + + /** + * Nivel nuevo + */ + public function newTier(): BelongsTo + { + return $this->belongsTo(ClientTier::class, 'new_tier_id'); + } +} diff --git a/app/Models/Returns.php b/app/Models/Returns.php index d7dc7c7..75c8448 100644 --- a/app/Models/Returns.php +++ b/app/Models/Returns.php @@ -15,6 +15,7 @@ class Returns extends Model 'subtotal', 'tax', 'total', + 'discount_refund', 'refund_method', 'reason', 'notes' @@ -24,6 +25,7 @@ class Returns extends Model 'subtotal' => 'decimal:2', 'tax' => 'decimal:2', 'total' => 'decimal:2', + 'discount_refund' => 'decimal:2', 'created_at' => 'datetime', ]; diff --git a/app/Models/Sale.php b/app/Models/Sale.php index ecc3a8a..bbe1e54 100644 --- a/app/Models/Sale.php +++ b/app/Models/Sale.php @@ -23,6 +23,9 @@ class Sale extends Model 'subtotal', 'tax', 'total', + 'discount_percentage', + 'discount_amount', + 'client_tier_name', 'cash_received', 'change', 'payment_method', @@ -33,6 +36,8 @@ class Sale extends Model 'subtotal' => 'decimal:2', 'tax' => 'decimal:2', 'total' => 'decimal:2', + 'discount_percentage' => 'decimal:2', + 'discount_amount' => 'decimal:2', 'cash_received' => 'decimal:2', 'change' => 'decimal:2', ]; diff --git a/app/Models/SaleDetail.php b/app/Models/SaleDetail.php index 7d72f36..41d06d6 100644 --- a/app/Models/SaleDetail.php +++ b/app/Models/SaleDetail.php @@ -22,11 +22,15 @@ class SaleDetail extends Model 'quantity', 'unit_price', 'subtotal', + 'discount_percentage', + 'discount_amount', ]; protected $casts = [ 'unit_price' => 'decimal:2', 'subtotal' => 'decimal:2', + 'discount_percentage' => 'decimal:2', + 'discount_amount' => 'decimal:2', ]; public function sale() diff --git a/app/Services/ClientTierService.php b/app/Services/ClientTierService.php new file mode 100644 index 0000000..916909c --- /dev/null +++ b/app/Services/ClientTierService.php @@ -0,0 +1,177 @@ +first(); + $nextNumber = $lastClient ? ((int) substr($lastClient->client_number, 4)) + 1 : 1; + + return 'CLI-' . str_pad($nextNumber, 4, '0', STR_PAD_LEFT); + } + + /** + * Calcular y actualizar el tier del cliente basado en sus compras totales + */ + public function recalculateClientTier(Client $client): ?ClientTier + { + // Calcular compras netas (total - devoluciones) + $netPurchases = $client->total_purchases - $client->lifetime_returns; + + // Buscar el tier apropiado ordenado por monto mínimo descendente + $newTier = ClientTier::active() + ->where('min_purchase_amount', '<=', $netPurchases) + ->where(function ($query) use ($netPurchases) { + $query->whereNull('max_purchase_amount') + ->orWhere('max_purchase_amount', '>=', $netPurchases); + }) + ->orderBy('min_purchase_amount', 'desc') + ->first(); + + // Si cambió el tier, registrar en historial + if ($newTier && $client->tier_id !== $newTier->id) { + $this->recordTierChange($client, $newTier, $netPurchases); + + $client->tier_id = $newTier->id; + $client->save(); + } + + return $newTier; + } + + /** + * Registrar cambio de tier en el historial + */ + protected function recordTierChange(Client $client, ClientTier $newTier, float $totalAtChange): void + { + ClientTierHistory::create([ + 'client_id' => $client->id, + 'old_tier_id' => $client->tier_id, + 'new_tier_id' => $newTier->id, + 'total_at_change' => $totalAtChange, + 'reason' => 'Actualización automática por cambio en compras acumuladas', + ]); + } + + /** + * Obtener descuento aplicable para un cliente + */ + public function getApplicableDiscount(Client $client): float + { + if (!$client->tier) { + return 0.00; + } + + return $client->tier->discount_percentage; + } + + /** + * Actualizar estadísticas de compra del cliente + */ + public function updateClientPurchaseStats(Client $client, float $amount, int $quantity = 1): void + { + $client->total_purchases += $amount; + $client->total_transactions += 1; + $client->last_purchase_at = now(); + $client->save(); + + // Recalcular tier después de actualizar stats + $this->recalculateClientTier($client); + } + + /** + * Revertir estadísticas de compra del cliente (para devoluciones) + */ + public function revertClientPurchaseStats(Client $client, float $amount): void + { + $client->lifetime_returns += $amount; + $client->save(); + + // Recalcular tier después de registrar devolución + $this->recalculateClientTier($client); + } + + /** + * Obtener todos los tiers activos + */ + public function getActiveTiers() + { + return ClientTier::active() + ->orderBy('min_purchase_amount') + ->get(); + } + + /** + * Obtener tier apropiado para un monto específico + */ + public function getTierForAmount(float $amount): ?ClientTier + { + return ClientTier::active() + ->where('min_purchase_amount', '<=', $amount) + ->where(function ($query) use ($amount) { + $query->whereNull('max_purchase_amount') + ->orWhere('max_purchase_amount', '>=', $amount); + }) + ->orderBy('min_purchase_amount', 'desc') + ->first(); + } + + /** + * Obtener estadísticas del cliente + */ + public function getClientStats(Client $client): array + { + return [ + 'client_number' => $client->client_number, + 'total_purchases' => $client->total_purchases, + 'lifetime_returns' => $client->lifetime_returns, + 'net_purchases' => $client->net_purchases, + 'total_transactions' => $client->total_transactions, + 'average_purchase' => $client->total_transactions > 0 + ? $client->total_purchases / $client->total_transactions + : 0, + 'current_tier' => $client->tier ? [ + 'id' => $client->tier->id, + 'name' => $client->tier->tier_name, + 'discount' => $client->tier->discount_percentage, + ] : null, + 'next_tier' => $this->getNextTier($client), + ]; + } + + /** + * Obtener información del siguiente tier disponible + */ + protected function getNextTier(Client $client): ?array + { + $currentAmount = $client->net_purchases; + $currentTierId = $client->tier_id; + + $nextTier = ClientTier::active() + ->where('min_purchase_amount', '>', $currentAmount) + ->orderBy('min_purchase_amount') + ->first(); + + if ($nextTier) { + return [ + 'id' => $nextTier->id, + 'name' => $nextTier->tier_name, + 'discount' => $nextTier->discount_percentage, + 'required_amount' => $nextTier->min_purchase_amount, + 'remaining_amount' => $nextTier->min_purchase_amount - $currentAmount, + ]; + } + + return null; + } +} diff --git a/app/Services/ReturnService.php b/app/Services/ReturnService.php index c1a332d..80d2699 100644 --- a/app/Services/ReturnService.php +++ b/app/Services/ReturnService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Client; use App\Models\Returns; use App\Models\ReturnDetail; use App\Models\Sale; @@ -12,6 +13,12 @@ class ReturnService { + protected ClientTierService $clientTierService; + + public function __construct(ClientTierService $clientTierService) + { + $this->clientTierService = $clientTierService; + } /** * Crear una nueva devolución con sus detalles */ @@ -65,6 +72,13 @@ public function createReturn(array $data): Returns $total = $subtotal + $tax; + // Calcular descuento devuelto proporcional si la venta tenía descuento + $discountRefund = 0; + if ($sale->discount_amount > 0 && $sale->total > 0) { + $returnPercentage = $total / $sale->total; + $discountRefund = round($sale->discount_amount * $returnPercentage, 2); + } + // 3. Crear registro de devolución $return = Returns::create([ 'sale_id' => $sale->id, @@ -74,6 +88,7 @@ public function createReturn(array $data): Returns 'subtotal' => $subtotal, 'tax' => $tax, 'total' => $total, + 'discount_refund' => $discountRefund, 'refund_method' => $data['refund_method'] ?? $sale->payment_method, 'reason' => $data['reason'], 'notes' => $data['notes'] ?? null, @@ -154,11 +169,41 @@ public function createReturn(array $data): Returns } } - // 5. Retornar con relaciones cargadas + // 5. Actualizar estadísticas del cliente si existe + if ($sale->client_id) { + $client = Client::find($sale->client_id); + if ($client) { + $this->clientTierService->revertClientPurchaseStats($client, $return->total); + } + } + + // 6. Actualizar cash register con descuento devuelto y returns + if ($return->cash_register_id) { + $cashRegister = CashRegister::find($return->cash_register_id); + if ($cashRegister) { + // Actualizar tracking de devoluciones + $cashRegister->increment('total_returns', $return->total); + $cashRegister->increment('returns_count', 1); + + // Actualizar tracking de descuentos devueltos (restar de total_discounts) + if ($discountRefund > 0) { + $cashRegister->decrement('total_discounts', $discountRefund); + } + + // Actualizar por método de pago + if ($return->refund_method === 'cash') { + $cashRegister->increment('cash_returns', $return->total); + } else { + $cashRegister->increment('card_returns', $return->total); + } + } + } + + // 7. Retornar con relaciones cargadas return $return->load([ 'details.inventory', 'details.serials', - 'sale', + 'sale.client.tier', 'user', ]); }); diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index 934f29a..d76be7b 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -1,6 +1,7 @@ clientTierService = $clientTierService; + } /** * Crear una nueva venta con sus detalles * @@ -16,6 +23,26 @@ class SaleService public function createSale(array $data) { return DB::transaction(function () use ($data) { + // Obtener cliente si existe + $client = isset($data['client_id']) ? Client::find($data['client_id']) : null; + + // Calcular descuento si el cliente tiene tier + $discountPercentage = 0; + $discountAmount = 0; + $clientTierName = null; + + if ($client && $client->tier) { + $discountPercentage = $this->clientTierService->getApplicableDiscount($client); + $clientTierName = $client->tier->tier_name; + + // Calcular descuento sobre el subtotal + tax + $totalBeforeDiscount = $data['subtotal'] + $data['tax']; + $discountAmount = round($totalBeforeDiscount * ($discountPercentage / 100), 2); + + // Recalcular total con descuento + $data['total'] = $totalBeforeDiscount - $discountAmount; + } + // Calcular el cambio si es pago en efectivo $cashReceived = null; $change = null; @@ -28,11 +55,15 @@ public function createSale(array $data) // 1. Crear la venta principal $sale = Sale::create([ 'user_id' => $data['user_id'], + 'client_id' => $data['client_id'] ?? null, 'cash_register_id' => $data['cash_register_id'] ?? $this->getCurrentCashRegister($data['user_id']), 'invoice_number' => $data['invoice_number'] ?? $this->generateInvoiceNumber(), 'subtotal' => $data['subtotal'], 'tax' => $data['tax'], 'total' => $data['total'], + 'discount_percentage' => $discountPercentage, + 'discount_amount' => $discountAmount, + 'client_tier_name' => $clientTierName, 'cash_received' => $cashReceived, 'change' => $change, 'payment_method' => $data['payment_method'], @@ -41,6 +72,12 @@ public function createSale(array $data) // 2. Crear los detalles de la venta y asignar seriales foreach ($data['items'] as $item) { + // Calcular descuento por detalle si aplica + $itemDiscountAmount = 0; + if ($discountPercentage > 0) { + $itemDiscountAmount = round($item['subtotal'] * ($discountPercentage / 100), 2); + } + // Crear detalle de venta $saleDetail = SaleDetail::create([ 'sale_id' => $sale->id, @@ -49,6 +86,8 @@ public function createSale(array $data) 'quantity' => $item['quantity'], 'unit_price' => $item['unit_price'], 'subtotal' => $item['subtotal'], + 'discount_percentage' => $discountPercentage, + 'discount_amount' => $itemDiscountAmount, ]); // Obtener el inventario @@ -96,8 +135,21 @@ public function createSale(array $data) } } - // 3. Retornar la venta con sus relaciones cargadas - return $sale->load(['details.inventory', 'details.serials', 'user']); + // 3. Actualizar estadísticas del cliente si existe + if ($client && $sale->status === 'completed') { + $this->clientTierService->updateClientPurchaseStats($client, $sale->total); + } + + // 4. Actualizar cash register con descuentos + if ($sale->cash_register_id && $discountAmount > 0) { + $cashRegister = CashRegister::find($sale->cash_register_id); + if ($cashRegister) { + $cashRegister->increment('total_discounts', $discountAmount); + } + } + + // 5. Retornar la venta con sus relaciones cargadas + return $sale->load(['details.inventory', 'details.serials', 'user', 'client.tier']); }); } @@ -133,10 +185,31 @@ public function cancelSale(Sale $sale) } } + // Revertir estadísticas del cliente si existe + if ($sale->client_id) { + $client = Client::find($sale->client_id); + if ($client) { + // Restar de total_purchases y decrementar transacciones + $client->decrement('total_purchases', $sale->total); + $client->decrement('total_transactions', 1); + + // Recalcular tier después de cancelación + $this->clientTierService->recalculateClientTier($client); + } + } + + // Revertir descuentos del cash register + if ($sale->cash_register_id && $sale->discount_amount > 0) { + $cashRegister = CashRegister::find($sale->cash_register_id); + if ($cashRegister) { + $cashRegister->decrement('total_discounts', $sale->discount_amount); + } + } + // Marcar venta como cancelada $sale->update(['status' => 'cancelled']); - return $sale->fresh(['details.inventory', 'details.serials', 'user']); + return $sale->fresh(['details.inventory', 'details.serials', 'user', 'client.tier']); }); } diff --git a/database/migrations/2026_01_28_100700_create_client_tiers_table.php b/database/migrations/2026_01_28_100700_create_client_tiers_table.php new file mode 100644 index 0000000..4dac503 --- /dev/null +++ b/database/migrations/2026_01_28_100700_create_client_tiers_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('tier_name'); + $table->decimal('min_purchase_amount', 10, 2); + $table->decimal('max_purchase_amount', 10, 2)->nullable(); + $table->decimal('discount_percentage', 5, 2); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('client_tiers'); + } +}; diff --git a/database/migrations/2026_01_28_100708_add_tier_to_clients_table.php b/database/migrations/2026_01_28_100708_add_tier_to_clients_table.php new file mode 100644 index 0000000..96d3c1c --- /dev/null +++ b/database/migrations/2026_01_28_100708_add_tier_to_clients_table.php @@ -0,0 +1,34 @@ +string('client_number')->unique()->after('name'); + $table->foreignId('tier_id')->nullable()->constrained('client_tiers')->after('email'); + $table->decimal('total_purchases', 15, 2)->default(0)->after('tier_id'); + $table->integer('total_transactions')->default(0)->after('total_purchases'); + $table->decimal('lifetime_returns', 10, 2)->default(0)->after('total_transactions'); + $table->timestamp('last_purchase_at')->nullable()->after('lifetime_returns'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('clients', function (Blueprint $table) { + $table->dropForeign(['tier_id']); + $table->dropColumn(['client_number', 'tier_id', 'total_purchases', 'total_transactions', 'lifetime_returns', 'last_purchase_at']); + }); + } +}; diff --git a/database/migrations/2026_01_28_101614_add_discount_to_sales_table.php b/database/migrations/2026_01_28_101614_add_discount_to_sales_table.php new file mode 100644 index 0000000..7b12abb --- /dev/null +++ b/database/migrations/2026_01_28_101614_add_discount_to_sales_table.php @@ -0,0 +1,30 @@ +decimal('discount_percentage', 5, 2)->default(0)->after('total'); + $table->decimal('discount_amount', 10, 2)->default(0)->after('discount_percentage'); + $table->string('client_tier_name')->nullable()->after('discount_amount'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sales', function (Blueprint $table) { + $table->dropColumn(['discount_percentage', 'discount_amount', 'client_tier_name']); + }); + } +}; diff --git a/database/migrations/2026_01_28_101842_add_discount_to_sale_details_table.php b/database/migrations/2026_01_28_101842_add_discount_to_sale_details_table.php new file mode 100644 index 0000000..777358e --- /dev/null +++ b/database/migrations/2026_01_28_101842_add_discount_to_sale_details_table.php @@ -0,0 +1,29 @@ +decimal('discount_percentage', 5, 2)->default(0)->after('subtotal'); + $table->decimal('discount_amount', 10, 2)->default(0)->after('discount_percentage'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sale_details', function (Blueprint $table) { + $table->dropColumn(['discount_percentage', 'discount_amount']); + }); + } +}; diff --git a/database/migrations/2026_01_28_101916_add_discount_to_returns_table.php b/database/migrations/2026_01_28_101916_add_discount_to_returns_table.php new file mode 100644 index 0000000..478f992 --- /dev/null +++ b/database/migrations/2026_01_28_101916_add_discount_to_returns_table.php @@ -0,0 +1,28 @@ +decimal('discount_refund', 10, 2)->default(0)->after('total'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('returns', function (Blueprint $table) { + $table->dropColumn('discount_refund'); + }); + } +}; diff --git a/database/migrations/2026_01_28_101957_add_discount_to_cash_registers_table.php b/database/migrations/2026_01_28_101957_add_discount_to_cash_registers_table.php new file mode 100644 index 0000000..5b74311 --- /dev/null +++ b/database/migrations/2026_01_28_101957_add_discount_to_cash_registers_table.php @@ -0,0 +1,28 @@ +decimal('total_discounts', 10, 2)->default(0)->after('returns_count'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('cash_registers', function (Blueprint $table) { + $table->dropColumn('total_discounts'); + }); + } +}; diff --git a/database/migrations/2026_01_28_110914_create_client_tier_history_table.php b/database/migrations/2026_01_28_110914_create_client_tier_history_table.php new file mode 100644 index 0000000..a4d9854 --- /dev/null +++ b/database/migrations/2026_01_28_110914_create_client_tier_history_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('client_id')->constrained('clients'); + $table->foreignId('old_tier_id')->nullable()->constrained('client_tiers'); + $table->foreignId('new_tier_id')->constrained('client_tiers'); + $table->decimal('total_at_change', 10, 2); + $table->text('reason')->nullable(); + $table->timestamp('changed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('client_tier_history'); + } +}; diff --git a/database/seeders/ClientTierSeeder.php b/database/seeders/ClientTierSeeder.php new file mode 100644 index 0000000..a144b8d --- /dev/null +++ b/database/seeders/ClientTierSeeder.php @@ -0,0 +1,61 @@ + 'Bronce', + 'min_purchase_amount' => 0.00, + 'max_purchase_amount' => 10000.00, + 'discount_percentage' => 0.00, + 'is_active' => true, + ], + [ + 'tier_name' => 'Plata', + 'min_purchase_amount' => 15000.00, + 'max_purchase_amount' => 20000.00, + 'discount_percentage' => 5.00, + 'is_active' => true, + ], + [ + 'tier_name' => 'Oro', + 'min_purchase_amount' => 25000.00, + 'max_purchase_amount' => 40000.00, + 'discount_percentage' => 10.00, + 'is_active' => true, + ], + [ + 'tier_name' => 'Platino', + 'min_purchase_amount' => 60000.00, + 'max_purchase_amount' => null, // Sin límite superior + 'discount_percentage' => 15.00, + 'is_active' => true, + ], + ]; + + foreach ($tiers as $tier) { + ClientTier::create($tier); + } + + $this->command->info('Client tiers creados exitosamente'); + $this->command->table( + ['Tier', 'Desde', 'Hasta', 'Descuento'], + collect($tiers)->map(fn($t) => [ + $t['tier_name'], + '$' . number_format($t['min_purchase_amount'], 2), + $t['max_purchase_amount'] ? '$' . number_format($t['max_purchase_amount'], 2) : 'Sin límite', + $t['discount_percentage'] . '%', + ]) + ); + } +} diff --git a/routes/api.php b/routes/api.php index 5d1d17b..2f61645 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,8 @@ use App\Http\Controllers\App\CashRegisterController; use App\Http\Controllers\App\CategoryController; use App\Http\Controllers\App\ClientController; +use App\Http\Controllers\App\ClientTierController; +use App\Http\Controllers\App\ExcelController; use App\Http\Controllers\App\InventoryController; use App\Http\Controllers\App\PriceController; use App\Http\Controllers\App\ReportController; @@ -72,10 +74,29 @@ Route::prefix('reports')->group(function () { Route::get('top-selling-product', [ReportController::class, 'topSellingProduct']); Route::get('products-without-movement', [ReportController::class, 'productsWithoutMovement']); + Route::get('client-discounts/excel', [ExcelController::class, 'clientDiscountsReport']); }); //CLIENTES Route::resource('clients', ClientController::class); + + // ESTADÍSTICAS Y DESCUENTOS DE CLIENTES + Route::prefix('clients')->group(function () { + Route::get('/{client}/stats', [ClientController::class, 'stats']); + Route::get('/{client}/tier-history', [ClientController::class, 'tierHistory']); + Route::get('/{client}/sales-with-discounts', [ClientController::class, 'salesWithDiscounts']); + }); + + // RANGOS DE CLIENTES + Route::prefix('client-tiers')->group(function () { + Route::get('/', [ClientTierController::class, 'index']); + Route::get('/active', [ClientTierController::class, 'active']); + Route::get('/{tier}', [ClientTierController::class, 'show']); + Route::post('/', [ClientTierController::class, 'store']); + Route::put('/{tier}', [ClientTierController::class, 'update']); + Route::delete('/{tier}', [ClientTierController::class, 'destroy']); + Route::patch('/{tier}/toggle-active', [ClientTierController::class, 'toggleActive']); + }); }); /** Rutas públicas */