diff --git a/app/Http/Controllers/App/ExcelController.php b/app/Http/Controllers/App/ExcelController.php index 6cfdcde..484335a 100644 --- a/app/Http/Controllers/App/ExcelController.php +++ b/app/Http/Controllers/App/ExcelController.php @@ -4,7 +4,9 @@ use App\Http\Controllers\Controller; use App\Models\Client; +use App\Models\Inventory; use App\Models\Sale; +use App\Models\SaleDetail; use Illuminate\Http\Request; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Writer\Xlsx; @@ -453,6 +455,253 @@ public function salesReport(Request $request) return response()->download($filePath, $fileName)->deleteFileAfterSend(true); } + /** + * Generar reporte Excel de inventario + */ + public function inventoryReport(Request $request) + { + // 1. VALIDACIÓN + $request->validate([ + 'category_id' => 'nullable|exists:categories,id', + 'with_serials_only' => 'nullable|boolean', + 'low_stock_threshold' => 'nullable|integer|min:0', + ]); + + // 2. CONSULTA DE INVENTARIO + $query = Inventory::with([ + 'category:id,name', + 'price:inventory_id,retail_price', + 'serials' + ]) + ->when($request->category_id, function($q) use ($request) { + $q->where('category_id', $request->category_id); + }) + ->when($request->with_serials_only, function($q) { + $q->where('track_serials', true); + }) + ->when($request->low_stock_threshold, function($q) use ($request) { + $q->where('stock', '<=', $request->low_stock_threshold); + }); + + $inventories = $query->orderBy('name')->get(); + + if ($inventories->isEmpty()) { + return response()->json(['message' => 'No se encontraron productos'], 404); + } + + // 3. MAPEO DE DATOS + $data = $inventories->map(function($inventory) { + // Cantidad vendida total + $quantitySold = SaleDetail::where('inventory_id', $inventory->id) + ->whereHas('sale', function($q) { + $q->where('status', 'completed'); + }) + ->sum('quantity'); + + // Conteo de seriales + $serialsTotal = $inventory->serials->count(); + $serialsAvailable = $inventory->serials->where('status', 'disponible')->count(); + $serialsSold = $inventory->serials->where('status', 'vendido')->count(); + $serialsReturned = $inventory->serials->where('status', 'devuelto')->count(); + + $price = $inventory->price?->retail_price ?? 0; + $totalSold = $quantitySold * $price; + $inventoryValue = $inventory->stock * $price; + + return [ + 'sku' => $inventory->sku ?? 'N/A', + 'name' => $inventory->name, + 'category' => $inventory->category?->name ?? 'Sin categoría', + 'stock' => $inventory->stock, + 'quantity_sold' => $quantitySold, + 'serials_total' => $serialsTotal, + 'serials_available' => $serialsAvailable, + 'serials_sold' => $serialsSold, + 'serials_returned' => $serialsReturned, + 'price' => (float) $price, + 'total_sold' => $totalSold, + 'inventory_value' => $inventoryValue, + ]; + }); + + // 4. CONFIGURACIÓN EXCEL + $fileName = 'Reporte_Inventario_' . now()->format('Ymd_His') . '.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' => '70AD47']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true], + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]] + ]; + + // --- ESTRUCTURA DEL DOCUMENTO --- + $lastCol = 'M'; + $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 INVENTARIO'); + $sheet->getStyle('A3')->applyFromArray([ + 'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '000000']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], + ]); + + // --- INFORMACIÓN DE FECHA --- + $sheet->mergeCells('A5:B5'); + $sheet->setCellValue('A5', 'FECHA:'); + $sheet->getStyle('A5')->applyFromArray($styleLabel); + + $sheet->mergeCells("C5:{$lastCol}5"); + Carbon::setLocale('es'); + $sheet->setCellValue('C5', ucfirst(now()->translatedFormat('l j \d\e F Y'))); + $sheet->getStyle("C5:{$lastCol}5")->applyFromArray($styleBox); + $sheet->getStyle('C5')->getFont()->setSize(12); + + // --- RESUMEN DE TOTALES --- + $totalProducts = $data->count(); + $totalStock = $data->sum('stock'); + $totalQuantitySold = $data->sum('quantity_sold'); + $totalSoldValue = $data->sum('total_sold'); + $totalInventoryValue = $data->sum('inventory_value'); + + $row = 7; + $sheet->setCellValue('A' . $row, 'TOTAL PRODUCTOS:'); + $sheet->setCellValue('C' . $row, $totalProducts); + $sheet->setCellValue('D' . $row, 'STOCK TOTAL:'); + $sheet->setCellValue('F' . $row, $totalStock); + $sheet->setCellValue('G' . $row, 'VENDIDOS:'); + $sheet->setCellValue('H' . $row, $totalQuantitySold); + $sheet->setCellValue('I' . $row, 'TOTAL VENDIDO:'); + $sheet->setCellValue('K' . $row, '$' . number_format($totalSoldValue, 2)); + $sheet->setCellValue('L' . $row, 'VALOR INVENTARIO:'); + $sheet->setCellValue('M' . $row, '$' . number_format($totalInventoryValue, 2)); + + $sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFont()->setBold(true); + $sheet->getStyle('C' . $row)->getFont()->setSize(12)->getColor()->setRGB('70AD47'); + $sheet->getStyle('F' . $row)->getFont()->setSize(12)->getColor()->setRGB('70AD47'); + $sheet->getStyle('H' . $row)->getFont()->setSize(12)->getColor()->setRGB('2E75B6'); + $sheet->getStyle('K' . $row)->getFont()->setSize(12)->getColor()->setRGB('FF6600'); + $sheet->getStyle('M' . $row)->getFont()->setSize(12)->getColor()->setRGB('008000'); + + // --- ENCABEZADOS DE TABLA --- + $h = 9; + $headers = [ + 'A' => 'No.', + 'B' => 'SKU', + 'C' => 'NOMBRE', + 'D' => 'CATEGORÍA', + 'E' => "STOCK\nDISPONIBLE", + 'F' => "CANTIDAD\nVENDIDA", + 'G' => "SERIALES\nTOTALES", + 'H' => "SERIALES\nDISPONIBLES", + 'I' => "SERIALES\nVENDIDOS", + 'J' => "SERIALES\nDEVUELTOS", + 'K' => "PRECIO\nUNITARIO", + 'L' => "TOTAL\nVENDIDO", + 'M' => "VALOR\nINVENTARIO", + ]; + + 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['sku']); + $sheet->setCellValue('C' . $row, $item['name']); + $sheet->setCellValue('D' . $row, $item['category']); + $sheet->setCellValue('E' . $row, $item['stock']); + $sheet->setCellValue('F' . $row, $item['quantity_sold']); + $sheet->setCellValue('G' . $row, $item['serials_total']); + $sheet->setCellValue('H' . $row, $item['serials_available']); + $sheet->setCellValue('I' . $row, $item['serials_sold']); + $sheet->setCellValue('J' . $row, $item['serials_returned']); + $sheet->setCellValue('K' . $row, '$' . number_format($item['price'], 2)); + $sheet->setCellValue('L' . $row, '$' . number_format($item['total_sold'], 2)); + $sheet->setCellValue('M' . $row, '$' . number_format($item['inventory_value'], 2)); + + // 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("E{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("F{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("G{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("H{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("I{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("J{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + + // Color de stock bajo (si está configurado el threshold) + if ($request->low_stock_threshold && $item['stock'] <= $request->low_stock_threshold) { + $sheet->getStyle("E{$row}")->getFont()->getColor()->setRGB('FF0000'); + $sheet->getStyle("E{$row}")->getFont()->setBold(true); + } + + // 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(15); + $sheet->getColumnDimension('C')->setWidth(35); + $sheet->getColumnDimension('D')->setWidth(18); + $sheet->getColumnDimension('E')->setWidth(12); + $sheet->getColumnDimension('F')->setWidth(12); + $sheet->getColumnDimension('G')->setWidth(12); + $sheet->getColumnDimension('H')->setWidth(14); + $sheet->getColumnDimension('I')->setWidth(12); + $sheet->getColumnDimension('J')->setWidth(12); + $sheet->getColumnDimension('K')->setWidth(18); + $sheet->getColumnDimension('L')->setWidth(14); + $sheet->getColumnDimension('M')->setWidth(16); + + $writer = new Xlsx($spreadsheet); + $writer->save($filePath); + + return response()->download($filePath, $fileName)->deleteFileAfterSend(true); + } + /** * Traducir método de pago */ diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index bdd5d7f..78a6761 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -129,6 +129,18 @@ public function run(): void $clientTierDestroy ] = $this->onCRUD('client-tiers', $clientTiersType, 'api'); + // Permisos de Solicitudes de Factura + $invoiceRequestsType = PermissionType::firstOrCreate([ + 'name' => 'Solicitudes de factura' + ]); + + $invoiceRequestIndex = $this->onIndex('invoice-requests', 'Mostrar solicitudes', $invoiceRequestsType, 'api'); + $invoiceRequestShow = $this->onPermission('invoice-requests.show', 'Ver detalles', $invoiceRequestsType, 'api'); + $invoiceRequestProcess = $this->onPermission('invoice-requests.process', 'Procesar solicitud', $invoiceRequestsType, 'api'); + $invoiceRequestReject = $this->onPermission('invoice-requests.reject', 'Rechazar solicitud', $invoiceRequestsType, 'api'); + $invoiceRequestUpload = $this->onPermission('invoice-requests.upload', 'Subir archivos de factura', $invoiceRequestsType, 'api'); + $invoiceRequestStats = $this->onPermission('invoice-requests.stats', 'Ver estadísticas', $invoiceRequestsType, 'api'); + // ==================== ROLES ==================== @@ -177,7 +189,13 @@ public function run(): void $clientTierIndex, $clientTierCreate, $clientTierEdit, - $clientTierDestroy + $clientTierDestroy, + $invoiceRequestIndex, + $invoiceRequestShow, + $invoiceRequestProcess, + $invoiceRequestReject, + $invoiceRequestUpload, + $invoiceRequestStats ); //Operador PDV (solo permisos de operación de caja y ventas) @@ -205,7 +223,9 @@ public function run(): void $clientTierIndex, $clientTierCreate, $clientTierEdit, - $clientTierDestroy + $clientTierDestroy, + // Solicitudes de factura (solo lectura) + $invoiceRequestIndex ); } } diff --git a/routes/api.php b/routes/api.php index afd5033..f929658 100644 --- a/routes/api.php +++ b/routes/api.php @@ -78,6 +78,7 @@ Route::get('products-without-movement', [ReportController::class, 'productsWithoutMovement']); Route::get('client-discounts/excel', [ExcelController::class, 'clientDiscountsReport']); Route::get('sales/excel', [ExcelController::class, 'salesReport']); + Route::get('inventory/excel', [ExcelController::class, 'inventoryReport']); }); //CLIENTES