shopit-back/database/seeders/ProductCatalogFromImagesSee...

468 lines
18 KiB
PHP

<?php
namespace Database\Seeders;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use RuntimeException;
class ProductCatalogFromImagesSeeder extends Seeder
{
/**
* @var array<string, array{price: int, stock?: int, variant_groups?: array<string, array{stock: int}>}>
*/
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<int, string>
*/
private const CLOTHING_SIZES = ['S', 'M', 'L', 'XL'];
/**
* @var array<int, string>
*/
private const SHOE_SIZES = ['38', '40', '42', '44'];
/**
* @var array<int, string>
*/
private const IGNORED_FILES = [
'istockphoto-1675347112-2048x2048.jpg',
];
public function __construct(private readonly ProductService $productService)
{
}
/**
* 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 = storage_path('public/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 = Product::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('slug', $productSlugsToDelete)
->with(['attachments', 'variants.attachments'])
->get();
foreach ($existingProducts as $existingProduct) {
$this->productService->delete($existingProduct);
}
foreach ($productsToSeed as $catalogProduct) {
$this->seedProduct(
$tenant,
$catalogProduct['metadata'],
$catalogProduct['pricing'],
$catalogProduct['variant_groups'],
);
}
}
/**
* @param array{attribute_codes: array<int, string>, 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<int, array{files: array<int, \SplFileInfo>, group_key: string, metadata: array{attribute_codes: array<int, string>, 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'])
->whereNull('tenant_code')
->first();
if (! $category instanceof Category) {
throw new RuntimeException("Category '{$metadata['category_name']}' not found.");
}
$attributeIds = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', $metadata['attribute_codes'])
->pluck('id', 'codigo');
if ($attributeIds->count() !== count($metadata['attribute_codes'])) {
throw new RuntimeException("Missing attributes for product '{$metadata['product_slug']}'.");
}
$product = $this->productService->create($tenant, [
'categoria_id' => $category->id,
'brand_id' => $brand?->id,
'slug' => $metadata['product_slug'],
'nombre' => $metadata['product_name'],
'descripcion' => $metadata['description'],
'precio' => $pricing['price'],
'attribute_ids' => array_values($attributeIds->all()),
]);
$productAttributeIds = DB::table('products_attributes')
->where('product_id', $product->id)
->pluck('id', 'attribute_id');
$sizes = $metadata['type'] === 'zapatillas'
? self::SHOE_SIZES
: self::CLOTHING_SIZES;
foreach ($variantGroups as $variantGroup) {
$images = array_map(
fn (\SplFileInfo $file): UploadedFile => $this->createUploadedFile($file->getPathname()),
$variantGroup['files']
);
if ($attributeIds->isEmpty()) {
$this->productService->createVariant($product, [
'stock' => $variantGroup['stock'],
'definitions' => [],
'images' => $images,
]);
continue;
}
foreach ($sizes as $size) {
$definitions = [];
if (isset($attributeIds['color']) && $variantGroup['metadata']['color'] !== null) {
$colorProductAttributeId = $productAttributeIds[$attributeIds['color']] ?? null;
if ($colorProductAttributeId === null) {
throw new RuntimeException("Missing product attribute for color on product '{$metadata['product_slug']}'.");
}
$definitions[] = [
'products_attribute_id' => $colorProductAttributeId,
'value' => $variantGroup['metadata']['color'],
];
}
$sizeAttributeCode = $metadata['type'] === 'zapatillas'
? 'talle_numerico'
: 'talle';
$sizeProductAttributeId = $productAttributeIds[$attributeIds[$sizeAttributeCode]] ?? null;
if ($sizeProductAttributeId === null) {
throw new RuntimeException("Missing product attribute for size on product '{$metadata['product_slug']}'.");
}
$definitions[] = [
'products_attribute_id' => $sizeProductAttributeId,
'value' => $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;
}
$this->productService->createVariant($product, [
'stock' => $stock,
'definitions' => $definitions,
'images' => $images,
]);
}
}
}
/**
* @param array<int, array{files: array<int, \SplFileInfo>, group_key: string, metadata: array{attribute_codes: array<int, string>, 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<int, string>, 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<int, string>, 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
);
}
}