}> */ private const PRODUCT_CATALOG = [ 'buzo_blanco_adidas' => ['price' => 89999, 'stock' => 12], 'buzo_gris_nike' => ['price' => 94999, 'stock' => 10], 'buzo_gris_under_armour' => ['price' => 96999, 'stock' => 8], 'buzo_negro_adidas' => ['price' => 91999, 'stock' => 9], 'camiseta_rosario_central' => ['price' => 69999, 'stock' => 20], 'pantalon' => [ 'price' => 55999, 'variant_groups' => [ 'pantalon_blanco' => ['stock' => 14], 'pantalon_negro' => ['stock' => 14], 'pantalon_azul' => ['stock' => 0], ], ], 'remera' => [ 'price' => 35999, 'variant_groups' => [ 'remera_blanca' => ['stock' => 22], 'remera_negra' => ['stock' => 22], 'remera_azul' => ['stock' => 0], ], ], 'zapatillas_lecoq' => ['price' => 109999, 'stock' => 6], 'zapatillas_topper' => ['price' => 104999, 'stock' => 7], 'pelota_mundial' => ['price' => 45000, 'stock' => 15], ]; /** * @var array */ private const CLOTHING_SIZES = ['S', 'M', 'L', 'XL']; /** * @var array */ private const SHOE_SIZES = ['38', '40', '42', '44']; /** * @var array */ private const IGNORED_FILES = [ 'istockphoto-1675347112-2048x2048.jpg', ]; public function __construct(private readonly CatalogService $catalogService) {} /** * Run the database seeds. */ public function run(): void { $tenant = Tenant::query()->where('codigo', 'sonder')->first(); if (! $tenant instanceof Tenant) { throw new RuntimeException("Tenant 'sonder' not found."); } $catalogPath = public_path('images/catalog'); if (! is_dir($catalogPath)) { throw new RuntimeException("Catalog directory not found at path: {$catalogPath}"); } $files = collect(File::files($catalogPath)) ->filter(fn (\SplFileInfo $file) => ! in_array($file->getFilename(), self::IGNORED_FILES, true)) ->values(); if ($files->isEmpty()) { throw new RuntimeException("Catalog directory is empty at path: {$catalogPath}"); } $groupedFiles = $files->groupBy(function (\SplFileInfo $file): string { return $this->normalizeGroupKey($file->getBasename('.'.$file->getExtension())); }); $productsToSeed = collect(self::PRODUCT_CATALOG) ->map(function (array $catalogConfig, string $productKey) use ($groupedFiles): array { if (! isset($catalogConfig['variant_groups']) && ! array_key_exists('stock', $catalogConfig)) { throw new RuntimeException("Missing stock configuration for catalog product '{$productKey}'."); } $variantGroups = collect($catalogConfig['variant_groups'] ?? [ $productKey => ['stock' => $catalogConfig['stock']], ]) ->map(function (array $variantConfig, string $groupKey) use ($groupedFiles): array { $filesForGroup = $groupedFiles->get($groupKey); if ($filesForGroup === null || $filesForGroup->isEmpty()) { throw new RuntimeException("No images found for catalog group '{$groupKey}'."); } return [ 'group_key' => $groupKey, 'stock' => $variantConfig['stock'], 'files' => $filesForGroup->all(), 'metadata' => $this->parseGroupKey($groupKey), ]; }) ->values() ->all(); return [ 'pricing' => ['price' => $catalogConfig['price']], 'metadata' => $this->buildProductMetadata($productKey, $variantGroups), 'variant_groups' => $variantGroups, ]; }) ->values(); $productSlugsToDelete = $productsToSeed ->flatMap(fn (array $catalogProduct): array => [ $catalogProduct['metadata']['product_slug'], ...collect($catalogProduct['variant_groups']) ->pluck('group_key') ->all(), ]) ->unique() ->values(); $existingProducts = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->whereIn('slug', $productSlugsToDelete) ->with('variants:id,catalog_item_id,inventory_id') ->get(); foreach ($existingProducts as $existingProduct) { $this->catalogService->delete($existingProduct); } foreach ($productsToSeed as $catalogProduct) { $this->seedProduct( $tenant, $catalogProduct['metadata'], $catalogProduct['pricing'], $catalogProduct['variant_groups'], ); } $this->seedFeaturedProducts($tenant); } private function seedFeaturedProducts(Tenant $tenant): void { FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete(); FeaturedGroup::query()->create([ 'tenant_code' => $tenant->codigo, 'source_type' => FeaturedGroupSource::All, 'product_layout' => ProductLayout::ColumnWithImage, 'group_layout' => GroupLayout::Paginated, 'group_name' => 'Productos', 'group_order' => 2, ]); $carouselGroup = FeaturedGroup::query()->create([ 'tenant_code' => $tenant->codigo, 'source_type' => FeaturedGroupSource::Manual, 'product_layout' => ProductLayout::ColumnWithImage, 'group_layout' => GroupLayout::Carousel, 'group_name' => 'Productos destacados', 'group_order' => 1, ]); $randomItems = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->inRandomOrder() ->limit(5) ->get('id'); $carouselGroup->featuredItems()->createMany( $randomItems->values()->map( fn (CatalogItem $item, int $order): array => [ 'catalog_item_id' => $item->id, 'order' => $order, ] )->all() ); } /** * @param array{attribute_codes: array, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string} $metadata * @param array{price: int} $pricing * @param array, group_key: string, metadata: array{attribute_codes: array, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string}, stock: int}> $variantGroups */ private function seedProduct(Tenant $tenant, array $metadata, array $pricing, array $variantGroups): void { $brand = null; if ($metadata['brand_name'] !== null) { $brand = Brand::query() ->where('tenant_codigo', $tenant->codigo) ->where('nombre', $metadata['brand_name']) ->first(); if (! $brand instanceof Brand) { throw new RuntimeException("Brand '{$metadata['brand_name']}' not found for tenant '{$tenant->codigo}'."); } } $category = Category::query() ->where('nombre', $metadata['category_name']) ->where('tenant_code', $tenant->codigo) ->first(); if (! $category instanceof Category) { throw new RuntimeException("Category '{$metadata['category_name']}' not found for tenant '{$tenant->codigo}'."); } $attributeCodes = Attribute::query() ->where('tenant_codigo', $tenant->codigo) ->whereIn('codigo', $metadata['attribute_codes']) ->pluck('codigo'); if ($attributeCodes->count() !== count($metadata['attribute_codes'])) { throw new RuntimeException("Missing attributes for product '{$metadata['product_slug']}'."); } $sizes = $metadata['type'] === 'zapatillas' ? self::SHOE_SIZES : self::CLOTHING_SIZES; $variants = []; $itemImages = []; $directStock = 0; foreach ($variantGroups as $variantGroup) { $images = array_map( fn (\SplFileInfo $file): UploadedFile => $this->createUploadedFile($file->getPathname()), $variantGroup['files'] ); if ($attributeCodes->isEmpty()) { $directStock = $variantGroup['stock']; $itemImages = [...$itemImages, ...$images]; continue; } foreach ($sizes as $size) { $values = []; if ($attributeCodes->contains('color') && $variantGroup['metadata']['color'] !== null) { $values['color'] = $variantGroup['metadata']['color']; } $sizeAttributeCode = $metadata['type'] === 'zapatillas' ? 'talle_numerico' : 'talle'; if (! $attributeCodes->contains($sizeAttributeCode)) { throw new RuntimeException("Missing product attribute for size on product '{$metadata['product_slug']}'."); } $values[$sizeAttributeCode] = $size; // Override stock for specific variants as requested by the user $stock = $variantGroup['stock']; $productType = $metadata['type']; $color = $variantGroup['metadata']['color']; if ($productType === 'remera' && $color === 'Blanco' && $size === 'L') { $stock = 0; } elseif ($productType === 'remera' && $color === 'Negro' && $size === 'S') { $stock = 0; } elseif ($productType === 'pantalon' && $color === 'Negro' && $size === 'XL') { $stock = 0; } elseif ($productType === 'pantalon' && $color === 'Blanco' && $size === 'M') { $stock = 0; } $variants[] = [ 'real_stock' => $stock, 'values' => $values, 'images' => $images, ]; } } $this->catalogService->create([ 'tenant_code' => $tenant->codigo, 'category_id' => $category->id, 'brand_id' => $brand?->id, 'slug' => $metadata['product_slug'], 'nombre' => $metadata['product_name'], 'descripcion' => $metadata['description'], 'precio' => $pricing['price'], 'inventory_policy' => InventoryPolicy::Tracked->value, ...($attributeCodes->isEmpty() ? ['real_stock' => $directStock, 'images' => $itemImages] : [ 'attribute_codes' => array_values($metadata['attribute_codes']), 'variants' => $variants, ]), ]); } /** * @param array, group_key: string, metadata: array{attribute_codes: array, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string}, stock: int}> $variantGroups * @return array{attribute_codes: array, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string} */ private function buildProductMetadata(string $productKey, array $variantGroups): array { $metadata = $this->parseGroupKey($productKey); $hasColorVariants = collect($variantGroups) ->contains(fn (array $variantGroup): bool => $variantGroup['metadata']['color'] !== null); if ($hasColorVariants && ! in_array('color', $metadata['attribute_codes'], true)) { array_unshift($metadata['attribute_codes'], 'color'); } if ($hasColorVariants && $metadata['color'] === null) { $category = Str::lower($metadata['category_name']); $brandText = $metadata['brand_name'] !== null ? " {$metadata['brand_name']}" : ''; $metadata['description'] = "Producto seed de {$category}{$brandText} con variantes por color y talle."; } return $metadata; } /** * @return array{attribute_codes: array, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string} */ private function parseGroupKey(string $groupKey): array { if ($groupKey === 'camiseta_rosario_central') { return [ 'type' => 'camiseta', 'category_name' => 'Remeras', 'brand_name' => 'Rosario Central', 'color' => null, 'attribute_codes' => ['talle'], 'product_slug' => $groupKey, 'product_name' => 'Camiseta Rosario Central', 'description' => 'Camiseta Rosario Central con variantes por talle.', ]; } if ($groupKey === 'pelota_mundial') { return [ 'type' => 'pelota', 'category_name' => 'Accesorios', 'brand_name' => 'Adidas', 'color' => null, 'attribute_codes' => [], 'product_slug' => $groupKey, 'product_name' => 'Pelota Mundial 2026', 'description' => 'Pelota oficial del mundial 2026 sin variantes.', ]; } $segments = explode('_', $groupKey); $type = array_shift($segments); if (! is_string($type) || $type === '') { throw new RuntimeException("Unable to infer product type from '{$groupKey}'."); } $color = null; $brandSegments = $segments; if (isset($segments[0]) && in_array($segments[0], ['blanco', 'blanca', 'negro', 'negra', 'gris', 'azul'], true)) { $color = $this->normalizeColor($segments[0]); $brandSegments = array_slice($segments, 1); } $brandKey = implode('_', $brandSegments); $brandName = $brandKey !== '' ? $this->normalizeBrandName($brandKey) : null; $categoryName = $this->resolveCategoryName($type); $productName = $this->buildProductName($type, $color, $brandName); $description = $this->buildDescription($type, $color, $brandName); $attributeCodes = $type === 'zapatillas' ? ['talle_numerico'] : ['talle']; if ($color !== null) { array_unshift($attributeCodes, 'color'); } return [ 'type' => $type, 'category_name' => $categoryName, 'brand_name' => $brandName, 'color' => $color, 'attribute_codes' => $attributeCodes, 'product_slug' => $groupKey, 'product_name' => $productName, 'description' => $description, ]; } private function normalizeGroupKey(string $basename): string { $normalized = Str::of($basename) ->lower() ->replace('-', '_') ->replace(' ', '_') ->replaceMatches('/_\d+$/', '') ->value(); return str_replace('nije', 'nike', $normalized); } private function normalizeColor(string $color): string { return match ($color) { 'blanco', 'blanca' => 'Blanco', 'negro', 'negra' => 'Negro', 'gris' => 'Gris', 'azul' => 'Azul', default => throw new RuntimeException("Unsupported color '{$color}'."), }; } private function normalizeBrandName(string $brandKey): string { return match ($brandKey) { 'adidas' => 'Adidas', 'nike' => 'Nike', 'under_armour' => 'Under Armour', 'topper' => 'Topper', 'lecoq' => 'Le Coq', default => throw new RuntimeException("Unsupported brand '{$brandKey}'."), }; } private function resolveCategoryName(string $type): string { return match ($type) { 'buzo' => 'Buzos', 'remera', 'camiseta' => 'Remeras', 'pantalon' => 'Pantalones', 'zapatillas' => 'Zapatillas', default => throw new RuntimeException("Unsupported product type '{$type}'."), }; } private function buildProductName(string $type, ?string $color, ?string $brandName): string { $typeLabel = match ($type) { 'buzo' => 'Buzo', 'remera' => 'Remera', 'camiseta' => 'Camiseta', 'pantalon' => 'Pantalon', 'zapatillas' => 'Zapatillas', default => Str::headline($type), }; return trim(collect([$typeLabel, $color, $brandName])->filter()->implode(' ')); } private function buildDescription(string $type, ?string $color, ?string $brandName): string { $category = Str::lower($this->resolveCategoryName($type)); $colorText = $color !== null ? " color {$color}" : ''; $brandText = $brandName !== null ? " {$brandName}" : ''; return "Producto seed de {$category}{$brandText}{$colorText} con variantes por talle."; } private function createUploadedFile(string $path): UploadedFile { $mimeType = mime_content_type($path); return new UploadedFile( $path, basename($path), is_string($mimeType) ? $mimeType : null, null, true ); } }