From d5665f04483e2be36d393e63723ff976fde693aa Mon Sep 17 00:00:00 2001 From: Juan Felipe Zapata Moreno Date: Sun, 4 Jan 2026 17:11:57 -0600 Subject: [PATCH] add: cambio al pagar con efectivo --- .../App/CashRegisterController.php | 5 +- app/Http/Controllers/App/ReportController.php | 92 ++++++++++++++++ app/Http/Requests/App/SaleStoreRequest.php | 26 +++++ app/Models/Sale.php | 4 + app/Services/CashRegisterService.php | 104 +++++++++++------- app/Services/ReportService.php | 101 +++++++++++++++++ app/Services/SaleService.php | 14 ++- ...ash_received_and_change_to_sales_table.php | 29 +++++ routes/api.php | 7 ++ 9 files changed, 340 insertions(+), 42 deletions(-) create mode 100644 app/Http/Controllers/App/ReportController.php create mode 100644 app/Services/ReportService.php create mode 100644 database/migrations/2026_01_04_160742_add_cash_received_and_change_to_sales_table.php diff --git a/app/Http/Controllers/App/CashRegisterController.php b/app/Http/Controllers/App/CashRegisterController.php index 1214087..eacd8fe 100644 --- a/app/Http/Controllers/App/CashRegisterController.php +++ b/app/Http/Controllers/App/CashRegisterController.php @@ -55,9 +55,10 @@ public function current() ]); } - $summary = $this->cashRegisterService->getCurrentSummary($register); + $service = new CashRegisterService(); + $summary = $service->getCurrentSummary($register); - return ApiResponse::OK->response($summary); + return ApiResponse::OK->response(['register' => $summary ]); } /** diff --git a/app/Http/Controllers/App/ReportController.php b/app/Http/Controllers/App/ReportController.php new file mode 100644 index 0000000..7b5b044 --- /dev/null +++ b/app/Http/Controllers/App/ReportController.php @@ -0,0 +1,92 @@ +validate([ + 'from_date' => ['nullable', 'date_format:Y-m-d'], + 'to_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:from_date', 'required_with:from_date'], + ], [ + 'from_date.date_format' => 'La fecha inicial debe tener el formato Y-m-d (ejemplo: 2025-01-01).', + 'to_date.date_format' => 'La fecha final debe tener el formato Y-m-d (ejemplo: 2025-01-31).', + 'to_date.after_or_equal' => 'La fecha final debe ser igual o posterior a la fecha inicial.', + 'to_date.required_with' => 'La fecha final es obligatoria cuando se proporciona fecha inicial.', + ]); + + try { + $product = $this->reportService->getTopSellingProduct( + fromDate: $request->input('from_date'), + toDate: $request->input('to_date') + ); + + if ($product === null) { + return ApiResponse::OK->response([ + 'product' => null, + 'message' => 'No hay datos de ventas para el período especificado.' + ]); + } + + return ApiResponse::OK->response([ + 'product' => $product, + 'message' => 'Producto más vendido obtenido exitosamente.' + ]); + } catch (\Exception $e) { + return ApiResponse::INTERNAL_ERROR->response([ + 'message' => 'Error al generar el reporte: ' . $e->getMessage() + ]); + } + } + + /** + * Obtener productos sin movimiento + * + * @param Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function productsWithoutMovement(Request $request) + { + $request->validate([ + 'days_threshold' => ['nullable', 'integer', 'min:1'], + 'include_stock_value' => ['nullable', 'boolean'], + ], [ + 'days_threshold.integer' => 'El umbral de días debe ser un número entero.', + 'days_threshold.min' => 'El umbral de días debe ser al menos 1.', + 'include_stock_value.boolean' => 'El parámetro de valor de stock debe ser verdadero o falso.', + ]); + + try { + $products = $this->reportService->getProductsWithoutMovement( + daysThreshold: (int)($request->input('days_threshold', 30)), + includeStockValue: (bool)($request->input('include_stock_value', true)) + ); + + return ApiResponse::OK->response([ + 'products' => $products, + 'total_products' => count($products), + 'message' => 'Reporte de productos sin movimiento generado exitosamente.' + ]); + } catch (\Exception $e) { + return ApiResponse::INTERNAL_ERROR->response([ + 'message' => 'Error al generar el reporte: ' . $e->getMessage() + ]); + } + } +} diff --git a/app/Http/Requests/App/SaleStoreRequest.php b/app/Http/Requests/App/SaleStoreRequest.php index 1c653ad..7ea6bf7 100644 --- a/app/Http/Requests/App/SaleStoreRequest.php +++ b/app/Http/Requests/App/SaleStoreRequest.php @@ -27,6 +27,9 @@ public function rules(): array 'total' => ['required', 'numeric', 'min:0'], 'payment_method' => ['required', 'in:cash,credit_card,debit_card'], + // Campos para pagos en efectivo + 'cash_received' => ['required_if:payment_method,cash', 'nullable', 'numeric', 'min:0'], + // Items del carrito 'items' => ['required', 'array', 'min:1'], 'items.*.inventory_id' => ['required', 'exists:inventories,id'], @@ -58,6 +61,11 @@ public function messages(): array 'payment_method.required' => 'El método de pago es obligatorio.', 'payment_method.in' => 'El método de pago debe ser: efectivo, tarjeta de crédito o débito.', + // Mensajes de cash_received + 'cash_received.required_if' => 'El dinero recibido es obligatorio para pagos en efectivo.', + 'cash_received.numeric' => 'El dinero recibido debe ser un número.', + 'cash_received.min' => 'El dinero recibido no puede ser negativo.', + // Mensajes de Items 'items.required' => 'Debe incluir al menos un producto.', 'items.array' => 'Los items deben ser un arreglo.', @@ -76,4 +84,22 @@ public function messages(): array 'items.*.subtotal.min' => 'El subtotal del item no puede ser negativo.', ]; } + + /** + * Validación adicional después de las reglas básicas + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + // Validar que el dinero recibido sea suficiente para pagos en efectivo + if ($this->payment_method === 'cash' && $this->cash_received !== null) { + if ($this->cash_received < $this->total) { + $validator->errors()->add( + 'cash_received', + 'El dinero recibido debe ser mayor o igual al total de la venta ($' . number_format($this->total, 2) . ').' + ); + } + } + }); + } } diff --git a/app/Models/Sale.php b/app/Models/Sale.php index 57749da..9f73266 100644 --- a/app/Models/Sale.php +++ b/app/Models/Sale.php @@ -22,6 +22,8 @@ class Sale extends Model 'subtotal', 'tax', 'total', + 'cash_received', + 'change', 'payment_method', 'status', ]; @@ -30,6 +32,8 @@ class Sale extends Model 'subtotal' => 'decimal:2', 'tax' => 'decimal:2', 'total' => 'decimal:2', + 'cash_received' => 'decimal:2', + 'change' => 'decimal:2', ]; public function user() diff --git a/app/Services/CashRegisterService.php b/app/Services/CashRegisterService.php index 48bf482..3abd564 100644 --- a/app/Services/CashRegisterService.php +++ b/app/Services/CashRegisterService.php @@ -4,7 +4,6 @@ use App\Models\CashRegister; use App\Models\Sale; -use Illuminate\Support\Facades\DB; class CashRegisterService { @@ -35,42 +34,43 @@ public function openRegister(array $data) */ public function closeRegister(CashRegister $register, array $data) { - if ($register->status === 'closed') { - throw new \Exception('Esta caja ya está cerrada.'); - } + $sales = Sale::where('cash_register_id', $register->id) + ->where('status', 'completed') + ->get(); - return DB::transaction(function () use ($register, $data) { - // Calcular ventas en efectivo - $cashSales = Sale::where('cash_register_id', $register->id) - ->where('payment_method', 'cash') - ->where('status', 'completed') - ->sum('total'); + // Calcular efectivo real (recibido - devuelto) + $cashSales = $sales->where('payment_method', 'cash') + ->sum(function ($sale) { + return ($sale->cash_received ?? 0) - ($sale->change ?? 0); + }); - // Calcular efectivo esperado - $expectedCash = $register->initial_cash + $cashSales; + $totalCashReceived = $sales->where('payment_method', 'cash')->sum('cash_received'); + $totalChangeGiven = $sales->where('payment_method', 'cash')->sum('change'); + $cardSales = $sales->whereIn('payment_method', ['credit_card', 'debit_card'])->sum('total'); + $totalSales = $sales->sum('total'); - // Calcular diferencia - $finalCash = $data['final_cash']; - $difference = $finalCash - $expectedCash; + // Efectivo esperado + $expectedCash = $register->initial_cash + $cashSales; - // Actualizar caja - $register->update([ - 'closed_at' => now(), - 'final_cash' => $finalCash, - 'expected_cash' => $expectedCash, - 'difference' => $difference, - 'total_sales' => Sale::where('cash_register_id', $register->id) - ->where('status', 'completed') - ->sum('total'), - 'sales_count' => Sale::where('cash_register_id', $register->id) - ->where('status', 'completed') - ->count(), - 'notes' => $data['notes'] ?? null, - 'status' => 'closed', - ]); + // Diferencia (sobrante o faltante) + $difference = $data['final_cash'] - $expectedCash; - return $register->fresh(['user', 'sales']); - }); + // Cerrar caja + $register->update([ + 'closed_at' => now(), + 'final_cash' => $data['final_cash'], + 'expected_cash' => $expectedCash, + 'difference' => $difference, + 'total_sales' => $totalSales, + 'cash_sales' => $cashSales, + 'card_sales' => $cardSales, + 'total_cash_received' => $totalCashReceived, + 'total_change_given' => $totalChangeGiven, + 'notes' => $data['notes'] ?? null, + 'status' => 'closed', + ]); + + return $register; } /** @@ -82,14 +82,40 @@ public function getCurrentSummary(CashRegister $register) ->where('status', 'completed') ->get(); + // Calcular efectivo real en caja (recibido - devuelto) + $cashSales = $sales->where('payment_method', 'cash') + ->sum(function ($sale) { + return ($sale->cash_received ?? 0) - ($sale->change ?? 0); + }); + + // Confirmación, envio de los totales + $totalCashReceived = $sales->where('payment_method', 'cash')->sum('cash_received'); + $totalChangeGiven = $sales->where('payment_method', 'cash')->sum('change'); + + $cardSales = $sales->whereIn('payment_method', ['credit_card', 'debit_card'])->sum('total'); + $totalSales = $sales->sum('total'); + $transactionCount = $sales->count(); + return [ - 'register' => $register, - 'total_cash' => $sales->where('payment_method', 'cash')->sum('total'), - 'total_credit_card' => $sales->where('payment_method', 'credit_card')->sum('total'), - 'total_debit_card' => $sales->where('payment_method', 'debit_card')->sum('total'), - 'total_sales' => $sales->sum('total'), - 'sales_count' => $sales->count(), - 'expected_cash' => $register->initial_cash + $sales->where('payment_method', 'cash')->sum('total'), + 'id' => $register->id, + 'user_id' => $register->user_id, + 'status' => $register->status, + 'opened_at' => $register->opened_at, + 'closed_at' => $register->closed_at, + 'initial_cash' => (float) $register->initial_cash, + + // Totales calculados + 'total_sales' => (float) $totalSales, + 'transaction_count' => $transactionCount, + 'cash_sales' => (float) $cashSales, + 'card_sales' => (float) $cardSales, + + //Desglose de efectivo + 'total_cash_received' => (float) $totalCashReceived, + 'total_change_given' => (float) $totalChangeGiven, + + // Efectivo esperado + 'expected_cash' => (float) ($register->initial_cash + $cashSales) ]; } } diff --git a/app/Services/ReportService.php b/app/Services/ReportService.php new file mode 100644 index 0000000..c11ec65 --- /dev/null +++ b/app/Services/ReportService.php @@ -0,0 +1,101 @@ +selectRaw(' + inventories.id, + inventories.name, + inventories.sku, + categories.name as category_name, + SUM(sale_details.quantity) as total_quantity_sold, + SUM(sale_details.subtotal) as total_revenue, + COUNT(DISTINCT sale_details.sale_id) as times_sold, + MAX(sales.created_at) as last_sale_date, + inventories.created_at as added_date + ') + ->join('inventories', 'sale_details.inventory_id', '=', 'inventories.id') + ->join('categories', 'inventories.category_id', '=', 'categories.id') + ->join('sales', 'sale_details.sale_id', '=', 'sales.id') + ->where('sales.status', 'completed') + ->whereNull('sales.deleted_at'); + + // Aplicar filtro de fechas si se proporcionan ambas + if ($fromDate && $toDate) { + $query->whereBetween(DB::raw('DATE(sales.created_at)'), [$fromDate, $toDate]); + } + + $result = $query + ->groupBy('inventories.id', 'inventories.name', 'inventories.sku', + 'categories.name', 'inventories.created_at') + ->orderByDesc('total_quantity_sold') + ->first(); + + return $result ? $result->toArray() : null; + } + + /** + * Obtener productos sin movimiento + * + * @param int $daysThreshold Días mínimos sin movimiento (default: 30) + * @param bool $includeStockValue Incluir valor del inventario (default: true) + * @return array Lista de productos sin ventas + */ + public function getProductsWithoutMovement(int $daysThreshold = 30, bool $includeStockValue = true): array + { + // Obtener IDs de productos que SÍ tienen ventas + $inventoriesWithSales = SaleDetail::query() + ->join('sales', 'sale_details.sale_id', '=', 'sales.id') + ->where('sales.status', 'completed') + ->whereNull('sales.deleted_at') + ->distinct() + ->pluck('sale_details.inventory_id') + ->toArray(); + + // Construir query para productos SIN ventas + $query = Inventory::query() + ->selectRaw(' + inventories.id, + inventories.name, + inventories.sku, + inventories.stock, + categories.name as category_name, + inventories.created_at as date_added, + DATEDIFF(NOW(), inventories.created_at) as days_without_movement, + prices.retail_price + '); + + // Agregar valor del inventario si se solicita + if ($includeStockValue) { + $query->addSelect( + DB::raw('(inventories.stock * COALESCE(prices.cost, 0)) as inventory_value') + ); + } + + return $query + ->leftJoin('categories', 'inventories.category_id', '=', 'categories.id') + ->leftJoin('prices', 'inventories.id', '=', 'prices.inventory_id') + ->where('inventories.is_active', true) + ->whereNull('inventories.deleted_at') + ->whereNotIn('inventories.id', $inventoriesWithSales) + ->havingRaw('DATEDIFF(NOW(), inventories.created_at) >= ?', [$daysThreshold]) + ->orderBy('inventories.created_at') + ->get() + ->toArray(); + } +} diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index 8e95a4c..8931e3e 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -1,5 +1,6 @@ $data['user_id'], @@ -22,6 +32,8 @@ public function createSale(array $data) 'subtotal' => $data['subtotal'], 'tax' => $data['tax'], 'total' => $data['total'], + 'cash_received' => $cashReceived, + 'change' => $change, 'payment_method' => $data['payment_method'], 'status' => $data['status'] ?? 'completed', ]); @@ -99,7 +111,7 @@ private function generateInvoiceNumber(): string private function getCurrentCashRegister($userId) { - $register = \App\Models\CashRegister::where('user_id', $userId) + $register = CashRegister::where('user_id', $userId) ->where('status', 'open') ->first(); diff --git a/database/migrations/2026_01_04_160742_add_cash_received_and_change_to_sales_table.php b/database/migrations/2026_01_04_160742_add_cash_received_and_change_to_sales_table.php new file mode 100644 index 0000000..f4329e1 --- /dev/null +++ b/database/migrations/2026_01_04_160742_add_cash_received_and_change_to_sales_table.php @@ -0,0 +1,29 @@ +decimal('cash_received', 10, 2)->nullable()->after('total'); + $table->decimal('change', 10, 2)->nullable()->after('cash_received'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sales', function (Blueprint $table) { + $table->dropColumn(['cash_received', 'change']); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 2579c9f..d34174b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\App\CategoryController; use App\Http\Controllers\App\InventoryController; use App\Http\Controllers\App\PriceController; +use App\Http\Controllers\App\ReportController; use App\Http\Controllers\App\SaleController; use Illuminate\Support\Facades\Route; @@ -48,6 +49,12 @@ Route::post('/open', [CashRegisterController::class, 'open']); Route::put('/{register}/close', [CashRegisterController::class, 'close']); }); + + // REPORTES + Route::prefix('reports')->group(function () { + Route::get('top-selling-product', [ReportController::class, 'topSellingProduct']); + Route::get('products-without-movement', [ReportController::class, 'productsWithoutMovement']); + }); }); /** Rutas públicas */