withCount('sales'); if ($request->has('status')) { $query->where('status', $request->status); } if ($request->has('date')) { $query->whereDate('close_date', $request->date); } $cashCloses = $query->orderBy('id', 'asc') ->paginate(config('app.pagination')); return ApiResponse::OK->response([ 'cash_closes' => $cashCloses, ]); } public function report(Request $request) { $request->validate([ 'start_date' => 'nullable|date', 'end_date' => 'nullable|date|after_or_equal:start_date', ]); $query = CashClose::with('user:id,name')->withCount('sales'); if ($request->has('start_date') && $request->has('end_date')) { $query->whereBetween('close_date', [$request->start_date, $request->end_date]); } elseif ($request->has('start_date')) { $query->whereDate('close_date', '>=', $request->start_date); } elseif ($request->has('end_date')) { $query->whereDate('close_date', '<=', $request->end_date); } else { $query->closed()->orderBy('id', 'desc')->limit(1); } // Información del corte de caja $cashCloses = $query->orderBy('id', 'desc')->get(); if ($cashCloses->isEmpty()) { return ApiResponse::NOT_FOUND->response([ 'message' => 'No se encontraron cortes de caja en el rango de fechas especificado.', ]); } $cashCloseIds = $cashCloses->pluck('id')->toArray(); // Estadísticas por paquete (Total de Paquetes Vendidos por Tipo) $packageStats = DB::table('sale_items') ->join('sales', 'sale_items.sale_id', '=', 'sales.id') ->join('packages', 'sale_items.package_id', '=', 'packages.id') ->whereIn('sales.cash_close_id', $cashCloseIds) ->select( 'packages.name as paquete', DB::raw('COUNT(*) as total_vendidos'), DB::raw('SUM(packages.price) as total_ingresos') ) ->groupBy('packages.id', 'packages.name') ->get(); // Estadísticas por duración (Total de Ventas por Duración) $durationStats = DB::table('sale_items') ->join('sales', 'sale_items.sale_id', '=', 'sales.id') ->join('packages', 'sale_items.package_id', '=', 'packages.id') ->whereIn('sales.cash_close_id', $cashCloseIds) ->select( 'packages.period as duracion_dias', DB::raw('COUNT(DISTINCT sales.id) as total_ventas') ) ->groupBy('packages.period') ->orderBy('packages.period', 'asc') ->get(); // Reporte detallado de ventas $detailedSales = SaleItem::whereHas('sale', function ($query) use ($cashCloseIds) { $query->whereIn('cash_close_id', $cashCloseIds); }) ->with([ 'sale.client:id,name,paternal,maternal', 'sale:id,client_id,payment_method', 'simCard:id,iccid,msisdn', 'package:id,name,price' ]) ->orderBy('id', 'asc') ->paginate(config('app.pagination')) ->through(function ($item) { return [ 'nombre_comprador' => $item->sale->client->full_name, 'id_sim' => $item->simCard->iccid, 'numero_asignado' => $item->simCard->msisdn, 'paquete' => $item->package->name, 'costo' => $item->package->price, 'medio_pago' => $item->sale->payment_method ]; }); $totalIncome = $cashCloses->sum('income'); $totalExit = $cashCloses->sum('exit'); $totalCash = $cashCloses->sum('income_cash'); $totalCard = $cashCloses->sum('income_card'); $totalTransfer = $cashCloses->sum('income_transfer'); $balanceFinal = $cashCloses->sum('initial_balance') + $totalIncome - $totalExit; return ApiResponse::OK->response([ 'cash_closes' => $cashCloses, 'periodo' => [ 'inicio' => $cashCloses->last()?->opened_at, 'fin' => $cashCloses->first()?->closed_at, ], 'resumen_financiero' => [ 'total_ventas' => $totalIncome, 'efectivo' => $totalCash, 'tarjeta' => $totalCard, 'transferencia' => $totalTransfer, 'egresos' => $totalExit, 'balance_final' => $balanceFinal, ], 'ventas_paquete' => $packageStats, 'ventas_duracion' => $durationStats, 'ventas_detalladas' => $detailedSales, ]); } public function exportReport(Request $request) { $request->validate([ 'start_date' => 'nullable|date', 'end_date' => 'nullable|date|after_or_equal:start_date', ]); $query = CashClose::with('user:id,name')->withCount('sales'); if ($request->has('start_date') && $request->has('end_date')) { $query->whereBetween('closed_at', [$request->start_date, $request->end_date]); } elseif ($request->has('start_date')) { $query->whereDate('closed_at', '>=', $request->start_date); } elseif ($request->has('end_date')) { $query->whereDate('closed_at', '<=', $request->end_date); } else { $query->closed()->orderBy('id', 'desc')->limit(1); } $cashCloses = $query->orderBy('id', 'desc')->get(); if ($cashCloses->isEmpty()) { return ApiResponse::NOT_FOUND->response([ 'message' => 'No se encontraron cortes de caja en el rango de fechas especificado.', ]); } $filename = 'reporte_corte_caja_' . date('Y-m-d_His') . '.xlsx'; return Excel::download( new CashCloseReportExport($cashCloses), $filename ); } /** * Preview de ventas disponibles para crear corte por rango de fechas */ public function previewSalesRange(Request $request) { $request->validate([ 'start_date' => 'required|date', 'end_date' => 'required|date|after_or_equal:start_date', ]); // Buscar ventas sin corte en el rango especificado $availableSales = Sale::with([ 'client:id,name,paternal,maternal', 'saleItems.simCard:id,iccid,msisdn', 'saleItems.package:id,name,price' ]) ->whereNull('cash_close_id') ->whereBetween('sale_date', [$request->start_date, $request->end_date]) ->orderBy('sale_date', 'asc') ->get(); if ($availableSales->isEmpty()) { return ApiResponse::NOT_FOUND->response([ 'message' => 'No se encontraron ventas disponibles en el rango de fechas especificado.', 'total_sales' => 0, ]); } // Calcular totales por método de pago $paymentMethods = $availableSales->groupBy('payment_method')->map(function ($sales, $method) { return [ 'method' => $method, 'count' => $sales->count(), 'total' => $sales->sum('total_amount'), ]; })->values(); $totalAmount = $availableSales->sum('total_amount'); return ApiResponse::OK->response([ 'message' => 'Ventas disponibles encontradas', 'periodo' => [ 'start_date' => $request->start_date, 'end_date' => $request->end_date, ], 'resumen' => [ 'total_ventas' => $availableSales->count(), 'monto_total' => $totalAmount, 'por_metodo_pago' => $paymentMethods, ], 'ventas' => $availableSales->map(function ($sale) { return [ 'id' => $sale->id, 'sale_date' => $sale->sale_date, 'client' => $sale->client->full_name ?? 'Sin cliente', 'payment_method' => $sale->payment_method, 'total_amount' => $sale->total_amount, 'items_count' => $sale->saleItems->count(), ]; }), ]); } /** * Crear corte de caja desde un rango de fechas */ public function createFromRange(Request $request) { $request->validate([ 'start_date' => 'required|date', 'end_date' => 'required|date|after_or_equal:start_date', 'initial_balance' => 'sometimes|numeric|min:0', 'exit' => 'sometimes|numeric|min:0', 'notes' => 'sometimes|string|max:1000', ]); try { DB::beginTransaction(); // Buscar ventas sin corte en el rango especificado $availableSales = Sale::whereNull('cash_close_id') ->whereBetween('sale_date', [$request->start_date, $request->end_date]) ->get(); if ($availableSales->isEmpty()) { return ApiResponse::NOT_FOUND->response([ 'message' => 'No se encontraron ventas disponibles para crear el corte de caja.', ]); } // Verificar que ninguna venta ya esté en otro corte (doble verificación) $salesWithClose = Sale::whereBetween('sale_date', [$request->start_date, $request->end_date]) ->whereNotNull('cash_close_id') ->with('cashClose:id,status,opened_at,closed_at') ->get(); if ($salesWithClose->isNotEmpty()) { return ApiResponse::BAD_REQUEST->response([ 'message' => 'Algunas ventas en este rango ya están asignadas a otros cortes.', 'conflicting_sales' => $salesWithClose->map(fn($s) => [ 'sale_id' => $s->id, 'sale_date' => $s->sale_date, 'cash_close_id' => $s->cash_close_id, 'cash_close_status' => $s->cashClose->status ?? null, ]), ], 422); } // Calcular totales $totalAmount = $availableSales->sum('total_amount'); $paymentMethods = $availableSales->groupBy('payment_method'); $incomeCash = $paymentMethods->get('cash', collect())->sum('total_amount'); $incomeCard = $paymentMethods->get('card', collect())->sum('total_amount'); $incomeTransfer = $paymentMethods->get('transfer', collect())->sum('total_amount'); // Crear el corte de caja $cashClose = CashClose::create([ 'user_id' => auth()->id(), 'opened_at' => $request->start_date, 'closed_at' => $request->end_date, 'initial_balance' => $request->input('initial_balance', 0), 'income' => $totalAmount, 'exit' => $request->input('exit', 0), 'income_cash' => $incomeCash, 'income_card' => $incomeCard, 'income_transfer' => $incomeTransfer, 'status' => 'closed', 'type' => 'manual', 'notes' => $request->input('notes'), ]); // Asignar las ventas al corte creado Sale::whereIn('id', $availableSales->pluck('id')) ->update(['cash_close_id' => $cashClose->id]); DB::commit(); $balanceFinal = $cashClose->initial_balance + $totalAmount - $cashClose->exit; return ApiResponse::CREATED->response([ 'message' => 'Corte de caja creado exitosamente', 'cash_close' => $cashClose->fresh('user'), 'resumen' => [ 'periodo' => [ 'inicio' => $cashClose->opened_at, 'fin' => $cashClose->closed_at, ], 'totales' => [ 'fondo_inicial' => $cashClose->initial_balance, 'total_ventas' => $totalAmount, 'cantidad_ventas' => $availableSales->count(), 'efectivo' => $incomeCash, 'tarjeta' => $incomeCard, 'transferencia' => $incomeTransfer, 'egresos' => $cashClose->exit, 'balance_final' => $balanceFinal, ], ], ]); } catch (\Exception $e) { DB::rollBack(); return ApiResponse::INTERNAL_ERROR->response([ 'message' => 'Error al crear el corte de caja', 'error' => $e->getMessage(), ]); } } /** * Deshacer (eliminar) un corte de caja * Las ventas se liberan automáticamente (cash_close_id = null) */ public function undoCashClose(CashClose $cashClose) { try { DB::beginTransaction(); // Verificar que el corte existe if (!$cashClose) { return ApiResponse::NOT_FOUND->response([ 'message' => 'Corte de caja no encontrado.', ]); } // Obtener información antes de eliminar $salesCount = $cashClose->sales()->count(); $cashCloseId = $cashClose->id; $cashCloseType = $cashClose->type; // Liberar las ventas (setear cash_close_id a null) Sale::where('cash_close_id', $cashClose->id) ->update(['cash_close_id' => null]); // Eliminar el corte (soft delete) $cashClose->delete(); DB::commit(); return ApiResponse::OK->response([ 'message' => 'Corte de caja eliminado exitosamente', 'info' => [ 'cash_close_id' => $cashCloseId, 'type' => $cashCloseType, 'sales_liberated' => $salesCount, 'nota' => 'Las ventas han sido liberadas y están disponibles para ser asignadas a otro corte.', ], ]); } catch (\Exception $e) { DB::rollBack(); return ApiResponse::INTERNAL_ERROR->response([ 'message' => 'Error al eliminar el corte de caja', 'error' => $e->getMessage(), ]); } } }