diff --git a/app/Domains/Catalog/Controllers/ProductController.php b/app/Domains/Catalog/Controllers/ProductController.php index 8058c22..7ff45d2 100644 --- a/app/Domains/Catalog/Controllers/ProductController.php +++ b/app/Domains/Catalog/Controllers/ProductController.php @@ -20,7 +20,7 @@ class ProductController extends Controller return ProductResource::collection( Product::query() ->where('tenant_codigo', $tenant->codigo) - ->with('attachments') + ->with(['attachments', 'brand', 'category']) ->latest() ->paginateFromRequest() )->response(); @@ -37,7 +37,13 @@ class ProductController extends Controller { $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 diff --git a/app/Domains/Catalog/Models/Product.php b/app/Domains/Catalog/Models/Product.php index 05bbc7a..ec84324 100644 --- a/app/Domains/Catalog/Models/Product.php +++ b/app/Domains/Catalog/Models/Product.php @@ -3,6 +3,8 @@ namespace App\Domains\Catalog\Models; 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 Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'tenant_codigo', 'categoria_id', + 'brand_id', 'slug', 'nombre', 'descripcion', @@ -29,6 +32,7 @@ class Product extends Model { return [ 'categoria_id' => 'integer', + 'brand_id' => 'integer', 'precio' => 'decimal:2', ]; } @@ -41,6 +45,22 @@ class Product extends Model return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); } + /** + * @return BelongsTo + */ + public function category(): BelongsTo + { + return $this->belongsTo(Category::class, 'categoria_id'); + } + + /** + * @return BelongsTo + */ + public function brand(): BelongsTo + { + return $this->belongsTo(Brand::class, 'brand_id'); + } + /** * @return HasMany */ diff --git a/app/Domains/Catalog/Requests/StoreProductRequest.php b/app/Domains/Catalog/Requests/StoreProductRequest.php index 76a308c..36acdd0 100644 --- a/app/Domains/Catalog/Requests/StoreProductRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductRequest.php @@ -20,6 +20,13 @@ class StoreProductRequest extends FormRequest { return [ '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')], 'nombre' => ['required', 'string', 'max:255'], 'descripcion' => ['nullable', 'string'], diff --git a/app/Domains/Catalog/Requests/UpdateProductRequest.php b/app/Domains/Catalog/Requests/UpdateProductRequest.php index b556545..75b1ef1 100644 --- a/app/Domains/Catalog/Requests/UpdateProductRequest.php +++ b/app/Domains/Catalog/Requests/UpdateProductRequest.php @@ -24,6 +24,13 @@ class UpdateProductRequest extends FormRequest return [ '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', diff --git a/app/Domains/Catalog/Resources/ProductResource.php b/app/Domains/Catalog/Resources/ProductResource.php index 42b8a23..3040910 100644 --- a/app/Domains/Catalog/Resources/ProductResource.php +++ b/app/Domains/Catalog/Resources/ProductResource.php @@ -2,6 +2,8 @@ namespace App\Domains\Catalog\Resources; +use App\Domains\Catalog\Resources\BrandResource; +use App\Domains\Catalog\Resources\CategoryResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -19,10 +21,13 @@ class ProductResource extends JsonResource 'id' => $this->id, 'tenant_codigo' => $this->tenant_codigo, 'categoria_id' => $this->categoria_id, + 'brand_id' => $this->brand_id, 'slug' => $this->slug, 'nombre' => $this->nombre, 'descripcion' => $this->descripcion, 'precio' => $this->precio, + 'category' => CategoryResource::make($this->whenLoaded('category')), + 'brand' => BrandResource::make($this->whenLoaded('brand')), 'images' => $this->whenLoaded('attachments', fn () => $this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values() ), diff --git a/app/Domains/Catalog/Services/ProductService.php b/app/Domains/Catalog/Services/ProductService.php index c4722eb..e1c6fb4 100644 --- a/app/Domains/Catalog/Services/ProductService.php +++ b/app/Domains/Catalog/Services/ProductService.php @@ -37,7 +37,7 @@ class ProductService $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); } - return $product->load(['attributes', 'attachments']); + return $product->load(['attributes', 'attachments', 'brand', 'category']); }); } @@ -79,6 +79,7 @@ class ProductService { DB::transaction(function () use ($product) { foreach ($product->variants as $variant) { + $this->deleteVariantAttachments($variant); $product->deleteVariant($variant); } @@ -108,7 +109,7 @@ class ProductService $variant = $product->createVariant($data); if (! empty($images)) { - $this->syncImages($variant, $images); + $this->syncVariantImages($variant, $images); } return $variant->load(['product', 'definitions.attribute', 'attachments']); @@ -132,7 +133,7 @@ class ProductService $updatedVariant = $product->updateVariant($variant, $data); if ($hasImages) { - $this->syncImages($updatedVariant, $images); + $this->syncVariantImages($updatedVariant, $images); } return $updatedVariant->load(['product', 'definitions.attribute', 'attachments']); @@ -147,6 +148,7 @@ class ProductService DB::transaction(function () use ($variant) { /** @var Product $product */ $product = $variant->product; + $this->deleteVariantAttachments($variant); $product->deleteVariant($variant); }); } @@ -183,14 +185,9 @@ class ProductService * * @param array $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 - $existing = $variant->attachments()->get(); - $variant->attachments()->detach(); - foreach ($existing as $attachment) { - $this->attachmentService->delete($attachment); - } + $this->deleteVariantAttachments($variant); $attachmentIds = []; @@ -205,7 +202,7 @@ class ProductService /** * 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 $images */ @@ -228,6 +225,16 @@ class ProductService $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. */ diff --git a/database/migrations/2026_06_29_210000_add_brand_id_to_productos_table.php b/database/migrations/2026_06_29_210000_add_brand_id_to_productos_table.php new file mode 100644 index 0000000..d86f10c --- /dev/null +++ b/database/migrations/2026_06_29_210000_add_brand_id_to_productos_table.php @@ -0,0 +1,33 @@ +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'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0ce234b..0ada740 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -27,6 +27,7 @@ class DatabaseSeeder extends Seeder AttributeSeeder::class, CategorySeeder::class, BrandSeeder::class, + ProductCatalogFromImagesSeeder::class, ]); } } diff --git a/database/seeders/ProductCatalogFromImagesSeeder.php b/database/seeders/ProductCatalogFromImagesSeeder.php new file mode 100644 index 0000000..4d2000b --- /dev/null +++ b/database/seeders/ProductCatalogFromImagesSeeder.php @@ -0,0 +1,354 @@ + + */ + 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 + */ + 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 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, 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 $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, 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 + ); + } +} diff --git a/storage/public/catalog/buzo_blanco_adidas.jpg b/storage/public/catalog/buzo_blanco_adidas.jpg new file mode 100644 index 0000000..25e5e26 Binary files /dev/null and b/storage/public/catalog/buzo_blanco_adidas.jpg differ diff --git a/storage/public/catalog/buzo_blanco_adidas_1.jpg b/storage/public/catalog/buzo_blanco_adidas_1.jpg new file mode 100644 index 0000000..603ddb8 Binary files /dev/null and b/storage/public/catalog/buzo_blanco_adidas_1.jpg differ diff --git a/storage/public/catalog/buzo_gris_nije.jpg b/storage/public/catalog/buzo_gris_nije.jpg new file mode 100644 index 0000000..c60b8ab Binary files /dev/null and b/storage/public/catalog/buzo_gris_nije.jpg differ diff --git a/storage/public/catalog/buzo_gris_nike_1.jpg b/storage/public/catalog/buzo_gris_nike_1.jpg new file mode 100644 index 0000000..ec2ab6f Binary files /dev/null and b/storage/public/catalog/buzo_gris_nike_1.jpg differ diff --git a/storage/public/catalog/buzo_gris_nike_2.jpg b/storage/public/catalog/buzo_gris_nike_2.jpg new file mode 100644 index 0000000..5b3f7c6 Binary files /dev/null and b/storage/public/catalog/buzo_gris_nike_2.jpg differ diff --git a/storage/public/catalog/buzo_gris_under_armour.jpg b/storage/public/catalog/buzo_gris_under_armour.jpg new file mode 100644 index 0000000..e0c3175 Binary files /dev/null and b/storage/public/catalog/buzo_gris_under_armour.jpg differ diff --git a/storage/public/catalog/buzo_negro_adidas.jpg b/storage/public/catalog/buzo_negro_adidas.jpg new file mode 100644 index 0000000..cca9304 Binary files /dev/null and b/storage/public/catalog/buzo_negro_adidas.jpg differ diff --git a/storage/public/catalog/buzo_negro_adidas_1.jpg b/storage/public/catalog/buzo_negro_adidas_1.jpg new file mode 100644 index 0000000..414b5d7 Binary files /dev/null and b/storage/public/catalog/buzo_negro_adidas_1.jpg differ diff --git a/storage/public/catalog/camiseta_rosario_central.jpeg b/storage/public/catalog/camiseta_rosario_central.jpeg new file mode 100644 index 0000000..0360b26 Binary files /dev/null and b/storage/public/catalog/camiseta_rosario_central.jpeg differ diff --git a/storage/public/catalog/camiseta_rosario_central_1.png b/storage/public/catalog/camiseta_rosario_central_1.png new file mode 100644 index 0000000..f030c9d Binary files /dev/null and b/storage/public/catalog/camiseta_rosario_central_1.png differ diff --git a/storage/public/catalog/pantalon_blanco.png b/storage/public/catalog/pantalon_blanco.png new file mode 100644 index 0000000..05960e8 Binary files /dev/null and b/storage/public/catalog/pantalon_blanco.png differ diff --git a/storage/public/catalog/pantalon_negro.jfif b/storage/public/catalog/pantalon_negro.jfif new file mode 100644 index 0000000..f12e8c3 Binary files /dev/null and b/storage/public/catalog/pantalon_negro.jfif differ diff --git a/storage/public/catalog/pantalon_negro.jpg b/storage/public/catalog/pantalon_negro.jpg new file mode 100644 index 0000000..269c559 Binary files /dev/null and b/storage/public/catalog/pantalon_negro.jpg differ diff --git a/storage/public/catalog/remera_blanca.jpg b/storage/public/catalog/remera_blanca.jpg new file mode 100644 index 0000000..73cbd4c Binary files /dev/null and b/storage/public/catalog/remera_blanca.jpg differ diff --git a/storage/public/catalog/remera_negra.jpg b/storage/public/catalog/remera_negra.jpg new file mode 100644 index 0000000..2bc7f50 Binary files /dev/null and b/storage/public/catalog/remera_negra.jpg differ diff --git a/storage/public/catalog/zapatillas_lecoq.webp b/storage/public/catalog/zapatillas_lecoq.webp new file mode 100644 index 0000000..781b26a Binary files /dev/null and b/storage/public/catalog/zapatillas_lecoq.webp differ diff --git a/storage/public/catalog/zapatillas_lecoq_1.webp b/storage/public/catalog/zapatillas_lecoq_1.webp new file mode 100644 index 0000000..e2048db Binary files /dev/null and b/storage/public/catalog/zapatillas_lecoq_1.webp differ diff --git a/storage/public/catalog/zapatillas_topper.webp b/storage/public/catalog/zapatillas_topper.webp new file mode 100644 index 0000000..1eafba0 Binary files /dev/null and b/storage/public/catalog/zapatillas_topper.webp differ diff --git a/storage/public/catalog/zapatillas_topper_1.webp b/storage/public/catalog/zapatillas_topper_1.webp new file mode 100644 index 0000000..18c19f3 Binary files /dev/null and b/storage/public/catalog/zapatillas_topper_1.webp differ