Files
pos_heidiver/app/Filament/Resources/ProductoResource.php
T
2026-01-06 15:35:59 -05:00

659 lines
33 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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
}
}