feat: add brand and category relationships to products, update requests and resources, and seed product catalog from images
@@ -20,7 +20,7 @@ class ProductController extends Controller
|
|||||||
return ProductResource::collection(
|
return ProductResource::collection(
|
||||||
Product::query()
|
Product::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->with('attachments')
|
->with(['attachments', 'brand', 'category'])
|
||||||
->latest()
|
->latest()
|
||||||
->paginateFromRequest()
|
->paginateFromRequest()
|
||||||
)->response();
|
)->response();
|
||||||
@@ -37,7 +37,13 @@ class ProductController extends Controller
|
|||||||
{
|
{
|
||||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||||
|
|
||||||
return ProductResource::make($producto->load(['attachments', 'variants.definitions.attribute', 'variants.attachments']));
|
return ProductResource::make($producto->load([
|
||||||
|
'attachments',
|
||||||
|
'brand',
|
||||||
|
'category',
|
||||||
|
'variants.definitions.attribute',
|
||||||
|
'variants.attachments',
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
|
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Catalog\Models\Brand;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -14,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
'categoria_id',
|
'categoria_id',
|
||||||
|
'brand_id',
|
||||||
'slug',
|
'slug',
|
||||||
'nombre',
|
'nombre',
|
||||||
'descripcion',
|
'descripcion',
|
||||||
@@ -29,6 +32,7 @@ class Product extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'categoria_id' => 'integer',
|
'categoria_id' => 'integer',
|
||||||
|
'brand_id' => 'integer',
|
||||||
'precio' => 'decimal:2',
|
'precio' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -41,6 +45,22 @@ class Product extends Model
|
|||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Category, $this>
|
||||||
|
*/
|
||||||
|
public function category(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class, 'categoria_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Brand, $this>
|
||||||
|
*/
|
||||||
|
public function brand(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Brand::class, 'brand_id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasMany<ProductVariant, $this>
|
* @return HasMany<ProductVariant, $this>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ class StoreProductRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'categoria_id' => ['required', 'integer'],
|
'categoria_id' => ['required', 'integer'],
|
||||||
|
'brand_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('brands', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||||
|
),
|
||||||
|
],
|
||||||
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
|
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
'descripcion' => ['nullable', 'string'],
|
'descripcion' => ['nullable', 'string'],
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ class UpdateProductRequest extends FormRequest
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'categoria_id' => ['required', 'integer'],
|
'categoria_id' => ['required', 'integer'],
|
||||||
|
'brand_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('brands', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||||
|
),
|
||||||
|
],
|
||||||
'slug' => [
|
'slug' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Resources;
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Resources\BrandResource;
|
||||||
|
use App\Domains\Catalog\Resources\CategoryResource;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -19,10 +21,13 @@ class ProductResource extends JsonResource
|
|||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'tenant_codigo' => $this->tenant_codigo,
|
'tenant_codigo' => $this->tenant_codigo,
|
||||||
'categoria_id' => $this->categoria_id,
|
'categoria_id' => $this->categoria_id,
|
||||||
|
'brand_id' => $this->brand_id,
|
||||||
'slug' => $this->slug,
|
'slug' => $this->slug,
|
||||||
'nombre' => $this->nombre,
|
'nombre' => $this->nombre,
|
||||||
'descripcion' => $this->descripcion,
|
'descripcion' => $this->descripcion,
|
||||||
'precio' => $this->precio,
|
'precio' => $this->precio,
|
||||||
|
'category' => CategoryResource::make($this->whenLoaded('category')),
|
||||||
|
'brand' => BrandResource::make($this->whenLoaded('brand')),
|
||||||
'images' => $this->whenLoaded('attachments', fn () =>
|
'images' => $this->whenLoaded('attachments', fn () =>
|
||||||
$this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values()
|
$this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values()
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class ProductService
|
|||||||
$this->syncProductImages($product, $images);
|
$this->syncProductImages($product, $images);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $product->load(['attributes', 'attachments']);
|
return $product->load(['attributes', 'attachments', 'brand', 'category']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ class ProductService
|
|||||||
$this->syncProductImages($product, $images);
|
$this->syncProductImages($product, $images);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $product->load(['attributes', 'attachments']);
|
return $product->load(['attributes', 'attachments', 'brand', 'category']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +79,7 @@ class ProductService
|
|||||||
{
|
{
|
||||||
DB::transaction(function () use ($product) {
|
DB::transaction(function () use ($product) {
|
||||||
foreach ($product->variants as $variant) {
|
foreach ($product->variants as $variant) {
|
||||||
|
$this->deleteVariantAttachments($variant);
|
||||||
$product->deleteVariant($variant);
|
$product->deleteVariant($variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ class ProductService
|
|||||||
$variant = $product->createVariant($data);
|
$variant = $product->createVariant($data);
|
||||||
|
|
||||||
if (! empty($images)) {
|
if (! empty($images)) {
|
||||||
$this->syncImages($variant, $images);
|
$this->syncVariantImages($variant, $images);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $variant->load(['product', 'definitions.attribute', 'attachments']);
|
return $variant->load(['product', 'definitions.attribute', 'attachments']);
|
||||||
@@ -132,7 +133,7 @@ class ProductService
|
|||||||
$updatedVariant = $product->updateVariant($variant, $data);
|
$updatedVariant = $product->updateVariant($variant, $data);
|
||||||
|
|
||||||
if ($hasImages) {
|
if ($hasImages) {
|
||||||
$this->syncImages($updatedVariant, $images);
|
$this->syncVariantImages($updatedVariant, $images);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $updatedVariant->load(['product', 'definitions.attribute', 'attachments']);
|
return $updatedVariant->load(['product', 'definitions.attribute', 'attachments']);
|
||||||
@@ -147,6 +148,7 @@ class ProductService
|
|||||||
DB::transaction(function () use ($variant) {
|
DB::transaction(function () use ($variant) {
|
||||||
/** @var Product $product */
|
/** @var Product $product */
|
||||||
$product = $variant->product;
|
$product = $variant->product;
|
||||||
|
$this->deleteVariantAttachments($variant);
|
||||||
$product->deleteVariant($variant);
|
$product->deleteVariant($variant);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -183,14 +185,9 @@ class ProductService
|
|||||||
*
|
*
|
||||||
* @param array<int, UploadedFile|string> $images
|
* @param array<int, UploadedFile|string> $images
|
||||||
*/
|
*/
|
||||||
protected function syncImages(ProductVariant $variant, array $images): void
|
protected function syncVariantImages(ProductVariant $variant, array $images): void
|
||||||
{
|
{
|
||||||
// Detach pivot record and delete attachment from S3 and database
|
$this->deleteVariantAttachments($variant);
|
||||||
$existing = $variant->attachments()->get();
|
|
||||||
$variant->attachments()->detach();
|
|
||||||
foreach ($existing as $attachment) {
|
|
||||||
$this->attachmentService->delete($attachment);
|
|
||||||
}
|
|
||||||
|
|
||||||
$attachmentIds = [];
|
$attachmentIds = [];
|
||||||
|
|
||||||
@@ -205,7 +202,7 @@ class ProductService
|
|||||||
/**
|
/**
|
||||||
* Upload a list of image files/base64 strings and sync them to a product.
|
* Upload a list of image files/base64 strings and sync them to a product.
|
||||||
*
|
*
|
||||||
* Same logic as syncImages but for products without variants.
|
* Same logic as syncVariantImages but for products without variants.
|
||||||
*
|
*
|
||||||
* @param array<int, UploadedFile|string> $images
|
* @param array<int, UploadedFile|string> $images
|
||||||
*/
|
*/
|
||||||
@@ -228,6 +225,16 @@ class ProductService
|
|||||||
$product->attachments()->sync($attachmentIds);
|
$product->attachments()->sync($attachmentIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function deleteVariantAttachments(ProductVariant $variant): void
|
||||||
|
{
|
||||||
|
$existing = $variant->attachments()->get();
|
||||||
|
$variant->attachments()->detach();
|
||||||
|
|
||||||
|
foreach ($existing as $attachment) {
|
||||||
|
$this->attachmentService->delete($attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an attribute.
|
* Delete an attribute.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('productos', function (Blueprint $table) {
|
||||||
|
$table->foreignId('brand_id')
|
||||||
|
->nullable()
|
||||||
|
->after('categoria_id')
|
||||||
|
->constrained('brands')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('productos', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('brand_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
AttributeSeeder::class,
|
AttributeSeeder::class,
|
||||||
CategorySeeder::class,
|
CategorySeeder::class,
|
||||||
BrandSeeder::class,
|
BrandSeeder::class,
|
||||||
|
ProductCatalogFromImagesSeeder::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
354
database/seeders/ProductCatalogFromImagesSeeder.php
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
<?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\File;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ProductCatalogFromImagesSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, array{price: int, 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_blanco' => ['price' => 55999, 'stock' => 14],
|
||||||
|
'pantalon_negro' => ['price' => 57999, 'stock' => 14],
|
||||||
|
'remera_blanca' => ['price' => 35999, 'stock' => 22],
|
||||||
|
'remera_negra' => ['price' => 36999, 'stock' => 22],
|
||||||
|
'zapatillas_lecoq' => ['price' => 109999, 'stock' => 6],
|
||||||
|
'zapatillas_topper' => ['price' => 104999, 'stock' => 7],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 $pricing, string $groupKey) use ($groupedFiles, $tenant): array {
|
||||||
|
$filesForGroup = $groupedFiles->get($groupKey);
|
||||||
|
|
||||||
|
if ($filesForGroup === null || $filesForGroup->isEmpty()) {
|
||||||
|
throw new RuntimeException("No images found for catalog group '{$groupKey}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = $this->parseGroupKey($groupKey);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'group_key' => $groupKey,
|
||||||
|
'pricing' => $pricing,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'files' => $filesForGroup->all(),
|
||||||
|
'metadata' => $metadata,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$existingProducts = Product::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->whereIn('slug', $productsToSeed->pluck('metadata.product_slug'))
|
||||||
|
->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['files'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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, stock: int} $pricing
|
||||||
|
* @param array<int, \SplFileInfo> $files
|
||||||
|
*/
|
||||||
|
private function seedProduct(Tenant $tenant, array $metadata, array $pricing, array $files): 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()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sizes = $metadata['type'] === 'zapatillas'
|
||||||
|
? self::SHOE_SIZES
|
||||||
|
: self::CLOTHING_SIZES;
|
||||||
|
|
||||||
|
$images = array_map(
|
||||||
|
fn (\SplFileInfo $file): UploadedFile => $this->createUploadedFile($file->getPathname()),
|
||||||
|
$files
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($sizes as $size) {
|
||||||
|
$definitions = [];
|
||||||
|
|
||||||
|
if (isset($attributeIds['color']) && $metadata['color'] !== null) {
|
||||||
|
$definitions[] = [
|
||||||
|
'attribute_id' => $attributeIds['color'],
|
||||||
|
'value' => $metadata['color'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sizeAttributeCode = $metadata['type'] === 'zapatillas'
|
||||||
|
? 'talle_numerico'
|
||||||
|
: 'talle';
|
||||||
|
|
||||||
|
$definitions[] = [
|
||||||
|
'attribute_id' => $attributeIds[$sizeAttributeCode],
|
||||||
|
'value' => $size,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->productService->createVariant($product, [
|
||||||
|
'slug' => $metadata['product_slug'].'-'.Str::slug((string) $size),
|
||||||
|
'nombre' => $metadata['product_name'].' - Talle '.$size,
|
||||||
|
'stock' => $pricing['stock'],
|
||||||
|
'descripcion' => $metadata['description'],
|
||||||
|
'precio' => $pricing['price'],
|
||||||
|
'definitions' => $definitions,
|
||||||
|
'images' => $images,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$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'], 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',
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
storage/public/catalog/buzo_blanco_adidas.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
storage/public/catalog/buzo_blanco_adidas_1.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
storage/public/catalog/buzo_gris_nije.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
storage/public/catalog/buzo_gris_nike_1.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
storage/public/catalog/buzo_gris_nike_2.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
storage/public/catalog/buzo_gris_under_armour.jpg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
storage/public/catalog/buzo_negro_adidas.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
storage/public/catalog/buzo_negro_adidas_1.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
storage/public/catalog/camiseta_rosario_central.jpeg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
storage/public/catalog/camiseta_rosario_central_1.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
BIN
storage/public/catalog/pantalon_blanco.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
storage/public/catalog/pantalon_negro.jfif
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
storage/public/catalog/pantalon_negro.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
storage/public/catalog/remera_blanca.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
storage/public/catalog/remera_negra.jpg
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
storage/public/catalog/zapatillas_lecoq.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
storage/public/catalog/zapatillas_lecoq_1.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
storage/public/catalog/zapatillas_topper.webp
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
storage/public/catalog/zapatillas_topper_1.webp
Normal file
|
After Width: | Height: | Size: 386 KiB |