From c5d8e3c65bfdb69460bf4566cbaf86dfe9721f15 Mon Sep 17 00:00:00 2001 From: Juan Felipe Zapata Moreno Date: Fri, 30 Jan 2026 14:18:24 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20agregar=20reporte=20de=20ventas=20en=20?= =?UTF-8?q?formato=20Excel=20y=20ajustar=20l=C3=B3gica=20de=20descuentos?= =?UTF-8?q?=20en=20ventas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/App/ClientController.php | 21 +- app/Http/Controllers/App/ExcelController.php | 249 +++++++++++++++++- .../Controllers/App/FacturaDataController.php | 12 +- app/Services/ClientTierService.php | 12 - app/Services/SaleService.php | 11 +- routes/api.php | 1 + 6 files changed, 271 insertions(+), 35 deletions(-) diff --git a/app/Http/Controllers/App/ClientController.php b/app/Http/Controllers/App/ClientController.php index 9333220..2170aec 100644 --- a/app/Http/Controllers/App/ClientController.php +++ b/app/Http/Controllers/App/ClientController.php @@ -25,7 +25,7 @@ public function index(Request $request) } if ($request->has('client_number') && $request->client_number) { - $query->where('client_number', $request->client_number); + $query->where('client_number', 'like', "%{$request->client_number}%"); } elseif ($request->has('q') && $request->q) { @@ -55,11 +55,16 @@ public function store(Request $request) 'email' => 'nullable|email|max:255', 'phone' => 'nullable|string|max:20', 'address' => 'nullable|string|max:500', - 'rfc' => 'nullable|string|max:13', + 'rfc' => 'required|string|max:13', + 'razon_social' => 'nullable|string|max:255', + 'regimen_fiscal' => 'nullable|string|max:100', + 'cp_fiscal' => 'nullable|string|max:5', + 'uso_cfdi' => 'nullable|string|max:100', ],[ 'email.unique' => 'El correo electrónico ya está en uso por otro cliente.', 'phone.unique' => 'El teléfono ya está en uso por otro cliente.', 'rfc.unique' => 'El RFC ya está en uso por otro cliente.', + 'rfc.required' => 'El RFC es obligatorio.', ]); try{ @@ -69,10 +74,14 @@ public function store(Request $request) 'phone', 'address', 'rfc', + 'razon_social', + 'regimen_fiscal', + 'cp_fiscal', + 'uso_cfdi', ]); - // Generar client_number automáticamente - $data['client_number'] = $this->clientTierService->generateClientNumber(); + // Usar RFC como client_number + $data['client_number'] = $data['rfc'] ?? null; $client = Client::create($data); @@ -113,6 +122,10 @@ public function update(Request $request, Client $client) 'phone', 'address', 'rfc', + 'razon_social', + 'regimen_fiscal', + 'cp_fiscal', + 'uso_cfdi', ])); return ApiResponse::OK->response([ diff --git a/app/Http/Controllers/App/ExcelController.php b/app/Http/Controllers/App/ExcelController.php index 10b2db9..6cfdcde 100644 --- a/app/Http/Controllers/App/ExcelController.php +++ b/app/Http/Controllers/App/ExcelController.php @@ -35,8 +35,7 @@ public function clientDiscountsReport(Request $request) $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]); + $q->whereBetween('created_at', [$fechaInicio, $fechaFin]); }) ->when($tierId, function($query) use ($tierId) { $query->where('tier_id', $tierId); @@ -50,7 +49,6 @@ public function clientDiscountsReport(Request $request) // 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(); @@ -223,4 +221,249 @@ public function clientDiscountsReport(Request $request) return response()->download($filePath, $fileName)->deleteFileAfterSend(true); } + + /** + * Generar reporte Excel de ventas + */ + public function salesReport(Request $request) + { + // 1. VALIDACIÓN + $request->validate([ + 'fecha_inicio' => 'required|date', + 'fecha_fin' => 'required|date|after_or_equal:fecha_inicio', + 'client_id' => 'nullable|exists:clients,id', + 'user_id' => 'nullable|exists:users,id', + 'status' => 'nullable|in:completed,cancelled', + ]); + + $fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay(); + $fechaFin = Carbon::parse($request->fecha_fin)->endOfDay(); + + // 2. CONSULTA DE VENTAS + $sales = Sale::with(['client:id,name,client_number,rfc', 'user:id,name', 'details']) + ->whereBetween('created_at', [$fechaInicio, $fechaFin]) + ->when($request->client_id, function($query) use ($request) { + $query->where('client_id', $request->client_id); + }) + ->when($request->user_id, function($query) use ($request) { + $query->where('user_id', $request->user_id); + }) + ->when($request->status, function($query) use ($request) { + $query->where('status', $request->status); + }) + ->orderBy('created_at', 'desc') + ->get(); + + if ($sales->isEmpty()) { + return response()->json(['message' => 'No se encontraron ventas en el periodo especificado'], 404); + } + + // 3. MAPEO DE DATOS + $data = $sales->map(function($sale) { + return [ + 'folio' => $sale->invoice_number, + 'fecha' => $sale->created_at->format('d/m/Y H:i'), + 'cliente' => $sale->client?->name ?? 'N/A', + 'rfc_cliente' => $sale->client?->rfc ?? 'N/A', + 'vendedor' => $sale->user?->name ?? 'N/A', + 'subtotal' => (float) $sale->subtotal, + 'iva' => (float) $sale->tax, + 'descuento' => (float) ($sale->discount_amount ?? 0), + 'total' => (float) $sale->total, + 'metodo_pago' => $this->translatePaymentMethod($sale->payment_method), + 'status' => $sale->status === 'completed' ? 'Completada' : 'Cancelada', + 'productos' => $sale->details->count(), + ]; + }); + + // 4. CONFIGURACIÓN EXCEL + $fileName = 'Reporte_Ventas_' . $fechaInicio->format('Ymd') . '_' . $fechaFin->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' => '2E75B6']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true], + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]] + ]; + + // --- ESTRUCTURA DEL DOCUMENTO --- + $lastCol = 'L'; + $sheet->getRowDimension(2)->setRowHeight(10); + $sheet->getRowDimension(3)->setRowHeight(25); + $sheet->getRowDimension(5)->setRowHeight(30); + + // --- TÍTULO PRINCIPAL --- + $sheet->mergeCells("A3:{$lastCol}3"); + $sheet->setCellValue('A3', 'REPORTE DE VENTAS'); + $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:{$lastCol}5"); + $sheet->setCellValue('C5', $periodoTexto); + $sheet->getStyle("C5:{$lastCol}5")->applyFromArray($styleBox); + $sheet->getStyle('C5')->getFont()->setSize(12); + + // --- RESUMEN DE TOTALES --- + $totalVentas = $data->count(); + $totalSubtotal = $data->sum('subtotal'); + $totalIva = $data->sum('iva'); + $totalDescuentos = $data->sum('descuento'); + $totalMonto = $data->sum('total'); + + $row = 7; + $sheet->setCellValue('A' . $row, 'TOTAL VENTAS:'); + $sheet->setCellValue('B' . $row, $totalVentas); + $sheet->setCellValue('D' . $row, 'SUBTOTAL:'); + $sheet->setCellValue('E' . $row, '$' . number_format($totalSubtotal, 2)); + $sheet->setCellValue('G' . $row, 'IVA:'); + $sheet->setCellValue('H' . $row, '$' . number_format($totalIva, 2)); + $sheet->setCellValue('I' . $row, 'DESCUENTOS:'); + $sheet->setCellValue('J' . $row, '$' . number_format($totalDescuentos, 2)); + $sheet->setCellValue('K' . $row, 'TOTAL:'); + $sheet->setCellValue('L' . $row, '$' . number_format($totalMonto, 2)); + + $sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFont()->setBold(true); + $sheet->getStyle('B' . $row)->getFont()->setSize(12)->getColor()->setRGB('2E75B6'); + $sheet->getStyle('E' . $row)->getFont()->setSize(12)->getColor()->setRGB('2E75B6'); + $sheet->getStyle('H' . $row)->getFont()->setSize(12)->getColor()->setRGB('2E75B6'); + $sheet->getStyle('J' . $row)->getFont()->setSize(12)->getColor()->setRGB('FF6600'); + $sheet->getStyle('L' . $row)->getFont()->setSize(12)->getColor()->setRGB('008000'); + + // --- ENCABEZADOS DE TABLA --- + $h = 9; + $headers = [ + 'A' => 'No.', + 'B' => 'FOLIO', + 'C' => 'FECHA', + 'D' => 'CLIENTE', + 'E' => 'RFC', + 'F' => 'VENDEDOR', + 'G' => 'SUBTOTAL', + 'H' => 'IVA', + 'I' => 'DESCUENTO', + 'J' => 'TOTAL', + 'K' => "MÉTODO\nPAGO", + 'L' => 'ESTADO', + ]; + + foreach ($headers as $col => $text) { + $sheet->setCellValue("{$col}{$h}", $text); + } + + $sheet->getStyle("A{$h}:{$lastCol}{$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['folio']); + $sheet->setCellValue('C' . $row, $item['fecha']); + $sheet->setCellValue('D' . $row, $item['cliente']); + $sheet->setCellValue('E' . $row, $item['rfc_cliente']); + $sheet->setCellValue('F' . $row, $item['vendedor']); + $sheet->setCellValue('G' . $row, '$' . number_format($item['subtotal'], 2)); + $sheet->setCellValue('H' . $row, '$' . number_format($item['iva'], 2)); + $sheet->setCellValue('I' . $row, '$' . number_format($item['descuento'], 2)); + $sheet->setCellValue('J' . $row, '$' . number_format($item['total'], 2)); + $sheet->setCellValue('K' . $row, $item['metodo_pago']); + $sheet->setCellValue('L' . $row, $item['status']); + + // Estilos de fila + $sheet->getStyle("A{$row}:{$lastCol}{$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("C{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("K{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("L{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + + // Color de estado + if ($item['status'] === 'Cancelada') { + $sheet->getStyle("L{$row}")->getFont()->getColor()->setRGB('FF0000'); + } + + // Color alterno de filas + if ($i % 2 == 0) { + $sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFill() + ->setFillType(Fill::FILL_SOLID) + ->getStartColor()->setRGB('F2F2F2'); + } + + $row++; + $i++; + } + + // --- ANCHOS DE COLUMNA --- + $sheet->getColumnDimension('A')->setWidth(5); + $sheet->getColumnDimension('B')->setWidth(22); + $sheet->getColumnDimension('C')->setWidth(18); + $sheet->getColumnDimension('D')->setWidth(22); + $sheet->getColumnDimension('E')->setWidth(16); + $sheet->getColumnDimension('F')->setWidth(20); + $sheet->getColumnDimension('G')->setWidth(14); + $sheet->getColumnDimension('H')->setWidth(12); + $sheet->getColumnDimension('I')->setWidth(14); + $sheet->getColumnDimension('J')->setWidth(14); + $sheet->getColumnDimension('K')->setWidth(14); + $sheet->getColumnDimension('L')->setWidth(14); + + $writer = new Xlsx($spreadsheet); + $writer->save($filePath); + + return response()->download($filePath, $fileName)->deleteFileAfterSend(true); + } + + /** + * Traducir método de pago + */ + private function translatePaymentMethod(?string $method): string + { + return match($method) { + 'cash' => 'Efectivo', + 'credit_card' => 'T. Crédito', + 'debit_card' => 'T. Débito', + 'transfer' => 'Transferencia', + default => $method ?? 'N/A', + }; + } } diff --git a/app/Http/Controllers/App/FacturaDataController.php b/app/Http/Controllers/App/FacturaDataController.php index a18c31f..41b6485 100644 --- a/app/Http/Controllers/App/FacturaDataController.php +++ b/app/Http/Controllers/App/FacturaDataController.php @@ -30,17 +30,9 @@ public function show(string $invoiceNumber) ]); } - // Si ya tiene datos de facturación - if ($sale->client_id) { - return ApiResponse::NO_CONTENT->response([ - 'message' => 'Esta venta ya tiene datos de facturación registrados', - 'client' => $sale->client, - 'sale' => $this->formatSaleData($sale) - ]); - } - return ApiResponse::OK->response([ - 'sale' => $this->formatSaleData($sale) + 'sale' => $this->formatSaleData($sale), + 'client' => $sale->client, ]); } diff --git a/app/Services/ClientTierService.php b/app/Services/ClientTierService.php index 65ff928..870722c 100644 --- a/app/Services/ClientTierService.php +++ b/app/Services/ClientTierService.php @@ -5,21 +5,9 @@ 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 */ diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index e5ae628..e0d7ecc 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -40,12 +40,11 @@ public function createSale(array $data) $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); + // Calcular descuento solo sobre el subtotal (sin IVA) + $discountAmount = round($data['subtotal'] * ($discountPercentage / 100), 2); - // Recalcular total con descuento - $data['total'] = $totalBeforeDiscount - $discountAmount; + // Recalcular total: subtotal - descuento + IVA + $data['total'] = ($data['subtotal'] - $discountAmount) + $data['tax']; } // Calcular el cambio si es pago en efectivo @@ -60,7 +59,7 @@ public function createSale(array $data) // 1. Crear la venta principal $sale = Sale::create([ 'user_id' => $data['user_id'], - 'client_id' => $data['client_id'] ?? null, + 'client_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'], diff --git a/routes/api.php b/routes/api.php index 2f61645..1a7af85 100644 --- a/routes/api.php +++ b/routes/api.php @@ -75,6 +75,7 @@ Route::get('top-selling-product', [ReportController::class, 'topSellingProduct']); Route::get('products-without-movement', [ReportController::class, 'productsWithoutMovement']); Route::get('client-discounts/excel', [ExcelController::class, 'clientDiscountsReport']); + Route::get('sales/excel', [ExcelController::class, 'salesReport']); }); //CLIENTES