This commit is contained in:
Lizandro Guarnizo
2026-01-06 15:35:59 -05:00
commit a768146f65
602 changed files with 42505 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace App\Filament\Pages;
use App\Models\Producto;
use App\Models\Bodega;
use Filament\Pages\Page;
use App\Services\TransferenciaBodegaService;
use Filament\Notifications\Notification;
class GestionStockPage extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-chart-bar-square';
protected static ?string $navigationLabel = 'Gestión de Stock';
protected static ?string $navigationGroup = 'Inventario';
protected static string $view = 'filament.pages.gestion-stock';
public $productosStockBajo;
public $bodegasEstado;
public $sugerenciasTransferencia;
public function mount(): void
{
// Datos para la vista
$this->productosStockBajo = $this->getProductosStockBajo();
$this->bodegasEstado = $this->getBodegasEstado();
$this->sugerenciasTransferencia = $this->getSugerenciasTransferencia();
}
protected function getProductosStockBajo()
{
return Producto::with(['bodegas', 'categoria'])
->where('estado', true)
->get()
->filter(function ($producto) {
return $producto->getStockEfectivo() <= $producto->stock_minimo;
})
->map(function ($producto) {
return [
'id' => $producto->id,
'nombre' => $producto->nombre,
'categoria' => $producto->categoria->nombre ?? 'Sin categoría',
'stock_actual' => $producto->getStockEfectivo(),
'stock_minimo' => $producto->stock_minimo,
'distribucion' => $producto->bodegas->map(function ($bodega) {
return [
'bodega' => $bodega->nombre,
'stock' => $bodega->pivot->stock,
];
})->toArray(),
];
})
->toArray();
}
protected function getBodegasEstado()
{
return Bodega::with('productos')
->get()
->map(function ($bodega) {
$totalProductos = $bodega->productos()->count();
$productosConStock = $bodega->productos()->wherePivot('stock', '>', 0)->count();
$stockTotal = $bodega->productos()->sum('producto_bodega.stock');
return [
'id' => $bodega->id,
'nombre' => $bodega->nombre,
'total_productos' => $totalProductos,
'productos_con_stock' => $productosConStock,
'stock_total' => $stockTotal,
'utilizacion' => $totalProductos > 0 ? round(($productosConStock / $totalProductos) * 100, 1) : 0,
];
})
->toArray();
}
protected function getSugerenciasTransferencia()
{
$sugerencias = [];
// Buscar productos con stock desbalanceado entre bodegas
$productos = Producto::with('bodegas')
->where('estado', true)
->get();
foreach ($productos as $producto) {
if ($producto->bodegas->count() > 1) {
$bodegas = $producto->bodegas->sortByDesc('pivot.stock');
$mayor = $bodegas->first();
$menor = $bodegas->last();
// Si hay gran diferencia de stock entre bodegas
if ($mayor->pivot->stock > 0 && $menor->pivot->stock <= $producto->stock_minimo) {
$cantidadSugerida = min(
floor($mayor->pivot->stock * 0.3), // Máximo 30% del stock de la bodega con más stock
$producto->stock_minimo - $menor->pivot->stock + 5 // Lo necesario para estar sobre el mínimo
);
if ($cantidadSugerida > 0) {
$sugerencias[] = [
'producto_id' => $producto->id,
'producto_nombre' => $producto->nombre,
'bodega_origen' => $mayor->nombre,
'bodega_origen_id' => $mayor->id,
'bodega_destino' => $menor->nombre,
'bodega_destino_id' => $menor->id,
'cantidad_sugerida' => $cantidadSugerida,
'razon' => "Rebalanceo: {$menor->nombre} bajo mínimo",
'stock_origen' => $mayor->pivot->stock,
'stock_destino' => $menor->pivot->stock,
];
}
}
}
}
return collect($sugerencias)->take(10)->toArray();
}
public function ejecutarTransferenciaSugerida($data)
{
try {
$service = new TransferenciaBodegaService();
$transferencia = $service->transferir(
$data['producto_id'],
$data['bodega_origen_id'],
$data['bodega_destino_id'],
$data['cantidad_sugerida'],
$data['razon'] . ' (Transferencia automática sugerida)'
);
Notification::make()
->title('Transferencia ejecutada')
->body("Se transfirieron {$data['cantidad_sugerida']} unidades de {$data['producto_nombre']}")
->success()
->send();
// Recargar datos
$this->mount();
} catch (\Exception $e) {
Notification::make()
->title('Error en transferencia')
->body($e->getMessage())
->danger()
->send();
}
}
}
+480
View File
@@ -0,0 +1,480 @@
<?php
namespace App\Filament\Pages;
use App\Models\Caja;
use App\Models\Cliente;
use App\Models\MovimientoCaja;
use Filament\Pages\Page;
use Filament\Forms;
use Filament\Tables;
use App\Models\Producto;
use App\Models\ProductVariant;
use App\Models\Venta;
use Livewire\Component;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
class PuntoVenta extends Page
{
protected static ?string $navigationGroup = 'Operación';
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';
protected static string $view = 'filament.pages.punto-venta';
public $barcodeBusqueda = '';
public $productos = [];
public $carrito = [];
public $total = 0;
public $tipo_pago = 'Efectivo';
public $numeroDocumentoCliente = '';
public $datosCliente = [
'nombre' => null,
'correo' => null,
'telefono' => null,
];
public $cliente_id = null;
public $correoCotizacion = '';
public $bodega_id = null; // Bodega seleccionada para la venta
public function mount()
{
$this->productos = Producto::where('estado', true)
->where(function ($query) {
$query->where('stock', '>', 0)
->orWhereHas('bodegas', function ($q) {
$q->where('producto_bodega.stock', '>', 0);
})
->orWhereHas('variants', function ($q) {
$q->where('stock', '>', 0)
->orWhereHas('bodegas', function ($bq) {
$bq->where('variante_bodega.stock', '>', 0);
});
});
})
->with('variants.color', 'variants.size', 'bodegas')
->limit(20)
->get();
// Seleccionar bodega "Principal" por defecto
$bodegaPrincipal = \App\Models\Bodega::where('nombre', 'Principal')->first();
$this->bodega_id = $bodegaPrincipal ? $bodegaPrincipal->id : null;
}
public function agregarAlCarrito($productoId, $tipo)
{
if ($tipo == 'p') {
$producto = Producto::find($productoId);
if (!$producto)
return;
$key = 'p-' . $producto->id;
if (!isset($this->carrito[$key])) {
$this->carrito[$key] = [
'id' => $producto->id,
'nombre' => $producto->nombre,
'precio_venta' => $producto->precio_venta,
'precio_original' => $producto->precio_venta,
'precio_modificado' => $producto->precio_venta,
'cantidad' => 1,
'tipo' => 'p',
];
} else {
$this->carrito[$key]['cantidad']++;
}
} else {
$producto = ProductVariant::find($productoId);
if (!$producto)
return;
$key = 'v-' . $producto->id;
if (!isset($this->carrito[$key])) {
$this->carrito[$key] = [
'id' => $producto->producto->id,
'nombre' => $producto->producto->nombre,
'precio_venta' => $producto->producto->precio_venta,
'precio_original' => $producto->producto->precio_venta,
'precio_modificado' => $producto->producto->precio_venta,
'variante' => 'Color: ' . $producto->color->name . ', Talla: ' . $producto->size->name,
'variante_id' => $producto->id,
'cantidad' => 1,
'tipo' => 'v',
];
} else {
$this->carrito[$key]['cantidad']++;
}
}
$this->calcularTotal();
}
public function calcularTotal()
{
$this->total = collect($this->carrito)->sum(fn($item) => $item['precio_modificado'] * $item['cantidad']);
}
public function actualizarPrecio($key, $nuevoPrecio)
{
if (isset($this->carrito[$key])) {
// Validar que el precio sea válido (mayor o igual a 0)
$precio = floatval($nuevoPrecio);
if ($precio >= 0) {
$this->carrito[$key]['precio_modificado'] = $precio;
$this->calcularTotal();
}
}
}
public function procesarVenta()
{
if (empty($this->carrito)) {
session()->flash('error', 'No se puede procesar la venta: el carrito está vacío.');
return;
}
DB::beginTransaction();
try {
$clienteId = null;
if ($this->numeroDocumentoCliente) {
$cliente = Cliente::where('numero_documento', $this->numeroDocumentoCliente)->first();
if (!$cliente) {
$datosCliente = [
'nombre' => $this->datosCliente['nombre'] ?? "Cliente General",
'correo' => $this->datosCliente['correo'] ?? null,
'telefono' => $this->datosCliente['telefono'] ?? null,
'numero_documento' => $this->numeroDocumentoCliente,
'tipo_documento' => $this->datosCliente['tipo_documento'] ?? 'CC',
];
$cliente = Cliente::create($datosCliente);
}
$clienteId = $cliente->id;
} elseif ($this->cliente_id) {
$cliente = Cliente::find($this->cliente_id);
if ($cliente) {
$clienteId = $cliente->id;
} else {
$this->cliente_id = null;
$clienteId = null;
}
}
$venta = Venta::create([
'cliente_id' => $clienteId,
'total' => $this->total,
'tipo_pago' => $this->tipo_pago,
'estado' => 'Pagado',
]);
foreach ($this->carrito as $item) {
$precioOriginal = $item['precio_original'];
$precioModificado = $item['precio_modificado'];
$descuentoAplicado = $precioOriginal - $precioModificado;
$cantidadPendiente = $item['cantidad'];
// Identificar el producto o variante real
$producto = null;
$variante = null;
if (isset($item['variante_id'])) {
$variante = ProductVariant::find($item['variante_id']);
$producto = $variante->producto;
} else {
$producto = Producto::find($item['id']);
}
// Estrategia de deducción de stock:
// 1. Buscar en Bodega Principal
// 2. Buscar en otras bodegas con stock
// 3. Si falta, descontar de stock directo (o dejar negativo en Principal si se prefiere, aquí usaremos Principal como fallback)
$bodegas = \App\Models\Bodega::orderByRaw("CASE WHEN nombre = 'Principal' THEN 0 ELSE 1 END")->get();
foreach ($bodegas as $bodega) {
if ($cantidadPendiente <= 0) break;
$stockDisponible = 0;
if ($variante) {
$bodegaVariante = $variante->bodegas()->where('bodega_id', $bodega->id)->first();
$stockDisponible = $bodegaVariante ? $bodegaVariante->pivot->stock : 0;
} else {
$bodegaProducto = $producto->bodegas()->where('bodega_id', $bodega->id)->first();
$stockDisponible = $bodegaProducto ? $bodegaProducto->pivot->stock : 0;
}
if ($stockDisponible > 0) {
$cantidadADescontar = min($cantidadPendiente, $stockDisponible);
// Registrar detalle para esta bodega
$venta->detalles()->create([
'producto_id' => $item['id'],
'variante_id' => $item['variante_id'] ?? null,
'bodega_id' => $bodega->id,
'cantidad' => $cantidadADescontar,
'precio_unitario' => $precioModificado,
'precio_original' => $precioOriginal,
'descuento_aplicado' => $descuentoAplicado,
'usuario_modifico_precio_id' => ($descuentoAplicado != 0) ? auth()->id() : null,
'subtotal' => $cantidadADescontar * $precioModificado,
]);
// Actualizar stock
if ($variante) {
$variante->bodegas()->updateExistingPivot($bodega->id, [
'stock' => $stockDisponible - $cantidadADescontar
]);
} else {
$producto->bodegas()->updateExistingPivot($bodega->id, [
'stock' => $stockDisponible - $cantidadADescontar
]);
}
$cantidadPendiente -= $cantidadADescontar;
}
}
// Si aún queda cantidad pendiente (no había stock suficiente en ninguna bodega)
// Lo asignamos a la Bodega Principal (o NULL si no hay bodegas) y dejamos que el stock se vaya a negativo o se descuente del directo
if ($cantidadPendiente > 0) {
$bodegaFallback = $bodegas->first(); // Principal por el ordenamiento
$bodegaIdFallback = $bodegaFallback ? $bodegaFallback->id : null;
$venta->detalles()->create([
'producto_id' => $item['id'],
'variante_id' => $item['variante_id'] ?? null,
'bodega_id' => $bodegaIdFallback,
'cantidad' => $cantidadPendiente,
'precio_unitario' => $precioModificado,
'precio_original' => $precioOriginal,
'descuento_aplicado' => $descuentoAplicado,
'usuario_modifico_precio_id' => ($descuentoAplicado != 0) ? auth()->id() : null,
'subtotal' => $cantidadPendiente * $precioModificado,
]);
// Intentar descontar del fallback
if ($bodegaFallback) {
if ($variante) {
$bodegaVariante = $variante->bodegas()->where('bodega_id', $bodegaFallback->id)->first();
if ($bodegaVariante) {
$variante->bodegas()->updateExistingPivot($bodegaFallback->id, [
'stock' => $bodegaVariante->pivot->stock - $cantidadPendiente
]);
} else {
// Si no existe la relación, la creamos con stock negativo
$variante->bodegas()->attach($bodegaFallback->id, ['stock' => -$cantidadPendiente]);
}
} else {
$bodegaProducto = $producto->bodegas()->where('bodega_id', $bodegaFallback->id)->first();
if ($bodegaProducto) {
$producto->bodegas()->updateExistingPivot($bodegaFallback->id, [
'stock' => $bodegaProducto->pivot->stock - $cantidadPendiente
]);
} else {
$producto->bodegas()->attach($bodegaFallback->id, ['stock' => -$cantidadPendiente]);
}
}
} else {
// Si no hay bodegas en absoluto, descontar del stock directo
if ($variante) {
$variante->decrement('stock', $cantidadPendiente);
} else {
$producto->decrement('stock', $cantidadPendiente);
}
}
}
}
$cajaAbierta = Caja::where('estado', 'Abierta')
->orderBy('fecha_apertura', 'desc')
->first();
if ($cajaAbierta) {
MovimientoCaja::create([
'caja_id' => $cajaAbierta->id,
'tipo' => 'Ingreso',
'monto' => $this->total,
'descripcion' => 'Venta realizada. Venta ID: ' . $venta->id,
]);
}
DB::commit();
$this->resetVentaState();
session()->flash('message', 'Venta realizada con éxito.');
$this->js("window.dispatchEvent(new CustomEvent('imprimir-recibo', { detail: { venta_id: {$venta->id} } }));");
} catch (\Exception $e) {
DB::rollBack();
$this->resetVentaState();
session()->flash('error', 'Error al procesar la venta. ' . $e->getMessage());
}
}
/**
* Resetea el estado de la venta para evitar inconsistencias
*/
private function resetVentaState()
{
$this->carrito = [];
$this->total = 0;
$this->numeroDocumentoCliente = '';
$this->datosCliente = [
'nombre' => null,
'correo' => null,
'telefono' => null,
];
$this->cliente_id = null;
$this->tipo_pago = 'Efectivo';
// Mantener la bodega seleccionada para la próxima venta
// $this->bodega_id se mantiene
}
public function buscarProductoPorBarcode()
{
$barcode = trim($this->barcodeBusqueda);
$producto = Producto::where('codigo_barras', $barcode)->first();
$tipo = 'p';
if (!$producto) {
$producto = ProductVariant::where('barcode', $barcode)->first();
$tipo = 'v';
}
if ($producto) {
$this->agregarAlCarrito($producto->id, $tipo);
$this->barcodeBusqueda = '';
} else {
session()->flash('error', 'Producto no encontrado.');
}
}
public function incrementarCantidad($key)
{
if (isset($this->carrito[$key])) {
$this->carrito[$key]['cantidad']++;
$this->calcularTotal();
}
}
public function decrementarCantidad($key)
{
if (isset($this->carrito[$key])) {
$this->carrito[$key]['cantidad']--;
if ($this->carrito[$key]['cantidad'] <= 0) {
unset($this->carrito[$key]);
}
$this->calcularTotal();
}
}
public function eliminarDelCarrito($key)
{
if (isset($this->carrito[$key])) {
unset($this->carrito[$key]);
$this->calcularTotal();
}
}
public function enviarCotizacion()
{
$correo = $this->correoCotizacion;
if (!filter_var($correo, FILTER_VALIDATE_EMAIL)) {
session()->flash('error', 'Correo inválido.');
return;
}
try {
$carrito = $this->carrito;
$total = $this->total;
Mail::send('emails.cotizacion', compact('carrito', 'total'), function ($message) use ($correo) {
$message->to($correo)
->subject('Cotización de productos');
});
session()->flash('message', 'Cotización enviada exitosamente.');
$this->correoCotizacion = '';
} catch (\Exception $e) {
session()->flash('error', 'Error al enviar la cotización: ' . $e->getMessage());
}
}
public function buscarClientePorDocumento()
{
$documento = trim($this->numeroDocumentoCliente);
if (!$documento) {
// No se requiere documento
$this->cliente_id = null;
$this->datosCliente = [
'nombre' => null,
'correo' => null,
'telefono' => null,
];
return;
}
$cliente = Cliente::where('numero_documento', $documento)->first();
if ($cliente) {
// Verificar que el cliente encontrado existe realmente
$clienteValidado = Cliente::find($cliente->id);
if ($clienteValidado) {
$this->cliente_id = $clienteValidado->id;
$this->datosCliente = [
'nombre' => $clienteValidado->nombre,
'correo' => $clienteValidado->correo,
'telefono' => $clienteValidado->telefono,
];
} else {
// Cliente no válido, resetear
$this->cliente_id = null;
$this->datosCliente = [
'nombre' => null,
'correo' => null,
'telefono' => null,
];
}
} else {
$this->cliente_id = null;
$this->datosCliente = [
'nombre' => null,
'correo' => null,
'telefono' => null,
];
}
}
public function cambiarBodega($bodegaId)
{
$this->bodega_id = $bodegaId;
// Opcional: mostrar notificación de cambio
session()->flash('message', 'Bodega cambiada. Las ventas se realizarán desde la nueva bodega seleccionada.');
}
public function getBodegasProperty()
{
return \App\Models\Bodega::all();
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Filament\Pages;
use Filament\Actions\MountableAction;
use Filament\Pages\Page;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Form;
use App\Models\Setting;
use Filament\Actions;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\File;
class SettingPage extends Page implements HasForms
{
use InteractsWithForms;
public $logo;
public $description;
public $primary_color;
public $secondary_color;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static string $view = 'filament.pages.settings';
protected static ?string $title = 'Configuraciones del Sistema';
protected static ?string $navigationGroup = 'Administración'; //
protected static ?string $navigationLabel = 'Ajustes';
public ?array $data = [];
public function mount()
{
// Obtener la configuración existente o valores predeterminados
$setting = Setting::first();
$this->form->fill($setting?->toArray() ?? [
'logo' => null,
'description' => null,
'primary_color' => '#3498db',
'secondary_color' => '#2ecc71',
]);
}
protected function getFormSchema(): array
{
return [
FileUpload::make('logo')
->image()
->label('Logo')
->directory('settings/logos')
->required(),
Textarea::make('description')
->label('Descripción')
->maxLength(500)
->required(),
ColorPicker::make('primary_color')
->label('Color Primario')
->required(),
ColorPicker::make('secondary_color')
->label('Color Secundario')
->required(),
];
}
public function save()
{
$data = $this->form->getState();
Setting::updateOrCreate(['id' => 1], $data);
// Enviar notificación de éxito
Notification::make()
->title('Éxito')
->body('Configuraciones guardadas exitosamente.')
->success()
->send();
//$this->notify('success', 'Configuraciones guardadas exitosamente.');
}
protected function makeForm(): Form
{
return Form::make($this)
->schema($this->getFormSchema())
->statePath('data');
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('Cerrar todas las sesiones')
->color('danger')
->requiresConfirmation()
->action(function () {
if (config('session.driver') === 'file') {
// Eliminar todos los archivos de sesión
$files = File::files(storage_path('framework/sessions'));
foreach ($files as $file) {
File::delete($file);
}
} elseif (config('session.driver') === 'database') {
DB::table('sessions')->truncate();
}
// Limpia tu propia sesión para desconectarte también
Session::flush();
Notification::make()
->title('Sesiones cerradas')
->body('Todas las sesiones han sido cerradas exitosamente.')
->success()
->send();
}),
];
}
}