add: creación del modulo de tiers de clientes

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-28 16:46:31 -06:00
parent 5f0d4ec28e
commit 4357c560df
24 changed files with 1319 additions and 9 deletions

View File

@ -4,10 +4,17 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Client; use App\Models\Client;
use App\Services\ClientTierService;
use Notsoweb\ApiResponse\Enums\ApiResponse; use Notsoweb\ApiResponse\Enums\ApiResponse;
class ClientController extends Controller class ClientController extends Controller
{ {
protected ClientTierService $clientTierService;
public function __construct(ClientTierService $clientTierService)
{
$this->clientTierService = $clientTierService;
}
public function index(Request $request) public function index(Request $request)
{ {
$query = Client::query(); $query = Client::query();
@ -52,14 +59,21 @@ public function store(Request $request)
]); ]);
try{ try{
$data = $request->only([
$client = Client::create($request->only([
'name', 'name',
'email', 'email',
'phone', 'phone',
'address', 'address',
'rfc', '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([ return ApiResponse::OK->response([
'client' => $client, '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()
]);
}
}
} }

View File

@ -0,0 +1,110 @@
<?php
namespace App\Http\Controllers\App;
use App\Models\ClientTier;
use App\Http\Controllers\Controller;
use App\Http\Requests\ClientTiers\ClientTierStoreRequest;
use App\Http\Requests\ClientTiers\ClientTierUpdateRequest;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class ClientTierController extends Controller
{
/**
* Display a listing of client tiers
*/
public function index()
{
$tiers = ClientTier::withCount('clients')
->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
]);
}
}

View File

@ -0,0 +1,226 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\Client;
use App\Models\Sale;
use Illuminate\Http\Request;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use Carbon\Carbon;
class ExcelController extends Controller
{
/**
* Generar reporte Excel de descuentos a clientes
*/
public function clientDiscountsReport(Request $request)
{
// 1. VALIDACIÓN Y OBTENCIÓN DE DATOS
$request->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);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests\ClientTiers;
use Illuminate\Foundation\Http\FormRequest;
class ClientTierStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'tier_name' => ['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%',
];
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests\ClientTiers;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ClientTierUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$tierId = $this->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%',
];
}
}

View File

@ -20,6 +20,7 @@ class CashRegister extends Model
'cash_returns', 'cash_returns',
'card_returns', 'card_returns',
'returns_count', 'returns_count',
'total_discounts',
'notes', 'notes',
'status', 'status',
]; ];
@ -35,6 +36,7 @@ class CashRegister extends Model
'total_returns' => 'decimal:2', 'total_returns' => 'decimal:2',
'cash_returns' => 'decimal:2', 'cash_returns' => 'decimal:2',
'card_returns' => 'decimal:2', 'card_returns' => 'decimal:2',
'total_discounts' => 'decimal:2',
]; ];
public function user() public function user()

View File

@ -1,11 +1,14 @@
<?php namespace App\Models; <?php namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Client extends Model class Client extends Model
{ {
protected $fillable = [ protected $fillable = [
'name', 'name',
'client_number',
'email', 'email',
'phone', 'phone',
'address', 'address',
@ -14,10 +17,54 @@ class Client extends Model
'regimen_fiscal', 'regimen_fiscal',
'cp_fiscal', 'cp_fiscal',
'uso_cfdi', 'uso_cfdi',
'tier_id',
'total_purchases',
'total_transactions',
'lifetime_returns',
'last_purchase_at',
]; ];
public function sales() protected $casts = [
'total_purchases' => 'decimal:2',
'total_transactions' => 'integer',
'lifetime_returns' => 'decimal:2',
'last_purchase_at' => 'datetime',
];
public function sales(): HasMany
{ {
return $this->hasMany(Sale::class); 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;
}
} }

76
app/Models/ClientTier.php Normal file
View File

@ -0,0 +1,76 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ClientTier extends Model
{
protected $fillable = [
'tier_name',
'min_purchase_amount',
'max_purchase_amount',
'discount_percentage',
'is_active',
];
protected $casts = [
'min_purchase_amount' => '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;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClientTierHistory extends Model
{
protected $table = 'client_tier_history';
public $timestamps = false;
protected $fillable = [
'client_id',
'old_tier_id',
'new_tier_id',
'total_at_change',
'reason',
'changed_at',
];
protected $casts = [
'total_at_change' => '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');
}
}

View File

@ -15,6 +15,7 @@ class Returns extends Model
'subtotal', 'subtotal',
'tax', 'tax',
'total', 'total',
'discount_refund',
'refund_method', 'refund_method',
'reason', 'reason',
'notes' 'notes'
@ -24,6 +25,7 @@ class Returns extends Model
'subtotal' => 'decimal:2', 'subtotal' => 'decimal:2',
'tax' => 'decimal:2', 'tax' => 'decimal:2',
'total' => 'decimal:2', 'total' => 'decimal:2',
'discount_refund' => 'decimal:2',
'created_at' => 'datetime', 'created_at' => 'datetime',
]; ];

View File

@ -23,6 +23,9 @@ class Sale extends Model
'subtotal', 'subtotal',
'tax', 'tax',
'total', 'total',
'discount_percentage',
'discount_amount',
'client_tier_name',
'cash_received', 'cash_received',
'change', 'change',
'payment_method', 'payment_method',
@ -33,6 +36,8 @@ class Sale extends Model
'subtotal' => 'decimal:2', 'subtotal' => 'decimal:2',
'tax' => 'decimal:2', 'tax' => 'decimal:2',
'total' => 'decimal:2', 'total' => 'decimal:2',
'discount_percentage' => 'decimal:2',
'discount_amount' => 'decimal:2',
'cash_received' => 'decimal:2', 'cash_received' => 'decimal:2',
'change' => 'decimal:2', 'change' => 'decimal:2',
]; ];

View File

@ -22,11 +22,15 @@ class SaleDetail extends Model
'quantity', 'quantity',
'unit_price', 'unit_price',
'subtotal', 'subtotal',
'discount_percentage',
'discount_amount',
]; ];
protected $casts = [ protected $casts = [
'unit_price' => 'decimal:2', 'unit_price' => 'decimal:2',
'subtotal' => 'decimal:2', 'subtotal' => 'decimal:2',
'discount_percentage' => 'decimal:2',
'discount_amount' => 'decimal:2',
]; ];
public function sale() public function sale()

View File

@ -0,0 +1,177 @@
<?php
namespace App\Services;
use App\Models\Client;
use App\Models\ClientTier;
use App\Models\ClientTierHistory;
use Illuminate\Support\Facades\DB;
class ClientTierService
{
/**
* Generar número de cliente único secuencial
*/
public function generateClientNumber(): string
{
$lastClient = Client::orderBy('id', 'desc')->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;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use App\Models\Client;
use App\Models\Returns; use App\Models\Returns;
use App\Models\ReturnDetail; use App\Models\ReturnDetail;
use App\Models\Sale; use App\Models\Sale;
@ -12,6 +13,12 @@
class ReturnService class ReturnService
{ {
protected ClientTierService $clientTierService;
public function __construct(ClientTierService $clientTierService)
{
$this->clientTierService = $clientTierService;
}
/** /**
* Crear una nueva devolución con sus detalles * Crear una nueva devolución con sus detalles
*/ */
@ -65,6 +72,13 @@ public function createReturn(array $data): Returns
$total = $subtotal + $tax; $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 // 3. Crear registro de devolución
$return = Returns::create([ $return = Returns::create([
'sale_id' => $sale->id, 'sale_id' => $sale->id,
@ -74,6 +88,7 @@ public function createReturn(array $data): Returns
'subtotal' => $subtotal, 'subtotal' => $subtotal,
'tax' => $tax, 'tax' => $tax,
'total' => $total, 'total' => $total,
'discount_refund' => $discountRefund,
'refund_method' => $data['refund_method'] ?? $sale->payment_method, 'refund_method' => $data['refund_method'] ?? $sale->payment_method,
'reason' => $data['reason'], 'reason' => $data['reason'],
'notes' => $data['notes'] ?? null, '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([ return $return->load([
'details.inventory', 'details.inventory',
'details.serials', 'details.serials',
'sale', 'sale.client.tier',
'user', 'user',
]); ]);
}); });

View File

@ -1,6 +1,7 @@
<?php namespace App\Services; <?php namespace App\Services;
use App\Models\CashRegister; use App\Models\CashRegister;
use App\Models\Client;
use App\Models\Sale; use App\Models\Sale;
use App\Models\SaleDetail; use App\Models\SaleDetail;
use App\Models\Inventory; use App\Models\Inventory;
@ -9,6 +10,12 @@
class SaleService class SaleService
{ {
protected ClientTierService $clientTierService;
public function __construct(ClientTierService $clientTierService)
{
$this->clientTierService = $clientTierService;
}
/** /**
* Crear una nueva venta con sus detalles * Crear una nueva venta con sus detalles
* *
@ -16,6 +23,26 @@ class SaleService
public function createSale(array $data) public function createSale(array $data)
{ {
return DB::transaction(function () use ($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 // Calcular el cambio si es pago en efectivo
$cashReceived = null; $cashReceived = null;
$change = null; $change = null;
@ -28,11 +55,15 @@ public function createSale(array $data)
// 1. Crear la venta principal // 1. Crear la venta principal
$sale = Sale::create([ $sale = Sale::create([
'user_id' => $data['user_id'], 'user_id' => $data['user_id'],
'client_id' => $data['client_id'] ?? null,
'cash_register_id' => $data['cash_register_id'] ?? $this->getCurrentCashRegister($data['user_id']), 'cash_register_id' => $data['cash_register_id'] ?? $this->getCurrentCashRegister($data['user_id']),
'invoice_number' => $data['invoice_number'] ?? $this->generateInvoiceNumber(), 'invoice_number' => $data['invoice_number'] ?? $this->generateInvoiceNumber(),
'subtotal' => $data['subtotal'], 'subtotal' => $data['subtotal'],
'tax' => $data['tax'], 'tax' => $data['tax'],
'total' => $data['total'], 'total' => $data['total'],
'discount_percentage' => $discountPercentage,
'discount_amount' => $discountAmount,
'client_tier_name' => $clientTierName,
'cash_received' => $cashReceived, 'cash_received' => $cashReceived,
'change' => $change, 'change' => $change,
'payment_method' => $data['payment_method'], 'payment_method' => $data['payment_method'],
@ -41,6 +72,12 @@ public function createSale(array $data)
// 2. Crear los detalles de la venta y asignar seriales // 2. Crear los detalles de la venta y asignar seriales
foreach ($data['items'] as $item) { 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 // Crear detalle de venta
$saleDetail = SaleDetail::create([ $saleDetail = SaleDetail::create([
'sale_id' => $sale->id, 'sale_id' => $sale->id,
@ -49,6 +86,8 @@ public function createSale(array $data)
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'], 'unit_price' => $item['unit_price'],
'subtotal' => $item['subtotal'], 'subtotal' => $item['subtotal'],
'discount_percentage' => $discountPercentage,
'discount_amount' => $itemDiscountAmount,
]); ]);
// Obtener el inventario // Obtener el inventario
@ -96,8 +135,21 @@ public function createSale(array $data)
} }
} }
// 3. Retornar la venta con sus relaciones cargadas // 3. Actualizar estadísticas del cliente si existe
return $sale->load(['details.inventory', 'details.serials', 'user']); 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 // Marcar venta como cancelada
$sale->update(['status' => 'cancelled']); $sale->update(['status' => 'cancelled']);
return $sale->fresh(['details.inventory', 'details.serials', 'user']); return $sale->fresh(['details.inventory', 'details.serials', 'user', 'client.tier']);
}); });
} }

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('client_tiers', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('clients', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('sales', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('sale_details', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('returns', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cash_registers', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('client_tier_history', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,61 @@
<?php
namespace Database\Seeders;
use App\Models\ClientTier;
use Illuminate\Database\Seeder;
class ClientTierSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$tiers = [
[
'tier_name' => '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'] . '%',
])
);
}
}

View File

@ -3,6 +3,8 @@
use App\Http\Controllers\App\CashRegisterController; use App\Http\Controllers\App\CashRegisterController;
use App\Http\Controllers\App\CategoryController; use App\Http\Controllers\App\CategoryController;
use App\Http\Controllers\App\ClientController; 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\InventoryController;
use App\Http\Controllers\App\PriceController; use App\Http\Controllers\App\PriceController;
use App\Http\Controllers\App\ReportController; use App\Http\Controllers\App\ReportController;
@ -72,10 +74,29 @@
Route::prefix('reports')->group(function () { Route::prefix('reports')->group(function () {
Route::get('top-selling-product', [ReportController::class, 'topSellingProduct']); Route::get('top-selling-product', [ReportController::class, 'topSellingProduct']);
Route::get('products-without-movement', [ReportController::class, 'productsWithoutMovement']); Route::get('products-without-movement', [ReportController::class, 'productsWithoutMovement']);
Route::get('client-discounts/excel', [ExcelController::class, 'clientDiscountsReport']);
}); });
//CLIENTES //CLIENTES
Route::resource('clients', ClientController::class); 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 */ /** Rutas públicas */