From 0206ee29852cc02b322936b2ee86ab7f2e2f7223 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 29 Jun 2026 11:59:07 -0300 Subject: [PATCH] feat: implement product variant CRUD operations and tenant configuration seeding --- .../Controllers/ProductVariantController.php | 4 +- .../Requests/StoreProductVariantRequest.php | 3 + .../Requests/UpdateProductVariantRequest.php | 3 + .../Resources/ProductVariantResource.php | 3 + .../Catalog/Services/ProductService.php | 45 ++++++- .../Shared/Rules/ImageOrBase64Rule.php | 54 +++++++++ .../Tenant/Requests/StoreTenantRequest.php | 48 +------- .../Tenant/Requests/UpdateTenantRequest.php | 48 +------- database/seeders/AttributeSeeder.php | 114 ++++++++++++++++++ database/seeders/DatabaseSeeder.php | 1 + 10 files changed, 227 insertions(+), 96 deletions(-) create mode 100644 app/Domains/Shared/Rules/ImageOrBase64Rule.php create mode 100644 database/seeders/AttributeSeeder.php diff --git a/app/Domains/Catalog/Controllers/ProductVariantController.php b/app/Domains/Catalog/Controllers/ProductVariantController.php index 2d63db0..2b457e5 100644 --- a/app/Domains/Catalog/Controllers/ProductVariantController.php +++ b/app/Domains/Catalog/Controllers/ProductVariantController.php @@ -21,7 +21,7 @@ class ProductVariantController extends Controller { $query = ProductVariant::query() ->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo)) - ->with(['product', 'definitions.attribute']) + ->with(['product', 'definitions.attribute', 'attachments']) ->latest(); return ProductVariantResource::collection($query->paginateFromRequest())->response(); @@ -41,7 +41,7 @@ class ProductVariantController extends Controller { $productVariant = $this->resolveScopedVariant($tenant, $productVariant); - return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute'])); + return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute', 'attachments'])); } public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant, ProductService $productService): ProductVariantResource diff --git a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php b/app/Domains/Catalog/Requests/StoreProductVariantRequest.php index b514ab2..a8d9512 100644 --- a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductVariantRequest.php @@ -2,6 +2,7 @@ namespace App\Domains\Catalog\Requests; +use App\Domains\Shared\Rules\ImageOrBase64Rule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -59,6 +60,8 @@ class StoreProductVariantRequest extends FormRequest }, ], 'definitions.*.value' => ['nullable', 'string'], + 'images' => ['sometimes', 'nullable', 'array'], + 'images.*' => ['required', new ImageOrBase64Rule()], ]; } } diff --git a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php b/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php index 49d99e3..90d3c31 100644 --- a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php +++ b/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php @@ -3,6 +3,7 @@ namespace App\Domains\Catalog\Requests; use App\Domains\Catalog\Models\ProductVariant; +use App\Domains\Shared\Rules\ImageOrBase64Rule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -63,6 +64,8 @@ class UpdateProductVariantRequest extends FormRequest }, ], 'definitions.*.value' => ['nullable', 'string'], + 'images' => ['sometimes', 'nullable', 'array'], + 'images.*' => ['required', new ImageOrBase64Rule()], ]; } } diff --git a/app/Domains/Catalog/Resources/ProductVariantResource.php b/app/Domains/Catalog/Resources/ProductVariantResource.php index 5c8ffd7..94f8a98 100644 --- a/app/Domains/Catalog/Resources/ProductVariantResource.php +++ b/app/Domains/Catalog/Resources/ProductVariantResource.php @@ -25,6 +25,9 @@ class ProductVariantResource extends JsonResource 'precio' => $this->precio, 'product' => ProductResource::make($this->whenLoaded('product')), 'definitions' => ProductVariantDefinitionResource::collection($this->whenLoaded('definitions')), + 'images' => $this->whenLoaded('attachments', fn () => + $this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values() + ), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Domains/Catalog/Services/ProductService.php b/app/Domains/Catalog/Services/ProductService.php index 44278a6..e1905ca 100644 --- a/app/Domains/Catalog/Services/ProductService.php +++ b/app/Domains/Catalog/Services/ProductService.php @@ -2,14 +2,17 @@ namespace App\Domains\Catalog\Services; +use App\Domains\Attachable\Services\AttachmentService; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Tenant\Models\Tenant; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; class ProductService { + public function __construct(protected AttachmentService $attachmentService) {} /** * Create a product. * @@ -80,9 +83,16 @@ class ProductService public function createVariant(Product $product, array $data): ProductVariant { return DB::transaction(function () use ($product, $data) { + $images = $data['images'] ?? []; + unset($data['images']); + $variant = $product->createVariant($data); - return $variant->load(['product', 'definitions.attribute']); + if (! empty($images)) { + $this->syncImages($variant, $images); + } + + return $variant->load(['product', 'definitions.attribute', 'attachments']); }); } @@ -94,11 +104,19 @@ class ProductService public function updateVariant(ProductVariant $variant, array $data): ProductVariant { return DB::transaction(function () use ($variant, $data) { + $hasImages = array_key_exists('images', $data); + $images = $data['images'] ?? []; + unset($data['images']); + /** @var Product $product */ $product = $variant->product; $updatedVariant = $product->updateVariant($variant, $data); - return $updatedVariant->load(['product', 'definitions.attribute']); + if ($hasImages) { + $this->syncImages($updatedVariant, $images); + } + + return $updatedVariant->load(['product', 'definitions.attribute', 'attachments']); }); } @@ -138,6 +156,29 @@ class ProductService }); } + /** + * Upload a list of image files/base64 strings and sync them to a variant. + * + * When called on update, the existing attachments are detached first so the + * final set always matches exactly what was sent in the request. + * + * @param array $images + */ + protected function syncImages(ProductVariant $variant, array $images): void + { + // Detach any existing attachments before replacing them + $variant->attachments()->detach(); + + $attachmentIds = []; + + foreach ($images as $image) { + $attachment = $this->attachmentService->store($image, 'variants'); + $attachmentIds[] = $attachment->id; + } + + $variant->attachments()->sync($attachmentIds); + } + /** * Delete an attribute. */ diff --git a/app/Domains/Shared/Rules/ImageOrBase64Rule.php b/app/Domains/Shared/Rules/ImageOrBase64Rule.php new file mode 100644 index 0000000..f2b7771 --- /dev/null +++ b/app/Domains/Shared/Rules/ImageOrBase64Rule.php @@ -0,0 +1,54 @@ +getMimeType(); + if (! str_starts_with((string) $mime, 'image/')) { + $fail("The :attribute must be a valid image file."); + } + return; + } + + if (is_string($value)) { + $payload = trim($value); + + if ($payload === '') { + $fail("The :attribute must not be empty."); + return; + } + + $declaredMimeType = null; + if (preg_match('/^data:(?[-\w.+\/]+);base64,(?.+)$/s', $payload, $matches) === 1) { + $declaredMimeType = strtolower($matches['mime']); + $payload = $matches['data']; + } + + $decoded = base64_decode(preg_replace('/\s+/', '', $payload), true); + if ($decoded === false || $decoded === '') { + $fail("The :attribute must be a valid base64-encoded image."); + return; + } + + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $detectedMime = $finfo->buffer($decoded); + $mime = $detectedMime ?: $declaredMimeType; + + if (! $mime || ! str_starts_with((string) $mime, 'image/')) { + $fail("The :attribute must be a valid image (file or base64)."); + } + + return; + } + + $fail("The :attribute must be an image file or a base64-encoded image string."); + } +} diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index 6ba632a..8e01975 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -2,11 +2,10 @@ namespace App\Domains\Tenant\Requests; +use App\Domains\Shared\Rules\ImageOrBase64Rule; use App\Domains\Tenant\Support\TenantDomainNormalizer; use Closure; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Http\UploadedFile; -use Illuminate\Support\Str; use Illuminate\Validation\Rule; class StoreTenantRequest extends FormRequest @@ -36,51 +35,8 @@ class StoreTenantRequest extends FormRequest */ public function rules(): array { - $logoRule = [ - 'required', - static function (string $attribute, mixed $value, Closure $fail): void { - if ($value instanceof UploadedFile) { - $mime = $value->getMimeType(); - if (! str_starts_with($mime, 'image/')) { - $fail("The {$attribute} must be a valid image or SVG."); - } - return; - } + $logoRule = ['required', new ImageOrBase64Rule()]; - if (is_string($value)) { - if (Str::isUuid($value)) { - return; - } - - try { - $payload = trim($value); - $declaredMimeType = null; - if (preg_match('/^data:(?[-\w.+\/]+);base64,(?.+)$/s', $payload, $matches) === 1) { - $declaredMimeType = strtolower($matches['mime']); - $payload = $matches['data']; - } - $decoded = base64_decode(preg_replace('/\s+/', '', $payload), true); - if ($decoded === false || $decoded === '') { - $fail("The {$attribute} must be a valid base64 image or SVG."); - return; - } - $finfo = new \finfo(FILEINFO_MIME_TYPE); - $mime = $finfo->buffer($decoded); - if (! $mime && $declaredMimeType) { - $mime = $declaredMimeType; - } - if (! $mime || ! str_starts_with($mime, 'image/')) { - $fail("The {$attribute} must be a valid image or SVG."); - } - } catch (\Throwable) { - $fail("The {$attribute} must be a valid image or SVG."); - } - return; - } - - $fail("The {$attribute} must be a valid file or base64 string."); - }, - ]; return [ 'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')], diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index 7c9e926..c8c15c6 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -2,12 +2,11 @@ namespace App\Domains\Tenant\Requests; +use App\Domains\Shared\Rules\ImageOrBase64Rule; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Support\TenantDomainNormalizer; use Closure; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Http\UploadedFile; -use Illuminate\Support\Str; use Illuminate\Validation\Rule; class UpdateTenantRequest extends FormRequest @@ -42,51 +41,8 @@ class UpdateTenantRequest extends FormRequest /** @var Tenant|null $tenant */ $tenant = $this->route('tenant'); - $logoRule = [ - 'nullable', - static function (string $attribute, mixed $value, Closure $fail): void { - if ($value instanceof UploadedFile) { - $mime = $value->getMimeType(); - if (! str_starts_with($mime, 'image/')) { - $fail("The {$attribute} must be a valid image or SVG."); - } - return; - } + $logoRule = ['nullable', new ImageOrBase64Rule()]; - if (is_string($value)) { - if (Str::isUuid($value)) { - return; - } - - try { - $payload = trim($value); - $declaredMimeType = null; - if (preg_match('/^data:(?[-\w.+\/]+);base64,(?.+)$/s', $payload, $matches) === 1) { - $declaredMimeType = strtolower($matches['mime']); - $payload = $matches['data']; - } - $decoded = base64_decode(preg_replace('/\s+/', '', $payload), true); - if ($decoded === false || $decoded === '') { - $fail("The {$attribute} must be a valid base64 image or SVG."); - return; - } - $finfo = new \finfo(FILEINFO_MIME_TYPE); - $mime = $finfo->buffer($decoded); - if (! $mime && $declaredMimeType) { - $mime = $declaredMimeType; - } - if (! $mime || ! str_starts_with($mime, 'image/')) { - $fail("The {$attribute} must be a valid image or SVG."); - } - } catch (\Throwable) { - $fail("The {$attribute} must be a valid image or SVG."); - } - return; - } - - $fail("The {$attribute} must be a valid file or base64 string."); - }, - ]; return [ 'codigo' => [ diff --git a/database/seeders/AttributeSeeder.php b/database/seeders/AttributeSeeder.php new file mode 100644 index 0000000..3661ad7 --- /dev/null +++ b/database/seeders/AttributeSeeder.php @@ -0,0 +1,114 @@ +where('tenant_codigo', $tenant->codigo) + ->where('codigo', 'talle') + ->first(); + + if ($existingTalle) { + Product::deleteAttribute($existingTalle); + } + + Product::createAttribute($tenant, [ + 'codigo' => 'talle', + 'nombre' => 'Talle', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'options' => [ + ['value' => 'S', 'label' => 'S', 'sort_order' => 1], + ['value' => 'M', 'label' => 'M', 'sort_order' => 2], + ['value' => 'L', 'label' => 'L', 'sort_order' => 3], + ['value' => 'XL', 'label' => 'XL', 'sort_order' => 4], + ], + ]); + + // Seed Talle Numérico (Numeric Size options) attribute + $existingTalleNumerico = Attribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->where('codigo', 'talle_numerico') + ->first(); + + if ($existingTalleNumerico) { + Product::deleteAttribute($existingTalleNumerico); + } + + Product::createAttribute($tenant, [ + 'codigo' => 'talle_numerico', + 'nombre' => 'Talle Numérico', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'options' => [ + ['value' => '38', 'label' => '38', 'sort_order' => 1], + ['value' => '40', 'label' => '40', 'sort_order' => 2], + ['value' => '42', 'label' => '42', 'sort_order' => 3], + ['value' => '44', 'label' => '44', 'sort_order' => 4], + ], + ]); + + // Seed Color attribute + $existingColor = Attribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->where('codigo', 'color') + ->first(); + + if ($existingColor) { + Product::deleteAttribute($existingColor); + } + + Product::createAttribute($tenant, [ + 'codigo' => 'color', + 'nombre' => 'Color', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'metadata_schema' => [ + 'hex' => ['type' => 'string'], + ], + 'options' => [ + [ + 'value' => 'Negro', + 'label' => 'Negro', + 'sort_order' => 1, + 'metadata' => ['hex' => '#000000'], + ], + [ + 'value' => 'Gris', + 'label' => 'Gris', + 'sort_order' => 2, + 'metadata' => ['hex' => '#808080'], + ], + [ + 'value' => 'Blanco', + 'label' => 'Blanco', + 'sort_order' => 3, + 'metadata' => ['hex' => '#FFFFFF'], + ], + [ + 'value' => 'Azul', + 'label' => 'Azul', + 'sort_order' => 4, + 'metadata' => ['hex' => '#0000FF'], + ], + ], + ]); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 06f7cc8..0ce234b 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -24,6 +24,7 @@ class DatabaseSeeder extends Seeder $this->call([ TenantSeeder::class, + AttributeSeeder::class, CategorySeeder::class, BrandSeeder::class, ]);