feat: implement product variant CRUD operations and tenant configuration seeding

This commit is contained in:
2026-06-29 11:59:07 -03:00
parent 9b7d728117
commit 0206ee2985
10 changed files with 227 additions and 96 deletions

View File

@@ -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

View File

@@ -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()],
];
}
}

View File

@@ -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()],
];
}
}

View File

@@ -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,
];

View File

@@ -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<int, UploadedFile|string> $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.
*/

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Domains\Shared\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\UploadedFile;
class ImageOrBase64Rule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value instanceof UploadedFile) {
$mime = $value->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:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/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.");
}
}

View File

@@ -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:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/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')],

View File

@@ -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:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/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' => [

View File

@@ -0,0 +1,114 @@
<?php
namespace Database\Seeders;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Product;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Seeder;
class AttributeSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
// Seed Talle (Size - Text options) attribute
$existingTalle = Attribute::query()
->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'],
],
],
]);
}
}
}

View File

@@ -24,6 +24,7 @@ class DatabaseSeeder extends Seeder
$this->call([
TenantSeeder::class,
AttributeSeeder::class,
CategorySeeder::class,
BrandSeeder::class,
]);