add: cambio al pagar con efectivo

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-04 17:11:57 -06:00
parent e37a8b117d
commit d5665f0448
9 changed files with 340 additions and 42 deletions

View File

@ -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 ]);
} }
/** /**

View File

@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Services\ReportService;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class ReportController extends Controller
{
public function __construct(
protected ReportService $reportService
) {}
/**
* Obtener el producto más vendido
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function topSellingProduct(Request $request)
{
$request->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()
]);
}
}
}

View File

@ -27,6 +27,9 @@ public function rules(): array
'total' => ['required', 'numeric', 'min:0'], 'total' => ['required', 'numeric', 'min:0'],
'payment_method' => ['required', 'in:cash,credit_card,debit_card'], '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 del carrito
'items' => ['required', 'array', 'min:1'], 'items' => ['required', 'array', 'min:1'],
'items.*.inventory_id' => ['required', 'exists:inventories,id'], '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.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.', '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 // Mensajes de Items
'items.required' => 'Debe incluir al menos un producto.', 'items.required' => 'Debe incluir al menos un producto.',
'items.array' => 'Los items deben ser un arreglo.', '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.', '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) . ').'
);
}
}
});
}
} }

View File

@ -22,6 +22,8 @@ class Sale extends Model
'subtotal', 'subtotal',
'tax', 'tax',
'total', 'total',
'cash_received',
'change',
'payment_method', 'payment_method',
'status', 'status',
]; ];
@ -30,6 +32,8 @@ class Sale extends Model
'subtotal' => 'decimal:2', 'subtotal' => 'decimal:2',
'tax' => 'decimal:2', 'tax' => 'decimal:2',
'total' => 'decimal:2', 'total' => 'decimal:2',
'cash_received' => 'decimal:2',
'change' => 'decimal:2',
]; ];
public function user() public function user()

View File

@ -4,7 +4,6 @@
use App\Models\CashRegister; use App\Models\CashRegister;
use App\Models\Sale; use App\Models\Sale;
use Illuminate\Support\Facades\DB;
class CashRegisterService class CashRegisterService
{ {
@ -35,42 +34,43 @@ public function openRegister(array $data)
*/ */
public function closeRegister(CashRegister $register, array $data) public function closeRegister(CashRegister $register, array $data)
{ {
if ($register->status === 'closed') { $sales = Sale::where('cash_register_id', $register->id)
throw new \Exception('Esta caja ya está cerrada.'); ->where('status', 'completed')
} ->get();
return DB::transaction(function () use ($register, $data) { // Calcular efectivo real (recibido - devuelto)
// Calcular ventas en efectivo $cashSales = $sales->where('payment_method', 'cash')
$cashSales = Sale::where('cash_register_id', $register->id) ->sum(function ($sale) {
->where('payment_method', 'cash') return ($sale->cash_received ?? 0) - ($sale->change ?? 0);
->where('status', 'completed') });
->sum('total');
// Calcular efectivo esperado $totalCashReceived = $sales->where('payment_method', 'cash')->sum('cash_received');
$expectedCash = $register->initial_cash + $cashSales; $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 // Efectivo esperado
$finalCash = $data['final_cash']; $expectedCash = $register->initial_cash + $cashSales;
$difference = $finalCash - $expectedCash;
// Actualizar caja // Diferencia (sobrante o faltante)
$register->update([ $difference = $data['final_cash'] - $expectedCash;
'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',
]);
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') ->where('status', 'completed')
->get(); ->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 [ return [
'register' => $register, 'id' => $register->id,
'total_cash' => $sales->where('payment_method', 'cash')->sum('total'), 'user_id' => $register->user_id,
'total_credit_card' => $sales->where('payment_method', 'credit_card')->sum('total'), 'status' => $register->status,
'total_debit_card' => $sales->where('payment_method', 'debit_card')->sum('total'), 'opened_at' => $register->opened_at,
'total_sales' => $sales->sum('total'), 'closed_at' => $register->closed_at,
'sales_count' => $sales->count(), 'initial_cash' => (float) $register->initial_cash,
'expected_cash' => $register->initial_cash + $sales->where('payment_method', 'cash')->sum('total'),
// 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)
]; ];
} }
} }

View File

@ -0,0 +1,101 @@
<?php
namespace App\Services;
use App\Models\Inventory;
use App\Models\SaleDetail;
use Illuminate\Support\Facades\DB;
class ReportService
{
/**
* Obtener el producto más vendido
*
* @param string|null $fromDate Fecha inicial (formato: Y-m-d)
* @param string|null $toDate Fecha final (formato: Y-m-d)
* @return array|null Retorna el producto más vendido o null si no hay ventas
*/
public function getTopSellingProduct(?string $fromDate = null, ?string $toDate = null): ?array
{
$query = SaleDetail::query()
->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();
}
}

View File

@ -1,5 +1,6 @@
<?php namespace App\Services; <?php namespace App\Services;
use App\Models\CashRegister;
use App\Models\Sale; use App\Models\Sale;
use App\Models\SaleDetail; use App\Models\SaleDetail;
use App\Models\Inventory; use App\Models\Inventory;
@ -14,6 +15,15 @@ class SaleService
public function createSale(array $data) public function createSale(array $data)
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
// Calcular el cambio si es pago en efectivo
$cashReceived = null;
$change = null;
if ($data['payment_method'] === 'cash' && isset($data['cash_received'])) {
$cashReceived = $data['cash_received'];
$change = $cashReceived - $data['total'];
}
// 1. Crear la venta principal // 1. Crear la venta principal
$sale = Sale::create([ $sale = Sale::create([
'user_id' => $data['user_id'], 'user_id' => $data['user_id'],
@ -22,6 +32,8 @@ public function createSale(array $data)
'subtotal' => $data['subtotal'], 'subtotal' => $data['subtotal'],
'tax' => $data['tax'], 'tax' => $data['tax'],
'total' => $data['total'], 'total' => $data['total'],
'cash_received' => $cashReceived,
'change' => $change,
'payment_method' => $data['payment_method'], 'payment_method' => $data['payment_method'],
'status' => $data['status'] ?? 'completed', 'status' => $data['status'] ?? 'completed',
]); ]);
@ -99,7 +111,7 @@ private function generateInvoiceNumber(): string
private function getCurrentCashRegister($userId) private function getCurrentCashRegister($userId)
{ {
$register = \App\Models\CashRegister::where('user_id', $userId) $register = CashRegister::where('user_id', $userId)
->where('status', 'open') ->where('status', 'open')
->first(); ->first();

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('sales', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\App\CategoryController; use App\Http\Controllers\App\CategoryController;
use App\Http\Controllers\App\InventoryController; use App\Http\Controllers\App\InventoryController;
use App\Http\Controllers\App\PriceController; use App\Http\Controllers\App\PriceController;
use App\Http\Controllers\App\ReportController;
use App\Http\Controllers\App\SaleController; use App\Http\Controllers\App\SaleController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -48,6 +49,12 @@
Route::post('/open', [CashRegisterController::class, 'open']); Route::post('/open', [CashRegisterController::class, 'open']);
Route::put('/{register}/close', [CashRegisterController::class, 'close']); 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 */ /** Rutas públicas */