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
+295
View File
@@ -0,0 +1,295 @@
<?php
namespace App\Imports;
use App\Models\Producto;
use App\Models\ProductVariant;
use App\Models\Bodega;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;
use Illuminate\Support\Collection;
class CompraDetallesImport implements ToCollection, WithHeadingRow, WithValidation
{
protected $previewData = [];
protected $errors = [];
/**
* Procesa la colección y genera vista preliminar
*/
public function collection(Collection $rows)
{
$this->previewData = [];
$rowNumber = 2; // Comenzar en 2 porque fila 1 son los encabezados
foreach ($rows as $row) {
$detalle = $this->processRow($row->toArray(), $rowNumber);
$this->previewData[] = $detalle;
$rowNumber++;
}
}
/**
* Procesa una fila individual
*/
private function processRow(array $row, int $rowNumber): array
{
$result = [
'row_number' => $rowNumber,
'valid' => true,
'errors' => [],
'data' => [],
'producto_creado' => false,
];
// Normalizar nombres de columnas
$row = $this->normalizeKeys($row);
// Buscar o crear producto
$producto = null;
$variante = null;
$productoCreado = false;
// 1. Primero intentar buscar por código de barras
if (!empty($row['codigo_barras'])) {
// Buscar variante primero
$variante = ProductVariant::where('barcode', $row['codigo_barras'])->first();
if ($variante) {
$producto = $variante->producto;
} else {
// Buscar producto por código
$producto = Producto::where('codigo_barras', $row['codigo_barras'])->first();
}
}
// 2. Si no se encontró por código, buscar por nombre
if (!$producto && !empty($row['producto'])) {
$producto = Producto::where('nombre', 'like', '%' . trim($row['producto']) . '%')->first();
}
// 3. Si no existe, crear el producto
if (!$producto) {
// Validar que tenga al menos nombre o código
if (empty($row['producto']) && empty($row['codigo_barras'])) {
$result['valid'] = false;
$result['errors'][] = 'Debe proporcionar código de barras o nombre del producto';
} else {
$nombreProducto = !empty($row['producto']) ? trim($row['producto']) : 'Producto ' . $row['codigo_barras'];
$precioUnitario = isset($row['precio_unitario']) ? (float) $row['precio_unitario'] : 0;
if ($precioUnitario <= 0) {
$result['valid'] = false;
$result['errors'][] = 'Precio debe ser mayor a 0 para crear producto';
} else {
// Buscar o crear categoría "Importados"
$categoria = \App\Models\Categoria::firstOrCreate(
['nombre' => 'Importados'],
['descripcion' => 'Productos creados automáticamente durante importación de compras']
);
// Generar código de barras único si no se proporcionó
$codigoBarras = !empty($row['codigo_barras']) ? $row['codigo_barras'] : null;
if (!$codigoBarras) {
// Generar código único: IMP + timestamp + random
do {
$codigoBarras = 'IMP' . time() . rand(100, 999);
} while (Producto::where('codigo_barras', $codigoBarras)->exists());
}
$producto = Producto::create([
'nombre' => $nombreProducto,
'descripcion' => 'Creado automáticamente desde importación',
'codigo_barras' => $codigoBarras,
'categoria_id' => $categoria->id,
'precio_compra' => $precioUnitario,
'precio_venta' => round($precioUnitario * 1.3, 2), // 30% de margen por defecto
'unidad_medida' => 'unidad',
'estado' => true,
'imagen' => '',
]);
$productoCreado = true;
$result['producto_creado'] = true;
}
}
}
// Buscar bodega
$bodega = null;
if (!empty($row['bodega'])) {
$bodega = Bodega::where('nombre', 'like', '%' . $row['bodega'] . '%')->first();
} else {
$bodega = Bodega::where('nombre', 'Principal')->first();
}
if (!$bodega) {
$result['valid'] = false;
$result['errors'][] = 'Bodega no encontrada';
}
// Validar cantidad
$cantidad = isset($row['cantidad']) ? (int) $row['cantidad'] : 1;
if ($cantidad <= 0) {
$result['valid'] = false;
$result['errors'][] = 'Cantidad debe ser mayor a 0';
}
// Validar precio
$precio = isset($row['precio_unitario']) ? (float) $row['precio_unitario'] : 0;
if ($precio <= 0) {
$result['valid'] = false;
$result['errors'][] = 'Precio debe ser mayor a 0';
}
// Calcular subtotal
$subtotal = $cantidad * $precio;
// Preparar datos
$result['data'] = [
'codigo_barras' => $row['codigo_barras'] ?? '',
'producto_nombre_excel' => $row['producto'] ?? '',
'bodega_id' => $bodega?->id,
'bodega_nombre' => $bodega?->nombre ?? 'No encontrada',
'producto_id' => $producto?->id,
'producto_nombre' => $producto?->nombre ?? 'No encontrado',
'variante_id' => $variante?->id,
'variante_info' => $variante ? $this->getVariantInfo($variante) : null,
'cantidad' => $cantidad,
'precio_unitario' => $precio,
'subtotal' => $subtotal,
'DetalleCompra' => $row['observaciones'] ?? $row['detalle'] ?? '',
];
return $result;
}
/**
* Obtiene información formateada de la variante
*/
private function getVariantInfo($variante): string
{
$colorName = $variante->color?->name ?? 'Sin color';
$sizeName = $variante->size?->name ?? 'Sin talla';
return "$colorName / $sizeName";
}
/**
* Normaliza las claves del array
*/
private function normalizeKeys(array $row): array
{
$normalized = [];
$mappings = [
'codigo_de_barras' => 'codigo_barras',
'codigo' => 'codigo_barras',
'barcode' => 'codigo_barras',
'precio' => 'precio_unitario',
'precio_unit' => 'precio_unitario',
'observacion' => 'observaciones',
'detalle' => 'observaciones',
'cantidad' => 'cantidad',
'cant' => 'cantidad',
'qty' => 'cantidad',
'producto' => 'producto',
'nombre' => 'producto',
'nombre_producto' => 'producto',
'articulo' => 'producto',
'bodega' => 'bodega',
];
foreach ($row as $key => $value) {
$normalizedKey = strtolower(trim($key));
$normalizedKey = str_replace(' ', '_', $normalizedKey);
$normalizedKey = $this->removeAccents($normalizedKey);
if (isset($mappings[$normalizedKey])) {
$normalized[$mappings[$normalizedKey]] = $value;
} else {
$normalized[$normalizedKey] = $value;
}
}
return $normalized;
}
/**
* Elimina tildes
*/
private function removeAccents($string)
{
$unwanted = [
'á' => 'a', 'é' => 'e', 'í' => 'i', 'ó' => 'o', 'ú' => 'u',
'Á' => 'A', 'É' => 'E', 'Í' => 'I', 'Ó' => 'O', 'Ú' => 'U',
'ñ' => 'n', 'Ñ' => 'N',
];
return strtr($string, $unwanted);
}
/**
* Reglas de validación
*/
public function rules(): array
{
return [
'codigo_barras' => 'nullable|string',
'producto' => 'nullable|string',
'bodega' => 'nullable|string',
'cantidad' => 'nullable|integer|min:1',
'precio_unitario' => 'nullable|numeric|min:0',
'observaciones' => 'nullable|string',
];
}
/**
* Obtiene los datos de vista preliminar
*/
public function getPreviewData(): array
{
return $this->previewData;
}
/**
* Obtiene estadísticas de la importación
*/
public function getStats(): array
{
$valid = collect($this->previewData)->filter(fn($item) => $item['valid'])->count();
$invalid = collect($this->previewData)->filter(fn($item) => !$item['valid'])->count();
$productosCreados = collect($this->previewData)->filter(fn($item) => isset($item['producto_creado']) && $item['producto_creado'])->count();
$total = count($this->previewData);
$totalAmount = collect($this->previewData)
->where('valid', true)
->sum(fn($item) => $item['data']['subtotal']);
return [
'total' => $total,
'valid' => $valid,
'invalid' => $invalid,
'productos_creados' => $productosCreados,
'total_amount' => $totalAmount,
];
}
/**
* Convierte los datos preliminares a formato para guardar
*/
public function getDetallesForSave(): array
{
return collect($this->previewData)
->filter(fn($item) => $item['valid'])
->map(fn($item) => [
'bodega_id' => $item['data']['bodega_id'],
'producto_id' => $item['data']['producto_id'],
'variante_id' => $item['data']['variante_id'],
'cantidad' => $item['data']['cantidad'],
'precio_unitario' => $item['data']['precio_unitario'],
'subtotal' => $item['data']['subtotal'],
'DetalleCompra' => $item['data']['DetalleCompra'],
'producto_nombre_snapshot' => $item['data']['producto_nombre'],
'variante_info_snapshot' => $item['data']['variante_info'],
])
->toArray();
}
}
+300
View File
@@ -0,0 +1,300 @@
<?php
namespace App\Imports;
use App\Models\Producto;
use App\Models\Categoria;
use App\Models\Bodega;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\SkipsOnFailure;
use Maatwebsite\Excel\Concerns\SkipsOnError;
use Maatwebsite\Excel\Concerns\SkipsFailures;
use Maatwebsite\Excel\Concerns\SkipsErrors;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ProductosImport implements ToModel, WithHeadingRow, WithValidation, SkipsOnFailure, SkipsOnError
{
use SkipsFailures, SkipsErrors;
/**
* Normaliza y prepara los datos antes de la validación.
*/
public function prepareForValidation($data, $index)
{
// Normalizar nombres de columnas
$normalizedData = [];
foreach ($data as $key => $value) {
$normalizedKey = $this->normalizeColumnName($key);
$normalizedData[$normalizedKey] = $value;
}
$data = $normalizedData;
// Código de barras a string
if (isset($data['codigo_de_barras']) && is_numeric($data['codigo_de_barras'])) {
$data['codigo_de_barras'] = (string) $data['codigo_de_barras'];
}
// Estado a string
if (isset($data['estado'])) {
if (is_bool($data['estado']) || is_numeric($data['estado'])) {
$data['estado'] = $data['estado'] ? 'Activo' : 'Inactivo';
}
}
// Unidad de medida a string
if (isset($data['unidad_de_medida']) && !is_string($data['unidad_de_medida'])) {
$data['unidad_de_medida'] = (string) $data['unidad_de_medida'];
}
// Categoría por nombre
if (!empty($data['categoria_nombre']) && empty($data['categoria_id'])) {
$categoria = Categoria::firstOrCreate(
['nombre' => $data['categoria_nombre']],
['descripcion' => 'Categoría creada automáticamente durante importación']
);
$data['categoria_id'] = $categoria->id;
}
// Categoría (id o nombre)
if (!empty($data['categoria']) && empty($data['categoria_id'])) {
if (is_numeric($data['categoria'])) {
$data['categoria_id'] = $data['categoria'];
} else {
$categoria = Categoria::firstOrCreate(
['nombre' => $data['categoria']],
['descripcion' => 'Categoría creada automáticamente durante importación']
);
$data['categoria_id'] = $categoria->id;
}
}
return $data;
}
/**
* Normaliza el nombre de una columna.
*/
private function normalizeColumnName($name)
{
$mappings = [
'código de barras' => 'codigo_de_barras',
'codigo de barras' => 'codigo_de_barras',
'categoría' => 'categoria',
'categoria' => 'categoria',
'descripción' => 'descripcion',
'descripcion' => 'descripcion',
'unidad de medida' => 'unidad_de_medida',
'stock mínimo' => 'stock_minimo',
'stock minimo' => 'stock_minimo',
'stock máximo' => 'stock_maximo',
'stock maximo' => 'stock_maximo',
'precio compra' => 'precio_compra',
'precio venta' => 'precio_venta',
];
$normalized = strtolower(trim($name));
if (isset($mappings[$normalized])) {
return $mappings[$normalized];
}
$normalized = str_replace(' ', '_', $normalized);
$normalized = $this->removeAccents($normalized);
return $normalized;
}
/**
* Elimina tildes.
*/
private function removeAccents($string)
{
$unwanted = [
'á' => 'a', 'é' => 'e', 'í' => 'i', 'ó' => 'o', 'ú' => 'u',
'Á' => 'A', 'É' => 'E', 'Í' => 'I', 'Ó' => 'O', 'Ú' => 'U',
'ñ' => 'n', 'Ñ' => 'N',
];
return strtr($string, $unwanted);
}
/**
* Obtiene el ID de categoría.
*/
private function getCategoriaId(array $row): ?int
{
if (!empty($row['categoria_id']) && is_numeric($row['categoria_id'])) {
return (int) $row['categoria_id'];
}
if (!empty($row['categoria_nombre'])) {
$cat = Categoria::where('nombre', $row['categoria_nombre'])->first();
if ($cat) return $cat->id;
}
if (!empty($row['categoria'])) {
if (is_numeric($row['categoria'])) {
return (int) $row['categoria'];
}
$cat = Categoria::where('nombre', $row['categoria'])->first();
if ($cat) return $cat->id;
}
return null;
}
/**
* Parsea unidad de medida.
*/
private function parseUnidadMedida($value): string
{
if (empty($value)) return 'unidad';
$value = strtolower(trim($value));
$map = [
'unidad' => 'unidad',
'unidades' => 'unidad',
'u' => 'unidad',
'docena' => 'docena',
'docenas' => 'docena',
'dz' => 'docena',
'doc' => 'docena',
];
return $map[$value] ?? 'unidad';
}
/**
* Convierte estado a booleano.
*/
private function parseEstado($value): bool
{
if (is_bool($value)) return $value;
if (is_numeric($value)) return (bool) $value;
$value = strtolower(trim($value));
return in_array($value, ['activo', 'active', '1', 'true', 'si', 'yes']);
}
/**
* Crea o actualiza el producto y asigna stock a la bodega.
*/
public function model(array $row)
{
$categoriaId = $this->getCategoriaId($row);
// Determinar bodega
$bodegaId = null;
if (!empty($row['bodega_id'])) {
$bodegaId = $row['bodega_id'];
} elseif (!empty($row['bodega'])) {
$bodega = Bodega::where('nombre', $row['bodega'])->first();
$bodegaId = $bodega ? $bodega->id : null;
} else {
$bodega = Bodega::where('nombre', 'Principal')->first();
$bodegaId = $bodega ? $bodega->id : null;
}
// Actualizar por ID
if (!empty($row['id']) && is_numeric($row['id'])) {
$producto = Producto::find($row['id']);
if ($producto) {
$producto->update([
'nombre' => $row['nombre'],
'descripcion' => $row['descripcion'] ?? null,
'codigo_barras' => $row['codigo_de_barras'] ?? null,
'categoria_id' => $categoriaId,
'precio_compra' => $row['precio_compra'],
'precio_venta' => $row['precio_venta'],
'stock_minimo' => $row['stock_minimo'] ?? null,
'stock_maximo' => $row['stock_maximo'] ?? null,
'unidad_medida' => $this->parseUnidadMedida($row['unidad_de_medida'] ?? $row['unidad_medida'] ?? 'unidad'),
'estado' => $this->parseEstado($row['estado'] ?? 'Activo'),
'imagen' => '',
]);
if ($bodegaId && isset($row['stock'])) {
$producto->bodegas()->syncWithoutDetaching([
$bodegaId => ['stock' => $row['stock']],
]);
}
return null;
}
}
// Actualizar por código de barras
if (!empty($row['codigo_de_barras'])) {
$existente = Producto::where('codigo_barras', $row['codigo_de_barras'])->first();
if ($existente) {
$existente->update([
'nombre' => $row['nombre'],
'descripcion' => $row['descripcion'] ?? null,
'categoria_id' => $categoriaId,
'precio_compra' => $row['precio_compra'],
'precio_venta' => $row['precio_venta'],
'stock_minimo' => $row['stock_minimo'] ?? null,
'stock_maximo' => $row['stock_maximo'] ?? null,
'unidad_medida' => $this->parseUnidadMedida($row['unidad_de_medida'] ?? $row['unidad_medida'] ?? 'unidad'),
'estado' => $this->parseEstado($row['estado'] ?? 'Activo'),
'imagen' => '',
]);
if ($bodegaId && isset($row['stock'])) {
$existente->bodegas()->syncWithoutDetaching([
$bodegaId => ['stock' => $row['stock']],
]);
}
return null;
}
}
// Crear nuevo producto
$producto = new Producto([
'nombre' => $row['nombre'],
'descripcion' => $row['descripcion'] ?? null,
'codigo_barras' => $row['codigo_de_barras'] ?? null,
'categoria_id' => $categoriaId,
'precio_compra' => $row['precio_compra'],
'precio_venta' => $row['precio_venta'],
'stock_minimo' => $row['stock_minimo'] ?? null,
'stock_maximo' => $row['stock_maximo'] ?? null,
'unidad_medida' => $this->parseUnidadMedida($row['unidad_de_medida'] ?? $row['unidad_medida'] ?? 'unidad'),
'estado' => $this->parseEstado($row['estado'] ?? 'Activo'),
'imagen' => '',
]);
$producto->save();
if ($bodegaId && isset($row['stock'])) {
$producto->bodegas()->attach($bodegaId, ['stock' => $row['stock']]);
}
return $producto;
}
/**
* Reglas de validación.
*/
public function rules(): array
{
return [
'nombre' => 'required|string|max:255',
'descripcion' => 'nullable',
'codigo_de_barras' => 'nullable',
'categoria_id' => 'required|exists:categorias,id',
'precio_compra' => 'required|numeric|min:0',
'precio_venta' => 'required|numeric|min:0',
'stock' => 'nullable|integer|min:0',
'stock_minimo' => 'nullable|integer|min:0',
'stock_maximo' => 'nullable|integer|min:0',
'unidad_de_medida' => 'nullable',
'unidad_medida' => 'nullable',
'estado' => 'nullable',
];
}
/**
* Mensajes personalizados.
*/
public function customValidationMessages()
{
return [
'nombre.required' => 'El nombre del producto es obligatorio.',
'categoria_id.required' => 'La categoría es obligatoria.',
'categoria_id.exists' => 'La categoría especificada no existe.',
'precio_compra.required' => 'El precio de compra es obligatorio.',
'precio_compra.numeric' => 'El precio de compra debe ser un número.',
'precio_venta.required' => 'El precio de venta es obligatorio.',
'precio_venta.numeric' => 'El precio de venta debe ser un número.',
'stock.integer' => 'El stock debe ser un número entero.',
'unidad_de_medida.in' => 'La unidad de medida debe ser: unidad o docena.',
];
}
}