Add new product images and implement SeedMutualSmepCatalogTest for catalog migration

- Added multiple new product images for smart TVs, audio devices, and accessories in WEBP format.
- Created a new test class SeedMutualSmepCatalogTest to validate the catalog migration process.
- Implemented setup and teardown methods to manage database schema and data during tests.
- Verified the integrity of catalog items, inventories, and associated attributes after migration.
This commit is contained in:
2026-09-24 12:41:24 -03:00
parent 16e2ca679c
commit 74b72a7d4e
516 changed files with 3953 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,356 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
return new class extends Migration
{
private const TENANT_CODE = 'mutual_smep';
private const MANIFEST = 'data/mutual_smep_catalog_v1.json';
private const STORAGE_PREFIX = 'tenants/mutual_smep/catalog-v1/';
private const BRAND_MARKER = 'Provisioned by Mutual SMEP catalog migration v1.';
private const FEATURED_GROUP_CODE = 'productos-smep';
/** @var list<string> */
private array $storedPaths = [];
public function up(): void
{
if (! DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) {
throw new RuntimeException("Tenant '".self::TENANT_CODE."' not found.");
}
$manifest = $this->manifest();
$slugs = array_column($manifest['items'], 'slug');
$existingSlug = DB::table('catalog_items')
->where('tenant_code', self::TENANT_CODE)
->whereIn('slug', $slugs)
->value('slug');
if ($existingSlug !== null) {
throw new RuntimeException("Catalog item '{$existingSlug}' already exists for Mutual SMEP.");
}
try {
DB::transaction(function () use ($manifest): void {
$brandIds = [];
$categoryIds = [];
$attributeIds = [];
foreach ($manifest['items'] as $order => $item) {
$brandId = $this->brandId($item['brand'], $brandIds);
$categoryId = $this->categoryId(
$item['parent_category'],
$item['category'],
$categoryIds,
);
$hasVariants = $item['variants'] !== [];
$inventoryId = $hasVariants
? null
: $this->createInventory((int) $item['stock']);
$catalogItemId = DB::table('catalog_items')->insertGetId([
'tenant_code' => self::TENANT_CODE,
'category_id' => $categoryId,
'brand_id' => $brandId,
'inventory_id' => $inventoryId,
'type' => 'standard',
'slug' => $item['slug'],
'nombre' => $item['name'],
'group_order' => $order + 1,
'descripcion' => $item['description'],
'precio' => $item['price'],
'inventory_policy' => 'tracked',
'inventory_subject' => 'product',
'has_tickets' => false,
]);
$this->attachImages($catalogItemId, null, $item['images']);
if (! $hasVariants) {
continue;
}
$itemAttributeIds = [];
foreach ($item['attribute_codes'] as $attributeOrder => $attributeCode) {
$attributeId = $this->attributeId($attributeCode, $attributeIds);
$itemAttributeIds[$attributeCode] = DB::table('item_attributes')->insertGetId([
'catalog_item_id' => $catalogItemId,
'attribute_id' => $attributeId,
'allow_multi_select' => false,
'sort_order' => $attributeOrder + 1,
'show_in_selector' => true,
'ticket_label' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
foreach ($item['variants'] as $variant) {
$variantId = DB::table('variantes')->insertGetId([
'catalog_item_id' => $catalogItemId,
'inventory_id' => $this->createInventory((int) $variant['stock']),
'descripcion' => $variant['description'],
'precio' => $variant['price'],
]);
foreach ($variant['values'] as $attributeCode => $value) {
$itemAttributeId = $itemAttributeIds[$attributeCode] ?? null;
if ($itemAttributeId === null) {
throw new RuntimeException(
"Attribute '{$attributeCode}' is not assigned to '{$item['slug']}'."
);
}
DB::table('variant_values')->insert([
'variant_id' => $variantId,
'item_attribute_id' => $itemAttributeId,
'value' => $value,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->attachImages($catalogItemId, $variantId, $variant['images']);
}
}
DB::table('featured_groups')->insert([
'tenant_code' => self::TENANT_CODE,
'code' => self::FEATURED_GROUP_CODE,
'source_type' => 'all',
'category_id' => null,
'product_layout' => 'column_with_image',
'group_layout' => 'paginated',
'group_name' => 'Productos',
'group_order' => 1,
]);
});
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($this->storedPaths);
throw $throwable;
}
}
public function down(): void
{
$manifest = $this->manifest();
$catalogItemIds = DB::table('catalog_items')
->where('tenant_code', self::TENANT_CODE)
->whereIn('slug', array_column($manifest['items'], 'slug'))
->pluck('id');
$variantInventoryIds = DB::table('variantes')
->whereIn('catalog_item_id', $catalogItemIds)
->pluck('inventory_id');
$itemInventoryIds = DB::table('catalog_items')
->whereIn('id', $catalogItemIds)
->whereNotNull('inventory_id')
->pluck('inventory_id');
$attachmentIds = DB::table('catalog_items_attachments')
->whereIn('catalog_item_id', $catalogItemIds)
->pluck('attachment_id')
->unique()
->values();
$storedPaths = DB::table('attachments')
->whereIn('id', $attachmentIds)
->where('path', 'like', self::STORAGE_PREFIX.'%')
->pluck('path')
->all();
DB::transaction(function () use (
$catalogItemIds,
$variantInventoryIds,
$itemInventoryIds,
$attachmentIds,
): void {
DB::table('featured_groups')
->where('tenant_code', self::TENANT_CODE)
->where('code', self::FEATURED_GROUP_CODE)
->delete();
DB::table('catalog_items')->whereIn('id', $catalogItemIds)->delete();
DB::table('inventories')
->whereIn('id', $variantInventoryIds->merge($itemInventoryIds)->unique())
->delete();
DB::table('attachments')->whereIn('id', $attachmentIds)->delete();
DB::table('brands')
->where('tenant_codigo', self::TENANT_CODE)
->where('descripcion', self::BRAND_MARKER)
->whereNotExists(fn ($query) => $query
->selectRaw('1')
->from('catalog_items')
->whereColumn('catalog_items.brand_id', 'brands.id'))
->delete();
});
Storage::disk('s3')->delete($storedPaths);
}
/** @return array<string, mixed> */
private function manifest(): array
{
$path = database_path(self::MANIFEST);
if (! is_file($path)) {
throw new RuntimeException("Mutual SMEP catalog manifest not found: {$path}");
}
$manifest = json_decode(
(string) file_get_contents($path),
true,
flags: JSON_THROW_ON_ERROR,
);
if (
($manifest['version'] ?? null) !== 1
|| ($manifest['tenant_code'] ?? null) !== self::TENANT_CODE
|| count($manifest['items'] ?? []) !== 142
|| ($manifest['image_count'] ?? null) !== 511
) {
throw new RuntimeException('Mutual SMEP catalog manifest is invalid.');
}
return $manifest;
}
/** @param array<string, int> $cache */
private function brandId(?string $name, array &$cache): ?int
{
if ($name === null) {
return null;
}
if (isset($cache[$name])) {
return $cache[$name];
}
$brandId = DB::table('brands')
->where('tenant_codigo', self::TENANT_CODE)
->where('nombre', $name)
->value('id');
if ($brandId === null) {
$brandId = DB::table('brands')->insertGetId([
'tenant_codigo' => self::TENANT_CODE,
'nombre' => $name,
'descripcion' => self::BRAND_MARKER,
'created_at' => now(),
'updated_at' => now(),
]);
}
return $cache[$name] = (int) $brandId;
}
/** @param array<string, int> $cache */
private function categoryId(string $parentName, string $name, array &$cache): int
{
$key = "{$parentName}|{$name}";
if (isset($cache[$key])) {
return $cache[$key];
}
$parentId = DB::table('categorias')
->where('tenant_code', self::TENANT_CODE)
->where('nombre', $parentName)
->whereNull('categoria_id')
->value('id');
$categoryId = $parentId === null
? null
: DB::table('categorias')
->where('tenant_code', self::TENANT_CODE)
->where('categoria_id', $parentId)
->where('nombre', $name)
->value('id');
if ($categoryId === null) {
throw new RuntimeException("Category '{$parentName} > {$name}' not found for Mutual SMEP.");
}
return $cache[$key] = (int) $categoryId;
}
/** @param array<string, int> $cache */
private function attributeId(string $code, array &$cache): int
{
if (isset($cache[$code])) {
return $cache[$code];
}
$attributeId = DB::table('attribute')
->where('tenant_codigo', self::TENANT_CODE)
->where('codigo', $code)
->value('id');
if ($attributeId === null) {
throw new RuntimeException("Attribute '{$code}' not found for Mutual SMEP.");
}
return $cache[$code] = (int) $attributeId;
}
private function createInventory(int $stock): int
{
return (int) DB::table('inventories')->insertGetId([
'sold_units' => 0,
'refunded_units' => 0,
'reserved_stock' => 0,
'real_stock' => $stock,
]);
}
/** @param list<string> $images */
private function attachImages(int $catalogItemId, ?int $variantId, array $images): void
{
foreach ($images as $order => $relativePath) {
$sourcePath = public_path($relativePath);
if (! is_file($sourcePath)) {
throw new RuntimeException("Catalog image not found: {$sourcePath}");
}
$contents = file_get_contents($sourcePath);
if ($contents === false) {
throw new RuntimeException("Catalog image could not be read: {$sourcePath}");
}
$catalogRelativePath = Str::after(
str_replace('\\', '/', $relativePath),
'images/tennants/mutual_smep/catalog/',
);
$storedPath = self::STORAGE_PREFIX.$catalogRelativePath;
if (! Storage::disk('s3')->put($storedPath, $contents)) {
throw new RuntimeException("Catalog image could not be stored: {$storedPath}");
}
$this->storedPaths[] = $storedPath;
$attachmentId = DB::table('attachments')->insertGetId([
'key' => (string) Str::uuid(),
'path' => $storedPath,
'filename' => basename($relativePath),
'type' => 'image',
'mime_type' => 'image/webp',
'extension' => 'webp',
'size' => strlen($contents),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('catalog_items_attachments')->insert([
'catalog_item_id' => $catalogItemId,
'variant_id' => $variantId,
'attachment_id' => $attachmentId,
'orden' => $order,
'is_enabled' => true,
]);
}
}
};

View File

@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
$sourceRoot = 'C:\\Users\\ncoronel\\Documents\\catalogo_smep_limpio';
$projectRoot = dirname(__DIR__, 2);
$assetRoot = $projectRoot.'/public/images/tennants/mutual_smep/catalog';
$manifestPath = $projectRoot.'/database/data/mutual_smep_catalog_v1.json';
$defaultStock = 10;
if (! is_dir($sourceRoot)) {
throw new RuntimeException("Source catalog not found: {$sourceRoot}");
}
/** @return array{0: string, 1: string} */
function catalogDestination(string $source, string $name): array
{
return match ($source) {
'acolchados-frazadas-y-edredon' => preg_match('/^(Frazada|Manta)/iu', $name)
? ['Dormitorio y Blanco', 'Frazadas y Mantas']
: ['Dormitorio y Blanco', 'Acolchados y Edredones'],
'acolchados-y-sabanas-infantil' => ['Dormitorio y Blanco', 'Infantil y Cuna'],
'base-sommier-1-pl-0-80x1-90', 'base-sommier-2-pl-1-40x1-90' => ['Dormitorio y Blanco', 'Bases y Sommiers'],
'colchon-1-plaza-0-80x1-90', 'colchon-2-plazas-1-40x1-90' => str_starts_with(mb_strtolower($name), 'soporte')
? ['Tecnología', 'Soportes para TV']
: ['Dormitorio y Blanco', 'Colchones'],
'aires-acondicionados' => ['Climatización', 'Aires Acondicionados'],
'calefaccion-con-salida-al-ext' => ['Climatización', 'Calefactores a Gas con Salida'],
'calefactor-sin-salida-al-ext' => ['Climatización', 'Calefactores a Gas sin Salida'],
'calefaccion-electrica' => ['Climatización', 'Calefacción Eléctrica'],
'calefones-y-termotanques' => ['Electrodomésticos', 'Calefones y Termotanques'],
'heladeras' => ['Electrodomésticos', 'Heladeras'],
'lavarropas-automaticos' => ['Electrodomésticos', 'Lavarropas y Secarropas'],
'cocinas' => ['Electrodomésticos', 'Cocinas'],
'purificador-de-aire' => ['Electrodomésticos', 'Purificadores y Extractores de Cocina'],
'microondas-y-hornos-electricos' => str_starts_with(mb_strtolower($name), 'horno')
? ['Electrodomésticos', 'Hornos Eléctricos']
: ['Electrodomésticos', 'Microondas'],
'electrodomesticos-pequenos' => smallApplianceCategory($name),
'celulares' => ['Tecnología', 'Celulares'],
'notebooks-y-tablets' => ['Tecnología', 'Notebooks'],
'smart-tv' => str_starts_with(mb_strtolower($name), 'soporte')
? ['Tecnología', 'Soportes para TV']
: ['Tecnología', 'Smart TV'],
'tecnologia' => preg_match('/^(Mouse|Teclado)/iu', $name)
? ['Tecnología', 'Accesorios de Computación']
: ['Tecnología', 'Audio'],
'bicicletas' => preg_match('/\bR16\b/iu', $name)
? ['Bicicletas y Aire Libre', 'Bicicletas Infantiles']
: ['Bicicletas y Aire Libre', 'Bicicletas para Adultos'],
'piletas' => ['Bicicletas y Aire Libre', 'Piletas'],
'bazar' => ['Bazar', 'Termos'],
default => throw new RuntimeException("Unmapped source category: {$source}"),
};
}
/** @return array{0: string, 1: string} */
function smallApplianceCategory(string $name): array
{
return match (true) {
preg_match('/^(Cafetera|Pava eléctrica|Tostadora)/iu', $name) === 1 => ['Electrodomésticos', 'Café y Desayuno'],
preg_match('/^(Freidora|Waflera|Pochoclera)/iu', $name) === 1 => ['Electrodomésticos', 'Freidoras y Cocción'],
preg_match('/^(Secador|Cortacabello|Cortabarba)/iu', $name) === 1 => ['Electrodomésticos', 'Cuidado Personal'],
preg_match('/^(Lustraspiradora|Plancha)/iu', $name) === 1 => ['Electrodomésticos', 'Limpieza y Planchado'],
default => ['Electrodomésticos', 'Preparación de Alimentos'],
};
}
function productBrand(string $name): ?string
{
if (preg_match('/\bJC\b/u', $name)) {
return 'Jean Cartier';
}
if (mb_stripos($name, 'Liliana') !== false) {
return 'Liliana';
}
foreach ([
'Rosario Central', 'Jean Cartier', 'King Koil', 'Fire Bird', 'Xtrike Me',
'Whitenblack', 'Electrolux', 'Kavanagh', 'Pelopincho', 'Suavegom',
'Motorola', 'Samsung', 'Xiaomi', 'Moulinex', 'Peabody', 'Philips',
'Florencia', 'Kohinoor', 'Inducol', 'Longvie', 'Stanley', 'Noblex',
'Moonki', 'Lenovo', 'Piero', 'Eskabe', 'Oster', 'Drean', 'Gafa', 'Atma',
'Spar', 'Stark', 'Venzo', 'Havit', 'Acer', 'ASUS', 'MSI', 'Philco',
'Gama', 'GIGO', 'Ross', 'Nakan', 'TCL', 'BGH', 'JBL', 'TST', 'GBS', 'BLU',
] as $brand) {
if (mb_stripos($name, $brand) !== false) {
return $brand;
}
}
return null;
}
/** @return array{key: string, name: string, values: array<string, string>}|null */
function variantGroup(string $name): ?array
{
$measure = bedMeasureValue($name);
$groups = [
'/(?:Kavanagh.*[Ss][ií]mil plumón reversible|[Ss][ií]mil plumón reversible.*Kavanagh)/iu' => ['acolchado-kavanagh-simil-plumon-reversible', 'Acolchado Kavanagh Símil Plumón Reversible'],
'/Edredón.*Lisboa/iu' => ['edredon-lisboa-jean-cartier', 'Edredón Lisboa Jean Cartier'],
'/Sense Dúo Bitono con corderito/iu' => ['acolchado-sense-duo-bitono-corderito', 'Acolchado Sense Dúo Bitono con Corderito'],
'/Frazada polar.*Kavanagh Premium Soft/iu' => ['frazada-kavanagh-premium-soft', 'Frazada Kavanagh Premium Soft'],
'/Kit Edredón.*Alaska.*corderito/iu' => ['kit-edredon-alaska-corderito', 'Kit Edredón Alaska con Corderito'],
'/Colch[oó]n Inducol Constanza/iu' => ['colchon-inducol-constanza', 'Colchón Inducol Constanza'],
];
foreach ($groups as $pattern => [$key, $itemName]) {
if ($measure !== null && preg_match($pattern, $name)) {
return [
'key' => $key,
'name' => $itemName,
'values' => ['medida_cama' => $measure],
];
}
}
if (preg_match('/Motorola G15\s+4\/(256|512)\s*GB/iu', $name, $match)) {
return [
'key' => 'motorola-g15',
'name' => 'Motorola G15',
'values' => [
'memoria_ram' => '4 GB',
'almacenamiento' => "{$match[1]} GB",
],
];
}
return null;
}
function bedMeasureValue(string $name): ?string
{
return match (true) {
preg_match('/\bcuna\b/iu', $name) === 1 => 'Cuna',
preg_match('/\bKing\b/iu', $name) === 1 => 'King',
preg_match('/\bQueen\b/iu', $name) === 1 => 'Queen',
preg_match('/2\s*1\/2\s*pl/iu', $name) === 1 => '2 1/2 Plazas',
preg_match('/1\s*1\/2\s*pl/iu', $name) === 1 => '1 1/2 Plazas',
preg_match('/1[,.]40\s*x?\s*1[,.]90|140x190|140x24/iu', $name) === 1 => '2 Plazas',
preg_match('/0[,.](80|90)\s*x?\s*1[,.]90|080x|090x|0,8x/iu', $name) === 1 => '1 Plaza',
default => null,
};
}
function compressCatalogImage(string $source, string $destination): void
{
$sourceImage = @imagecreatefromjpeg($source);
if ($sourceImage === false) {
throw new RuntimeException("Unable to read JPEG image: {$source}");
}
$width = imagesx($sourceImage);
$height = imagesy($sourceImage);
$scale = min(1, 1200 / max($width, $height));
$targetWidth = max(1, (int) round($width * $scale));
$targetHeight = max(1, (int) round($height * $scale));
$targetImage = imagecreatetruecolor($targetWidth, $targetHeight);
if ($targetImage === false) {
throw new RuntimeException("Unable to allocate image: {$source}");
}
imagefill($targetImage, 0, 0, imagecolorallocate($targetImage, 255, 255, 255));
imagecopyresampled(
$targetImage,
$sourceImage,
0,
0,
0,
0,
$targetWidth,
$targetHeight,
$width,
$height,
);
$destinationDirectory = dirname($destination);
if (! is_dir($destinationDirectory) && ! mkdir($destinationDirectory, 0777, true) && ! is_dir($destinationDirectory)) {
throw new RuntimeException("Unable to create directory: {$destinationDirectory}");
}
if (! imagewebp($targetImage, $destination, 82)) {
throw new RuntimeException("Unable to write WebP image: {$destination}");
}
}
$rawProducts = [];
$jsonFiles = glob($sourceRoot.'\\*\\*.json') ?: [];
sort($jsonFiles);
foreach ($jsonFiles as $jsonFile) {
$data = json_decode((string) file_get_contents($jsonFile), true, flags: JSON_THROW_ON_ERROR);
$sourceCategory = (string) $data['categoria']['slug'];
foreach ($data['productos'] as $product) {
[$parentCategory, $category] = catalogDestination($sourceCategory, $product['nombre']);
$images = [];
foreach ($product['imagenes'] as $relativeImage) {
$sourceImage = dirname($jsonFile).'/'.str_replace('/', DIRECTORY_SEPARATOR, $relativeImage);
$basename = pathinfo($relativeImage, PATHINFO_FILENAME).'.webp';
$publicRelativePath = "images/tennants/mutual_smep/catalog/{$sourceCategory}/{$basename}";
$destination = $projectRoot.'/public/'.$publicRelativePath;
compressCatalogImage($sourceImage, $destination);
$images[] = $publicRelativePath;
}
$rawProducts[] = [
'source_slug' => $product['slug'],
'name' => $product['nombre'],
'description' => $product['descripcion'] ?? null,
'price' => (float) $product['precio']['importe'],
'brand' => productBrand($product['nombre']),
'parent_category' => $parentCategory,
'category' => $category,
'images' => $images,
'variant_group' => variantGroup($product['nombre']),
];
}
}
$items = [];
foreach ($rawProducts as $product) {
$group = $product['variant_group'];
if ($group === null) {
$items[$product['source_slug']] = [
'slug' => $product['source_slug'],
'name' => $product['name'],
'description' => $product['description'],
'price' => $product['price'],
'stock' => $defaultStock,
'brand' => $product['brand'],
'parent_category' => $product['parent_category'],
'category' => $product['category'],
'images' => $product['images'],
'attribute_codes' => [],
'variants' => [],
];
continue;
}
$key = $group['key'];
if (! isset($items[$key])) {
$items[$key] = [
'slug' => $key,
'name' => $group['name'],
'description' => $product['description'],
'price' => $product['price'],
'stock' => null,
'brand' => $product['brand'],
'parent_category' => $product['parent_category'],
'category' => $product['category'],
'images' => [],
'attribute_codes' => [],
'variants' => [],
];
}
$items[$key]['price'] = min($items[$key]['price'], $product['price']);
$items[$key]['description'] ??= $product['description'];
$items[$key]['brand'] ??= $product['brand'];
$items[$key]['attribute_codes'] = array_values(array_unique([
...$items[$key]['attribute_codes'],
...array_keys($group['values']),
]));
$items[$key]['variants'][] = [
'source_slug' => $product['source_slug'],
'description' => $product['description'],
'price' => $product['price'],
'stock' => $defaultStock,
'values' => $group['values'],
'images' => $product['images'],
];
}
ksort($items);
$manifest = [
'version' => 1,
'tenant_code' => 'mutual_smep',
'default_stock' => $defaultStock,
'source_product_count' => count($rawProducts),
'catalog_item_count' => count($items),
'image_count' => array_sum(array_map(fn (array $product): int => count($product['images']), $rawProducts)),
'items' => array_values($items),
];
$manifestDirectory = dirname($manifestPath);
if (! is_dir($manifestDirectory) && ! mkdir($manifestDirectory, 0777, true) && ! is_dir($manifestDirectory)) {
throw new RuntimeException("Unable to create directory: {$manifestDirectory}");
}
file_put_contents(
$manifestPath,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL,
);
echo json_encode([
'manifest' => $manifestPath,
'source_products' => count($rawProducts),
'catalog_items' => count($items),
'images' => $manifest['image_count'],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), PHP_EOL;