add: cambio al pagar con efectivo
This commit is contained in:
parent
e37a8b117d
commit
d5665f0448
@ -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 ]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
92
app/Http/Controllers/App/ReportController.php
Normal file
92
app/Http/Controllers/App/ReportController.php
Normal 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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) . ').'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
101
app/Services/ReportService.php
Normal file
101
app/Services/ReportService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
<?php namespace App\Services;
|
||||
|
||||
use App\Models\CashRegister;
|
||||
use App\Models\Sale;
|
||||
use App\Models\SaleDetail;
|
||||
use App\Models\Inventory;
|
||||
@ -14,6 +15,15 @@ class SaleService
|
||||
public function createSale(array $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
|
||||
$sale = Sale::create([
|
||||
'user_id' => $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();
|
||||
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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 */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user