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
+658
View File
@@ -0,0 +1,658 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ProductoResource\Pages;
use App\Filament\Resources\ProductoResource\RelationManagers;
use App\Models\Producto;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Actions\DeleteAction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Filament\Tables\Filters\SelectFilter;
use App\Filament\Resources\ProductoResource\RelationManagers\VariantesRelationManager;
use App\Filament\Resources\ProductoResource\RelationManagers\BodegasRelationManager;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Actions\Action;
use App\Models\ProductVariant;
use Illuminate\Validation\Rule;
use Filament\Notifications\Notification;
use App\Exports\ProductosExport;
use App\Imports\ProductosImport;
use Maatwebsite\Excel\Facades\Excel;
use Filament\Forms\Components\FileUpload;
class ProductoResource extends Resource
{
protected static ?string $navigationGroup = 'Inventario'; //
protected static ?string $model = Producto::class;
protected static string $relationship = 'variantes';
protected static ?string $navigationIcon = 'heroicon-o-cube';
public static function canViewAny(): bool
{
return auth()->user()->can('ver productos');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('nombre')
->required()
->maxLength(100),
// Campo para subir imagen
Forms\Components\FileUpload::make('imagen')
->label('Imagen del Producto')
->image()
->directory(directory: 'productos')
->maxSize(10240)
->acceptedFileTypes(['image/jpeg', 'image/png', 'image/gif', 'image/webp']),
Forms\Components\Textarea::make('descripcion')
->columnSpanFull()
->required()
->placeholder('Ingrese una descripción del producto')
->default(''),
TextInput::make('precio_compra')
->required()
->numeric()
->maxLength(255)
->placeholder('Ej: 1000')
->lazy(), // Cambiar de reactive() a lazy()
TextInput::make('precio_venta')
->required()
->numeric()
->maxLength(255)
->lazy() // Cambiar de reactive() a lazy()
->placeholder('Ej: 1200')
->afterStateUpdated(function (callable $get, callable $set) {
$precioCompra = $get('precio_compra');
$precioVenta = $get('precio_venta');
// Solo validar si ambos valores están presentes y son significativos
if (!is_null($precioCompra) && !is_null($precioVenta) &&
$precioCompra > 0 && $precioVenta > 0 &&
$precioVenta < $precioCompra) {
Notification::make()
->warning()
->title('Advertencia')
->body('El precio de venta es menor al precio de compra.')
->persistent()
->send();
}
}),
Forms\Components\TextInput::make('stock')
->required()
->numeric()
->default(0)
->lazy() // Cambiar de live() a lazy()
->dehydrated()
->hidden(fn($get, $record) => $record && $record->variants()->exists()) // Oculta si hay variantes
->disabled(fn($get) => $get('producto_id') && \App\Models\Producto::find($get('producto_id'))->variants()->exists())
->visible(false) // Oculto - se maneja automáticamente
->label('Stock (Unidades Base)'),
Forms\Components\TextInput::make('stock_entrada')
->label('Stock Inicial')
->numeric()
->default(0)
->lazy() // Cambiar de live() a lazy() para evitar actualizaciones en tiempo real
->dehydrated(true) // Permitir que se envíe al servidor
->hidden(fn($get, $record) => $record && $record->variants()->exists())
->helperText(fn($get, $record) => $record && $record->variants()->exists()
? 'El stock se maneja a nivel de variantes'
: 'Ingrese el stock inicial del producto')
->afterStateUpdated(function (callable $get, callable $set) {
$stockEntrada = $get('stock_entrada') ?? 0;
$unidadMedida = $get('unidad_medida') ?? 'unidad';
// Asegurar que la cantidad sea float (Filament a veces pasa strings desde inputs)
$stockEntradaFloat = is_numeric($stockEntrada) ? (float) $stockEntrada : 0.0;
$stockUnidades = Producto::convertirAUnidades($stockEntradaFloat, $unidadMedida);
$set('stock', $stockUnidades);
})
->hint(function (callable $get) {
$unidadMedida = $get('unidad_medida') ?? 'unidad';
$factor = Producto::getFactorConversion($unidadMedida);
return $factor > 1 ? "Se convertirá automáticamente (×{$factor})" : null;
}),
Forms\Components\TextInput::make('stock_minimo')
->required()
->numeric()
->default(0)
->lazy() // Cambiar de live() a lazy()
->dehydrated()
->visible(false) // Oculto - se maneja automáticamente
->label('Stock Mínimo (Unidades Base)')
->afterStateUpdated(function (callable $get, callable $set) {
$stockMinimo = $get('stock_minimo');
$stockMaximo = $get('stock_maximo');
// Solo validar si hay valores significativos
if (!is_null($stockMinimo) && !is_null($stockMaximo) &&
$stockMinimo > 0 && $stockMaximo > 0 &&
$stockMaximo < $stockMinimo) {
Notification::make()
->warning()
->title('Advertencia')
->body('El stock máximo debe ser mayor al stock mínimo.')
->persistent()
->send();
}
}),
Forms\Components\TextInput::make('stock_minimo_entrada')
->label('Stock Mínimo')
->numeric()
->default(0)
->lazy() // Cambiar de live() a lazy() para evitar actualizaciones en tiempo real
->dehydrated(true) // Permitir que se envíe al servidor
->hidden(fn($get, $record) => $record && $record->variants()->exists())
->helperText(fn($get, $record) => $record && $record->variants()->exists()
? 'El stock mínimo se configura por variante'
: 'Configure el stock mínimo para alertas')
->afterStateUpdated(function (callable $get, callable $set) {
$stockMinimoEntrada = $get('stock_minimo_entrada') ?? 0;
$unidadMedida = $get('unidad_medida') ?? 'unidad';
$stockMinimoEntradaFloat = is_numeric($stockMinimoEntrada) ? (float) $stockMinimoEntrada : 0.0;
$stockMinimoUnidades = Producto::convertirAUnidades($stockMinimoEntradaFloat, $unidadMedida);
$set('stock_minimo', $stockMinimoUnidades);
// Solo validar si hay un valor significativo para evitar notificaciones innecesarias
if ($stockMinimoEntradaFloat > 0) {
$stockMaximo = $get('stock_maximo');
if (!is_null($stockMaximo) && $stockMaximo < $stockMinimoUnidades) {
Notification::make()
->warning()
->title('Advertencia')
->body('El stock máximo debe ser mayor al stock mínimo.')
->persistent()
->send();
}
}
})
->hint(function (callable $get) {
$unidadMedida = $get('unidad_medida') ?? 'unidad';
$factor = Producto::getFactorConversion($unidadMedida);
return $factor > 1 ? "Se convertirá automáticamente (×{$factor})" : null;
}),
Forms\Components\TextInput::make('stock_maximo')
->required()
->numeric()
->default(0)
->lazy() // Cambiar de live() a lazy()
->dehydrated()
->visible(false) // Oculto - se maneja automáticamente
->label('Stock Máximo (Unidades Base)')
->afterStateUpdated(function (callable $get, callable $set) {
$stockMinimo = $get('stock_minimo');
$stockMaximo = $get('stock_maximo');
// Solo validar si hay valores significativos
if (!is_null($stockMinimo) && !is_null($stockMaximo) &&
$stockMinimo > 0 && $stockMaximo > 0 &&
$stockMaximo < $stockMinimo) {
Notification::make()
->warning()
->title('Advertencia')
->body('El stock máximo debe ser mayor al stock mínimo.')
->persistent()
->send();
}
}),
Forms\Components\TextInput::make('stock_maximo_entrada')
->label('Stock Máximo')
->numeric()
->default(function (callable $get) {
$unidadMedida = $get('unidad_medida') ?? 'unidad';
return Producto::convertirDesdeUnidades(1000, $unidadMedida);
})
->lazy() // Cambiar de live() a lazy() para evitar actualizaciones en tiempo real
->dehydrated(true) // Permitir que se envíe al servidor
->hidden(fn($get, $record) => $record && $record->variants()->exists())
->helperText(fn($get, $record) => $record && $record->variants()->exists()
? 'El stock máximo se configura por variante'
: 'Configure el stock máximo recomendado')
->afterStateUpdated(function (callable $get, callable $set) {
$stockMaximoEntrada = $get('stock_maximo_entrada') ?? 0;
$unidadMedida = $get('unidad_medida') ?? 'unidad';
$stockMaximoEntradaFloat = is_numeric($stockMaximoEntrada) ? (float) $stockMaximoEntrada : 0.0;
$stockMaximoUnidades = Producto::convertirAUnidades($stockMaximoEntradaFloat, $unidadMedida);
$set('stock_maximo', $stockMaximoUnidades);
// Solo validar si hay un valor significativo para evitar notificaciones innecesarias
if ($stockMaximoEntradaFloat > 0) {
$stockMinimo = $get('stock_minimo');
if (!is_null($stockMinimo) && $stockMaximoUnidades < $stockMinimo) {
Notification::make()
->warning()
->title('Advertencia')
->body('El stock máximo debe ser mayor al stock mínimo.')
->persistent()
->send();
}
}
})
->hint(function (callable $get) {
$unidadMedida = $get('unidad_medida') ?? 'unidad';
$factor = Producto::getFactorConversion($unidadMedida);
return $factor > 1 ? "Se convertirá automáticamente (×{$factor})" : null;
}),
Forms\Components\Select::make('unidad_medida')
->label('Unidad de Medida')
->options(Producto::getUnidadesMedida())
->default('unidad')
->required()
->live()
->helperText('El sistema guardará todo en unidades individuales. Esta opción facilita la entrada de datos.')
->afterStateUpdated(function (callable $get, callable $set) {
$unidad = $get('unidad_medida');
$factor = Producto::getFactorConversion($unidad);
// Actualizar los campos auxiliares basados en los valores actuales en unidades
$stockActual = $get('stock') ?? 0;
$stockMinimo = $get('stock_minimo') ?? 0;
$stockMaximo = $get('stock_maximo') ?? 1000;
$set('stock_entrada', Producto::convertirDesdeUnidades($stockActual, $unidad));
$set('stock_minimo_entrada', Producto::convertirDesdeUnidades($stockMinimo, $unidad));
$set('stock_maximo_entrada', Producto::convertirDesdeUnidades($stockMaximo, $unidad));
if ($factor > 1) {
Notification::make()
->info()
->title('Conversión de Unidades')
->body("1 {$unidad} = {$factor} unidades individuales")
->send();
}
}),
Forms\Components\Select::make('categoria_id')
->relationship('categoria', 'nombre')
->searchable()
->required()
->lazy() // Cambiar de reactive() a lazy()
->afterStateUpdated(fn($set, $get) => ProductoResource::updateSkuAndBarcode($set, $get)),
TextInput::make('codigo_barras')
->label('Código de Barras')
->length(13)
->lazy() // Cambiar de live() a lazy()
->dehydrated()
->hidden(fn($get, $record) => $record && $record->variants()->exists()) // Oculta si hay variantes
->disabled(fn($get) => $get('producto_id') && \App\Models\Producto::find($get('producto_id'))->variants()->exists())
->suffixAction(
Action::make('imprimirQr')
->icon('heroicon-o-printer')
->color('primary')
->hidden(fn($record) => is_null($record)) // Oculta el botón si no hay un registro
->disabled(fn($get) => empty($get('codigo_barras'))) // Deshabilita si no hay código de barras
->action(fn($state) => redirect()->route('imprimir.barcode', ['barcode' => $state])) // Redirige a la impresión
->visible(fn($get, $record) => $record && !$record->variants()->exists()) // Muestra si el producto no tiene variantes
),
Forms\Components\Toggle::make('estado')
->label('Estado')
->default(true)
->required(),
Forms\Components\Hidden::make('producto_id')
->default(fn($record) => $record->id ?? null),
Forms\Components\Hidden::make('categoria_id')
->default(fn($record) => $record->categoria_id ?? null),
]);
}
protected static function updateSkuAndBarcode($set, $get)
{
$countryCode = '57'; // Código de país
$categoryId = $get('categoria_id') ?? '00';
// Obtener el siguiente ID de producto
$nextProductId = Producto::max('id') + 1;
$variantId = '00'; // Si no hay variante, usamos 00
// Generar código de barras estructurado
$eanService = app(\App\Services\EAN13Service::class);
$barcode = $eanService->generateUniqueBarcode($countryCode, $categoryId, $nextProductId, $variantId);
// Asignar el código de barras al formulario
$set('codigo_barras', $barcode);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('nombre')
->searchable(),
Tables\Columns\TextColumn::make('codigo_barras')
->formatStateUsing(fn($record) => $record->variants()->exists() ? 'Tiene variantes' : $record->codigo_barras)
->action(fn($record) => !$record->variants()->exists() ? redirect()->route('imprimir.barcode', ['barcode' => $record->codigo_barras]) : null)
->tooltip(fn($record) => $record->variants()->exists() ? 'Este producto tiene variantes que contienen los Barcodes' : 'Imprimir código de barras'),
Tables\Columns\TextColumn::make('precio_compra')
->searchable(),
Tables\Columns\TextColumn::make('precio_venta')
->searchable(),
Tables\Columns\TextColumn::make('stock_total')
->label('Stock')
->getStateUsing(function ($record, \Livewire\Component $livewire) {
$filterState = $livewire->tableFilters ?? [];
$bodegaId = $filterState['bodegas']['value'] ?? null;
if ($bodegaId) {
return $record->getStockTotalEnBodega($bodegaId);
}
// Usar el método getStockEfectivo que considera bodegas, variantes y stock directo
return $record->getStockEfectivo();
})
->formatStateUsing(function ($state, $record) {
$totalStockUnidades = $state;
$ok = $totalStockUnidades >= $record->stock_minimo;
$icon = $ok ? '✅' : '⚠️';
// Mostrar stock en la unidad de medida configurada
$unidadMedida = $record->unidad_medida ?? 'unidad';
$stockEnUnidad = Producto::convertirDesdeUnidades($totalStockUnidades, $unidadMedida);
$nombreUnidad = Producto::getUnidadesMedida()[$unidadMedida] ?? 'Unidad (1)';
return "{$icon} {$stockEnUnidad} " . explode(' ', $nombreUnidad)[0];
})
->tooltip(function ($record, \Livewire\Component $livewire) {
$filterState = $livewire->tableFilters ?? [];
$bodegaId = $filterState['bodegas']['value'] ?? null;
if ($bodegaId) {
$totalStockUnidades = $record->getStockTotalEnBodega($bodegaId);
$bodegaNombre = \App\Models\Bodega::find($bodegaId)?->nombre ?? 'Bodega seleccionada';
$origen = "\nEn {$bodegaNombre}";
} else {
$totalStockUnidades = $record->getStockEfectivo();
$origen = '';
if ($record->bodegas()->exists()) {
$origen = "\nDistribuido en " . $record->bodegas()->count() . " bodega(s)";
} elseif ($record->variants()->exists()) {
$origen = "\nStock de variantes";
} else {
$origen = "\nStock directo";
}
}
$unidadMedida = $record->unidad_medida ?? 'unidad';
$stockEnUnidad = Producto::convertirDesdeUnidades($totalStockUnidades, $unidadMedida);
$nombreUnidad = Producto::getUnidadesMedida()[$unidadMedida] ?? 'Unidad (1)';
$mensaje = $totalStockUnidades < $record->stock_minimo
? "Stock por debajo del mínimo"
: "Stock suficiente";
return "{$mensaje}\nStock en {$nombreUnidad}: {$stockEnUnidad}\nStock en unidades: {$totalStockUnidades}{$origen}";
}),
Tables\Columns\TextColumn::make('stock_minimo')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('unidad_medida')
->label('Unidad')
->formatStateUsing(function ($state) {
$unidades = Producto::getUnidadesMedida();
return explode(' ', $unidades[$state ?? 'unidad'])[0];
})
->tooltip(function ($record) {
$unidades = Producto::getUnidadesMedida();
return $unidades[$record->unidad_medida ?? 'unidad'];
}),
Tables\Columns\TextColumn::make('categoria.nombre')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('estado')
->boolean(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->paginated(true)
//->paginationPageOptions([50])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('categoria_id')
->label('Categoría')
->relationship('categoria', 'nombre') // Relación con el modelo Categoría
->preload() // Precargar opciones
->searchable(), // Permitir búsqueda en el filtro
SelectFilter::make('bodegas')
->label('Bodega')
->relationship('bodegas', 'nombre')
->preload()
->searchable(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->before(function (Producto $record) {
// Verificar si el producto está siendo usado en compras
$comprasCount = $record->detalleCompras()->count();
$ventasCount = $record->detalleVentas()->count();
if ($comprasCount > 0 || $ventasCount > 0) {
Notification::make()
->warning()
->title('No se puede eliminar')
->body("Este producto está siendo usado en {$comprasCount} compras y {$ventasCount} ventas. Los registros se mantendrán con información histórica.")
->persistent()
->send();
}
}),
Tables\Actions\Action::make('transferir')
->label('Transferir Stock')
->icon('heroicon-o-arrow-path')
->color('warning')
->form([
Forms\Components\Select::make('bodega_origen_id')
->label('Bodega Origen')
->options(function (Producto $record) {
return $record->bodegas()
->wherePivot('stock', '>', 0)
->pluck('nombre', 'bodegas.id');
})
->required()
->reactive()
->helperText(function (callable $get, Producto $record) {
$bodegaId = $get('bodega_origen_id');
if (!$bodegaId) return null;
$bodega = $record->bodegas()->where('bodega_id', $bodegaId)->first();
$stock = $bodega ? $bodega->pivot->stock : 0;
return "Stock disponible: {$stock} unidades";
}),
Forms\Components\Select::make('bodega_destino_id')
->label('Bodega Destino')
->options(function (callable $get) {
$bodegaOrigenId = $get('bodega_origen_id');
$bodegas = \App\Models\Bodega::all()->pluck('nombre', 'id');
if ($bodegaOrigenId) {
$bodegas = $bodegas->except($bodegaOrigenId);
}
return $bodegas;
})
->required(),
Forms\Components\TextInput::make('cantidad')
->label('Cantidad')
->numeric()
->required()
->minValue(1)
->maxValue(function (callable $get, Producto $record) {
$bodegaId = $get('bodega_origen_id');
if (!$bodegaId) return 999999;
$bodega = $record->bodegas()->where('bodega_id', $bodegaId)->first();
return $bodega ? $bodega->pivot->stock : 0;
}),
Forms\Components\Textarea::make('motivo')
->label('Motivo')
->placeholder('Opcional')
->rows(2),
])
->action(function (Producto $record, array $data) {
try {
$service = new \App\Services\TransferenciaBodegaService();
$service->transferir(
$record->id,
$data['bodega_origen_id'],
$data['bodega_destino_id'],
$data['cantidad'],
$data['motivo'] ?? null
);
Notification::make()
->title('Transferencia realizada')
->body("Se transfirieron {$data['cantidad']} unidades de {$record->nombre}")
->success()
->send();
} catch (\Exception $e) {
Notification::make()
->title('Error en transferencia')
->body($e->getMessage())
->danger()
->send();
}
})
->visible(fn (Producto $record) => $record->bodegas()->wherePivot('stock', '>', 0)->exists()),
])
->headerActions([
Tables\Actions\Action::make('exportar')
->label('Exportar a Excel')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->action(function () {
return Excel::download(new ProductosExport, 'productos_' . date('Y-m-d_H-i-s') . '.xlsx');
}),
Tables\Actions\Action::make('importar')
->label('Importar desde Excel')
->icon('heroicon-o-arrow-up-tray')
->color('primary')
->form([
FileUpload::make('archivo')
->label('Archivo Excel')
->acceptedFileTypes([
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
])
->required()
->helperText('Formatos aceptados: .xlsx, .xls, .csv')
->disk('local')
->directory('imports'),
])
->action(function (array $data) {
try {
$import = new ProductosImport;
Excel::import($import, $data['archivo']);
$failures = $import->failures();
$errors = $import->errors();
if ($failures->isNotEmpty() || $errors->isNotEmpty()) {
$errorMessages = [];
foreach ($failures as $failure) {
$errorMessages[] = "Fila {$failure->row()}: " . implode(', ', $failure->errors());
}
foreach ($errors as $error) {
$errorMessages[] = $error->getMessage();
}
Notification::make()
->warning()
->title('Importación completada con errores')
->body('Algunos productos no se pudieron importar: ' . implode(' | ', array_slice($errorMessages, 0, 3)))
->persistent()
->send();
} else {
Notification::make()
->success()
->title('Importación exitosa')
->body('Los productos se importaron correctamente.')
->send();
}
} catch (\Exception $e) {
Notification::make()
->danger()
->title('Error en la importación')
->body('Ocurrió un error: ' . $e->getMessage())
->persistent()
->send();
}
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
VariantesRelationManager::class,
BodegasRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListProductos::route('/'),
'create' => Pages\CreateProducto::route('/create'),
'edit' => Pages\EditProducto::route('/{record}/edit'),
];
}
public static function getNavigationBadgeColor(): ?string
{
return 'success'; // Verde para productos
}
}