From 516ad1cae643b6c78b9e3358569982683dda6124 Mon Sep 17 00:00:00 2001 From: Juan Felipe Zapata Moreno Date: Fri, 6 Feb 2026 23:23:30 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20agregar=20controlador=20para=20generaci?= =?UTF-8?q?=C3=B3n=20de=20Kardex=20en=20Excel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/App/ExcelController.php | 76 ++-- .../App/InventoryMovementController.php | 3 - app/Http/Controllers/App/KardexController.php | 361 ++++++++++++++++++ routes/api.php | 2 + 4 files changed, 413 insertions(+), 29 deletions(-) create mode 100644 app/Http/Controllers/App/KardexController.php diff --git a/app/Http/Controllers/App/ExcelController.php b/app/Http/Controllers/App/ExcelController.php index 7f720c6..70e0478 100644 --- a/app/Http/Controllers/App/ExcelController.php +++ b/app/Http/Controllers/App/ExcelController.php @@ -462,17 +462,29 @@ 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', + 'fecha_inicio' => 'sometimes|date', + 'fecha_fin' => 'sometimes|date|after_or_equal:fecha_inicio', + 'category_id' => 'sometimes|nullable|exists:categories,id', + 'with_serials_only' => 'sometimes|nullable|boolean', + 'low_stock_threshold' => 'sometimes|nullable|integer|min:0', ]); + $fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay(); + $fechaFin = Carbon::parse($request->fecha_fin)->endOfDay(); + // 2. CONSULTA DE INVENTARIO $query = Inventory::with([ 'category:id,name', - 'price:inventory_id,retail_price', + 'price:inventory_id,cost,retail_price', 'serials' ]) + ->when($request->q, function($q) use ($request) { + $q->where(function($query) use ($request) { + $query->where('name', 'like', "%{$request->q}%") + ->orWhere('sku', 'like', "%{$request->q}%") + ->orWhere('barcode', $request->q); + }); + }) ->when($request->category_id, function($q) use ($request) { $q->where('category_id', $request->category_id); }) @@ -492,11 +504,12 @@ public function inventoryReport(Request $request) } // 3. MAPEO DE DATOS - $data = $inventories->map(function($inventory) { - // Cantidad vendida total + $data = $inventories->map(function($inventory) use ($fechaInicio, $fechaFin) { + // Cantidad vendida en el periodo $quantitySold = SaleDetail::where('inventory_id', $inventory->id) - ->whereHas('sale', function($q) { - $q->where('status', 'completed'); + ->whereHas('sale', function($q) use ($fechaInicio, $fechaFin) { + $q->where('status', 'completed') + ->whereBetween('created_at', [$fechaInicio, $fechaFin]); }) ->sum('quantity'); @@ -506,9 +519,10 @@ public function inventoryReport(Request $request) $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; + $cost = $inventory->price?->cost ?? 0; + $retailPrice = $inventory->price?->retail_price ?? 0; + $totalSold = $quantitySold * $retailPrice; + $inventoryValue = $inventory->stock * $cost; return [ 'sku' => $inventory->sku ?? 'N/A', @@ -520,14 +534,15 @@ public function inventoryReport(Request $request) 'serials_available' => $serialsAvailable, 'serials_sold' => $serialsSold, 'serials_returned' => $serialsReturned, - 'price' => (float) $price, + 'cost' => (float) $cost, + 'price' => (float) $retailPrice, 'total_sold' => $totalSold, 'inventory_value' => $inventoryValue, ]; }); // 4. CONFIGURACIÓN EXCEL - $fileName = 'Reporte_Inventario_' . now()->format('Ymd_His') . '.xlsx'; + $fileName = 'Reporte_Inventario_' . $fechaInicio->format('Ymd') . '_' . $fechaFin->format('Ymd') . '.xlsx'; $filePath = storage_path('app/temp/' . $fileName); if (!file_exists(dirname($filePath))) mkdir(dirname($filePath), 0755, true); @@ -555,7 +570,7 @@ public function inventoryReport(Request $request) ]; // --- ESTRUCTURA DEL DOCUMENTO --- - $lastCol = 'M'; + $lastCol = 'N'; $sheet->getRowDimension(2)->setRowHeight(10); $sheet->getRowDimension(3)->setRowHeight(25); $sheet->getRowDimension(5)->setRowHeight(30); @@ -568,14 +583,20 @@ public function inventoryReport(Request $request) 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], ]); - // --- INFORMACIÓN DE FECHA --- + // --- INFORMACIÓN DEL PERIODO --- $sheet->mergeCells('A5:B5'); - $sheet->setCellValue('A5', 'FECHA:'); + $sheet->setCellValue('A5', 'PERÍODO:'); $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'))); + 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("C5:{$lastCol}5"); + $sheet->setCellValue('C5', $periodoTexto); $sheet->getStyle("C5:{$lastCol}5")->applyFromArray($styleBox); $sheet->getStyle('C5')->getFont()->setSize(12); @@ -618,9 +639,10 @@ public function inventoryReport(Request $request) 'H' => "SERIALES\nDISPONIBLES", 'I' => "SERIALES\nVENDIDOS", 'J' => "SERIALES\nDEVUELTOS", - 'K' => "PRECIO\nUNITARIO", - 'L' => "TOTAL\nVENDIDO", - 'M' => "VALOR\nINVENTARIO", + 'K' => "COSTO\nUNITARIO", + 'L' => "PRECIO\nVENTA", + 'M' => "TOTAL\nVENDIDO", + 'N' => "VALOR\nINVENTARIO", ]; foreach ($headers as $col => $text) { @@ -645,9 +667,10 @@ public function inventoryReport(Request $request) $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)); + $sheet->setCellValue('K' . $row, '$' . number_format($item['cost'], 2)); + $sheet->setCellValue('L' . $row, '$' . number_format($item['price'], 2)); + $sheet->setCellValue('M' . $row, '$' . number_format($item['total_sold'], 2)); + $sheet->setCellValue('N' . $row, '$' . number_format($item['inventory_value'], 2)); // Estilos de fila $sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([ @@ -694,9 +717,10 @@ public function inventoryReport(Request $request) $sheet->getColumnDimension('H')->setWidth(14); $sheet->getColumnDimension('I')->setWidth(12); $sheet->getColumnDimension('J')->setWidth(12); - $sheet->getColumnDimension('K')->setWidth(18); + $sheet->getColumnDimension('K')->setWidth(14); $sheet->getColumnDimension('L')->setWidth(14); - $sheet->getColumnDimension('M')->setWidth(16); + $sheet->getColumnDimension('M')->setWidth(14); + $sheet->getColumnDimension('N')->setWidth(16); $writer = new Xlsx($spreadsheet); $writer->save($filePath); diff --git a/app/Http/Controllers/App/InventoryMovementController.php b/app/Http/Controllers/App/InventoryMovementController.php index 8baa690..f7a77a4 100644 --- a/app/Http/Controllers/App/InventoryMovementController.php +++ b/app/Http/Controllers/App/InventoryMovementController.php @@ -11,9 +11,6 @@ /** * Controlador para movimientos de inventario - * - * @author Moisés Cortés C. - * @version 1.0.0 */ class InventoryMovementController extends Controller { diff --git a/app/Http/Controllers/App/KardexController.php b/app/Http/Controllers/App/KardexController.php new file mode 100644 index 0000000..cd81412 --- /dev/null +++ b/app/Http/Controllers/App/KardexController.php @@ -0,0 +1,361 @@ +validate([ + 'inventory_id' => 'required|exists:inventories,id', + 'warehouse_id' => 'sometimes|nullable|exists:warehouses,id', + 'fecha_inicio' => 'required|date', + 'fecha_fin' => 'required|date|after_or_equal:fecha_inicio', + 'movement_type' => 'sometimes|nullable|in:entry,exit,transfer,sale,return', + ]); + + $fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay(); + $fechaFin = Carbon::parse($request->fecha_fin)->endOfDay(); + + $inventory = Inventory::with(['category', 'price'])->findOrFail($request->inventory_id); + + // 2. CONSULTA DE MOVIMIENTOS + $query = InventoryMovement::where('inventory_id', $inventory->id) + ->with(['warehouseFrom', 'warehouseTo', 'user']) + ->whereBetween('created_at', [$fechaInicio, $fechaFin]) + ->orderBy('created_at', 'asc') + ->orderBy('id', 'asc'); + + $warehouseId = $request->warehouse_id; + + if ($warehouseId) { + $query->where(function ($q) use ($warehouseId) { + $q->where('warehouse_from_id', $warehouseId) + ->orWhere('warehouse_to_id', $warehouseId); + }); + } + + if ($request->movement_type) { + $query->where('movement_type', $request->movement_type); + } + + $movements = $query->get(); + + // 3. CALCULAR SALDO INICIAL (movimientos previos al rango) + $initialBalance = $this->calculateInitialBalance($inventory->id, $fechaInicio, $warehouseId); + + // 4. CONSTRUIR LÍNEAS DEL KARDEX + $balance = $initialBalance; + $kardexLines = []; + + foreach ($movements as $movement) { + $effect = $this->calculateMovementEffect($movement, $warehouseId); + $entryQty = $effect > 0 ? $effect : 0; + $exitQty = $effect < 0 ? abs($effect) : 0; + $balance += $effect; + + $kardexLines[] = [ + 'date' => $movement->created_at->format('d/m/Y H:i'), + 'type' => $this->translateMovementType($movement->movement_type), + 'warehouse_from' => $movement->warehouseFrom?->name ?? '-', + 'warehouse_to' => $movement->warehouseTo?->name ?? '-', + 'entry' => $entryQty, + 'exit' => $exitQty, + 'balance' => $balance, + 'unit_cost' => $movement->unit_cost ?? 0, + 'notes' => $movement->notes ?? '', + 'invoice_reference' => $movement->invoice_reference ?? '', + 'user' => $movement->user?->name ?? '', + ]; + } + + if (empty($kardexLines)) { + return response()->json(['message' => 'No se encontraron movimientos en el periodo especificado'], 404); + } + + // 5. GENERAR EXCEL + $fileName = 'Kardex_' . ($inventory->sku ?? $inventory->id) . '_' . $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(); + $sheet->setTitle('Kardex'); + + // Fuente global + $sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial'); + + // Estilos + $styleLabel = [ + 'font' => ['bold' => true, 'size' => 10], + 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], + ]; + + $styleBox = [ + 'borders' => ['outline' => ['borderStyle' => Border::BORDER_THIN]], + 'alignment' => ['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]], + ]; + + $lastCol = 'K'; + + // --- TÍTULO --- + $sheet->mergeCells("A2:{$lastCol}2"); + $sheet->setCellValue('A2', 'KARDEX DE PRODUCTO'); + $sheet->getStyle('A2')->applyFromArray([ + 'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '2E75B6']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], + ]); + $sheet->getRowDimension(2)->setRowHeight(30); + + // --- INFORMACIÓN DEL PRODUCTO --- + $row = 4; + + $sheet->setCellValue("A{$row}", 'PRODUCTO:'); + $sheet->getStyle("A{$row}")->applyFromArray($styleLabel); + $sheet->mergeCells("B{$row}:E{$row}"); + $sheet->setCellValue("B{$row}", $inventory->name); + $sheet->getStyle("B{$row}:E{$row}")->applyFromArray($styleBox); + + $sheet->setCellValue("G{$row}", 'SKU:'); + $sheet->getStyle("G{$row}")->applyFromArray($styleLabel); + $sheet->mergeCells("H{$row}:{$lastCol}{$row}"); + $sheet->setCellValue("H{$row}", $inventory->sku ?? 'N/A'); + $sheet->getStyle("H{$row}:{$lastCol}{$row}")->applyFromArray($styleBox); + + $row++; + $sheet->setCellValue("A{$row}", 'CATEGORÍA:'); + $sheet->getStyle("A{$row}")->applyFromArray($styleLabel); + $sheet->mergeCells("B{$row}:E{$row}"); + $sheet->setCellValue("B{$row}", $inventory->category?->name ?? 'Sin categoría'); + $sheet->getStyle("B{$row}:E{$row}")->applyFromArray($styleBox); + + $sheet->setCellValue("G{$row}", 'COSTO ACTUAL:'); + $sheet->getStyle("G{$row}")->applyFromArray($styleLabel); + $sheet->mergeCells("H{$row}:{$lastCol}{$row}"); + $sheet->setCellValue("H{$row}", '$' . number_format($inventory->price?->cost ?? 0, 2)); + $sheet->getStyle("H{$row}:{$lastCol}{$row}")->applyFromArray($styleBox); + + // --- PERÍODO --- + $row++; + 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->setCellValue("A{$row}", 'PERÍODO:'); + $sheet->getStyle("A{$row}")->applyFromArray($styleLabel); + $sheet->mergeCells("B{$row}:{$lastCol}{$row}"); + $sheet->setCellValue("B{$row}", $periodoTexto); + $sheet->getStyle("B{$row}:{$lastCol}{$row}")->applyFromArray($styleBox); + $sheet->getStyle("B{$row}")->getFont()->setSize(11); + + // --- SALDO INICIAL --- + $row++; + $sheet->setCellValue("A{$row}", 'SALDO INICIAL:'); + $sheet->getStyle("A{$row}")->applyFromArray($styleLabel); + $sheet->mergeCells("B{$row}:C{$row}"); + $sheet->setCellValue("B{$row}", $initialBalance); + $sheet->getStyle("B{$row}:C{$row}")->applyFromArray($styleBox); + $sheet->getStyle("B{$row}")->getFont()->setBold(true); + + // --- ENCABEZADOS DE TABLA --- + $row += 2; + $headerRow = $row; + + $headers = [ + 'A' => 'FECHA', + 'B' => 'TIPO', + 'C' => "ALMACÉN\nORIGEN", + 'D' => "ALMACÉN\nDESTINO", + 'E' => 'ENTRADA', + 'F' => 'SALIDA', + 'G' => 'SALDO', + 'H' => "COSTO\nUNITARIO", + 'I' => 'REFERENCIA', + 'J' => 'NOTAS', + 'K' => 'USUARIO', + ]; + + foreach ($headers as $col => $text) { + $sheet->setCellValue("{$col}{$row}", $text); + } + + $sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray($styleTableHeader); + $sheet->getRowDimension($row)->setRowHeight(30); + + // --- DATOS --- + $row++; + $totalEntries = 0; + $totalExits = 0; + + foreach ($kardexLines as $line) { + $sheet->setCellValue("A{$row}", $line['date']); + $sheet->setCellValue("B{$row}", $line['type']); + $sheet->setCellValue("C{$row}", $line['warehouse_from']); + $sheet->setCellValue("D{$row}", $line['warehouse_to']); + $sheet->setCellValue("E{$row}", $line['entry'] ?: ''); + $sheet->setCellValue("F{$row}", $line['exit'] ?: ''); + $sheet->setCellValue("G{$row}", $line['balance']); + $sheet->setCellValue("H{$row}", $line['unit_cost'] ? '$' . number_format($line['unit_cost'], 2) : ''); + $sheet->setCellValue("I{$row}", $line['invoice_reference']); + $sheet->setCellValue("J{$row}", $line['notes']); + $sheet->setCellValue("K{$row}", $line['user']); + + $totalEntries += $line['entry']; + $totalExits += $line['exit']; + + // Estilo de fila + $sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([ + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], + 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], + 'font' => ['size' => 10], + ]); + + // Color alterno + if (($row - $headerRow) % 2 === 0) { + $sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([ + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'D9E2F3']], + ]); + } + + // Resaltar entradas en verde y salidas en rojo + if ($line['entry'] > 0) { + $sheet->getStyle("E{$row}")->getFont()->setColor(new Color('006100')); + $sheet->getStyle("E{$row}")->getFont()->setBold(true); + } + if ($line['exit'] > 0) { + $sheet->getStyle("F{$row}")->getFont()->setColor(new Color('9C0006')); + $sheet->getStyle("F{$row}")->getFont()->setBold(true); + } + + $row++; + } + + // --- FILA DE TOTALES --- + $sheet->mergeCells("A{$row}:D{$row}"); + $sheet->setCellValue("A{$row}", 'TOTALES'); + $sheet->setCellValue("E{$row}", $totalEntries); + $sheet->setCellValue("F{$row}", $totalExits); + $sheet->setCellValue("G{$row}", $balance); + + $sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([ + 'font' => ['bold' => true, 'size' => 10], + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '2E75B6']], + 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], + ]); + $sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFont()->setColor(new Color('FFFFFF')); + + // --- ANCHOS DE COLUMNA --- + $sheet->getColumnDimension('A')->setWidth(18); + $sheet->getColumnDimension('B')->setWidth(14); + $sheet->getColumnDimension('C')->setWidth(18); + $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(18); + $sheet->getColumnDimension('J')->setWidth(25); + $sheet->getColumnDimension('K')->setWidth(16); + + // 6. GUARDAR Y DESCARGAR + $writer = new Xlsx($spreadsheet); + $writer->save($filePath); + + return response()->download($filePath, $fileName)->deleteFileAfterSend(true); + } + + /** + * Calcular saldo inicial (movimientos previos al rango de fechas) + */ + private function calculateInitialBalance(int $inventoryId, Carbon $beforeDate, ?int $warehouseId): int + { + $query = InventoryMovement::where('inventory_id', $inventoryId) + ->where('created_at', '<', $beforeDate) + ->orderBy('created_at', 'asc') + ->orderBy('id', 'asc'); + + if ($warehouseId) { + $query->where(function ($q) use ($warehouseId) { + $q->where('warehouse_from_id', $warehouseId) + ->orWhere('warehouse_to_id', $warehouseId); + }); + } + + $balance = 0; + foreach ($query->get() as $movement) { + $balance += $this->calculateMovementEffect($movement, $warehouseId); + } + + return $balance; + } + + /** + * Calcular el efecto de un movimiento en el stock + */ + private function calculateMovementEffect(InventoryMovement $movement, ?int $warehouseId): int + { + // Sin filtro de almacén: stock global + if (!$warehouseId) { + return match ($movement->movement_type) { + 'entry', 'return' => $movement->quantity, + 'exit', 'sale' => -$movement->quantity, + 'transfer' => 0, + default => 0, + }; + } + + // Con filtro de almacén: depende de la dirección + if ($movement->warehouse_to_id == $warehouseId && $movement->warehouse_from_id != $warehouseId) { + return $movement->quantity; + } + + if ($movement->warehouse_from_id == $warehouseId && $movement->warehouse_to_id != $warehouseId) { + return -$movement->quantity; + } + + return 0; + } + + /** + * Traducir tipo de movimiento + */ + private function translateMovementType(string $type): string + { + return match ($type) { + 'entry' => 'Entrada', + 'exit' => 'Salida', + 'transfer' => 'Traspaso', + 'sale' => 'Venta', + 'return' => 'Devolución', + default => $type, + }; + } +} diff --git a/routes/api.php b/routes/api.php index 8ecec5f..f1abd06 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,7 @@ use App\Http\Controllers\App\InventorySerialController; use App\Http\Controllers\App\InvoiceRequestController; use App\Http\Controllers\App\InventoryMovementController; +use App\Http\Controllers\App\KardexController; use App\Http\Controllers\App\WarehouseController; use Illuminate\Support\Facades\Route; @@ -94,6 +95,7 @@ Route::get('client-discounts/excel', [ExcelController::class, 'clientDiscountsReport']); Route::get('sales/excel', [ExcelController::class, 'salesReport']); Route::get('inventory/excel', [ExcelController::class, 'inventoryReport']); + Route::get('kardex/excel', [KardexController::class, 'export']); }); //CLIENTES