From 717ee5d19470771b557cc1fd63651080eec55b7c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 7 Aug 2026 10:03:31 -0300 Subject: [PATCH] refactor(catalog): add event date attribute with dynamic options and update related logic --- app/Domains/Catalog/Models/Attribute.php | 11 ++ .../Resources/CatalogItemDetailResource.php | 115 +++++++++--------- .../Catalog/Services/CatalogService.php | 10 +- app/Domains/Shared/Enums/FieldType.php | 8 +- ...0200_add_event_date_catalog_attributes.php | 75 ++++++++++++ database/seeders/AttributeSeeder.php | 19 +-- .../FiestaFutbolInfantilProductSeeder.php | 1 + .../CatalogItemDetailControllerTest.php | 20 ++- tests/Feature/Catalog/CatalogServiceTest.php | 22 +++- .../FiestaFutbolInfantilProductSeederTest.php | 17 +-- tests/Unit/Catalog/CatalogModelsTest.php | 12 ++ 11 files changed, 224 insertions(+), 86 deletions(-) create mode 100644 database/migrations/2026_08_07_000200_add_event_date_catalog_attributes.php diff --git a/app/Domains/Catalog/Models/Attribute.php b/app/Domains/Catalog/Models/Attribute.php index 7bb7ff1..58e1f99 100644 --- a/app/Domains/Catalog/Models/Attribute.php +++ b/app/Domains/Catalog/Models/Attribute.php @@ -2,6 +2,7 @@ namespace App\Domains\Catalog\Models; +use App\Domains\Event\Models\EventDate; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -51,4 +52,14 @@ class Attribute extends Model { return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order'); } + + /** + * @return HasMany + */ + public function eventDates(): HasMany + { + return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo') + ->orderBy('date') + ->orderBy('time_start'); + } } diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index 7b30ce8..357fce4 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -6,6 +6,7 @@ use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; +use App\Domains\Shared\Enums\FieldType; use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -81,12 +82,6 @@ class CatalogItemDetailResource extends JsonResource private function attributeData(ItemAttribute $itemAttribute): array { $attribute = $itemAttribute->attribute; - $availableValues = $this->variants - ->flatMap->definitions - ->where('item_attribute_id', $itemAttribute->id) - ->pluck('value') - ->filter() - ->unique(); return [ 'id' => $attribute->id, @@ -95,66 +90,66 @@ class CatalogItemDetailResource extends JsonResource 'is_required' => $attribute->is_required, 'metadata_schema' => $attribute->metadata_schema, 'type' => $attribute->type->value, - 'options' => $attribute->options - ->whereIn('value', $availableValues) - ->map(fn ($option): array => [ - 'id' => $option->id, - 'value' => $option->value, - 'label' => $option->label, - 'sort_order' => $option->sort_order, - 'validity_time_id' => $option->validity_time_id, - 'validity_time' => $option->validityTime === null - ? null - : ValidityTimeResource::make($option->validityTime), - 'metadata' => $option->metadata, - ]) - ->values(), + 'options' => $attribute->type === FieldType::EventDate + ? $this->eventDateOptions($itemAttribute) + : $this->catalogAttributeOptions($itemAttribute), ]; } + /** @return Collection> */ + private function eventDateOptions(ItemAttribute $itemAttribute): Collection + { + return $itemAttribute->attribute->eventDates + ->map(fn ($eventDate, int $index): array => [ + 'id' => $eventDate->id, + 'value' => (string) $eventDate->id, + 'label' => $eventDate->date->format('d/m/Y').' · ' + .substr($eventDate->time_start, 0, 5).' a ' + .substr($eventDate->time_end, 0, 5), + 'sort_order' => $index, + 'validity_time_id' => null, + 'validity_time' => null, + 'metadata' => [ + 'date' => $eventDate->date->format('Y-m-d'), + 'time_start' => $eventDate->time_start, + 'time_end' => $eventDate->time_end, + ], + ]); + } + + /** @return Collection> */ + private function catalogAttributeOptions(ItemAttribute $itemAttribute): Collection + { + $availableValues = $this->variants + ->flatMap->definitions + ->where('item_attribute_id', $itemAttribute->id) + ->pluck('value') + ->filter() + ->unique(); + + return $itemAttribute->attribute->options + ->whereIn('value', $availableValues) + ->map(fn ($option): array => [ + 'id' => $option->id, + 'value' => $option->value, + 'label' => $option->label, + 'sort_order' => $option->sort_order, + 'validity_time_id' => $option->validity_time_id, + 'validity_time' => $option->validityTime === null + ? null + : ValidityTimeResource::make($option->validityTime), + 'metadata' => $option->metadata, + ]) + ->values(); + } + /** @return Collection> */ private function attributesData(): Collection { - $attributes = $this->itemAttributes - ->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute)); - - if ($this->variants->isEmpty() || $this->variants->contains( - fn (Variant $variant): bool => $variant->event_date_id === null - )) { - return $attributes->values(); - } - - $eventDateAttribute = [ - 'id' => -1, - 'codigo' => 'event_date', - 'nombre' => 'Fecha', - 'is_required' => true, - 'metadata_schema' => null, - 'type' => 'event_date', - 'options' => $this->variants - ->pluck('eventDate') - ->filter() - ->unique('id') - ->sortBy(fn ($eventDate): string => $eventDate->date->format('Y-m-d').' '.$eventDate->time_start) - ->values() - ->map(fn ($eventDate, int $index): array => [ - 'id' => $eventDate->id, - 'value' => (string) $eventDate->id, - 'label' => $eventDate->date->format('d/m/Y').' · ' - .substr($eventDate->time_start, 0, 5).' a ' - .substr($eventDate->time_end, 0, 5), - 'sort_order' => $index, - 'validity_time_id' => null, - 'validity_time' => null, - 'metadata' => [ - 'date' => $eventDate->date->format('Y-m-d'), - 'time_start' => $eventDate->time_start, - 'time_end' => $eventDate->time_end, - ], - ]), - ]; - - return collect([$eventDateAttribute])->concat($attributes)->values(); + return $this->itemAttributes + ->sortBy(fn (ItemAttribute $itemAttribute): int => $itemAttribute->attribute->type === FieldType::EventDate ? 0 : 1) + ->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute)) + ->values(); } /** @return array */ diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index 829ad52..944c48f 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -44,14 +44,17 @@ class CatalogService $hasEventDateVariants = $variants !== [] && collect($variants)->every( fn (array $variant): bool => ! empty($variant['event_date_id']) ); - $hasVariants = $attributeCodes !== [] || $hasEventDateVariants; - if ($hasEventDateVariants && in_array('event_date', $attributeCodes, true)) { + if ($hasEventDateVariants && ! in_array('event_date', $attributeCodes, true)) { throw ValidationException::withMessages([ - 'attribute_codes' => ['The event_date code is reserved for event dates.'], + 'attribute_codes' => [ + 'The event_date attribute is required for event date variants.', + ], ]); } + $hasVariants = $attributeCodes !== [] || $hasEventDateVariants; + $this->validateEventProductType($data); $this->validateUniqueVariantCombinations($variants, $attributeCodes); @@ -154,6 +157,7 @@ class CatalogService 'brand', 'validityTime', 'itemAttributes.attribute.options.validityTime', + 'itemAttributes.attribute.eventDates', 'variants' => fn ($query) => $query->orderBy('id'), 'variants.inventory', 'variants.attachments', diff --git a/app/Domains/Shared/Enums/FieldType.php b/app/Domains/Shared/Enums/FieldType.php index abf480e..eb11b2f 100644 --- a/app/Domains/Shared/Enums/FieldType.php +++ b/app/Domains/Shared/Enums/FieldType.php @@ -11,10 +11,16 @@ enum FieldType: string case Multiselect = 'multiselect'; case Color = 'color'; case Image = 'image'; + case EventDate = 'event_date'; public function supportsOptions(): bool { - return in_array($this, [self::Select, self::Multiselect], true); + return in_array($this, [self::Select, self::Multiselect, self::EventDate], true); + } + + public function usesDynamicOptions(): bool + { + return $this === self::EventDate; } /** diff --git a/database/migrations/2026_08_07_000200_add_event_date_catalog_attributes.php b/database/migrations/2026_08_07_000200_add_event_date_catalog_attributes.php new file mode 100644 index 0000000..d51c328 --- /dev/null +++ b/database/migrations/2026_08_07_000200_add_event_date_catalog_attributes.php @@ -0,0 +1,75 @@ +whereExists(fn ($query) => $query + ->selectRaw('1') + ->from('event_dates') + ->whereColumn('event_dates.tenant_code', 'tenants.codigo')) + ->orderBy('id') + ->each(function (object $tenant) use ($now): void { + DB::table('attribute')->updateOrInsert( + [ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'event_date', + ], + [ + 'nombre' => 'Fecha', + 'is_required' => true, + 'metadata_schema' => null, + 'type' => 'event_date', + 'updated_at' => $now, + 'created_at' => $now, + ], + ); + }); + + DB::table('catalog_items') + ->whereExists(fn ($query) => $query + ->selectRaw('1') + ->from('variantes') + ->whereColumn('variantes.catalog_item_id', 'catalog_items.id') + ->whereNotNull('variantes.event_date_id')) + ->orderBy('id') + ->each(function (object $catalogItem) use ($now): void { + $attributeId = DB::table('attribute') + ->where('tenant_codigo', $catalogItem->tenant_code) + ->where('codigo', 'event_date') + ->value('id'); + + if ($attributeId === null) { + return; + } + + DB::table('item_attributes')->updateOrInsert( + [ + 'catalog_item_id' => $catalogItem->id, + 'attribute_id' => $attributeId, + ], + [ + 'updated_at' => $now, + 'created_at' => $now, + ], + ); + }); + } + + public function down(): void + { + $attributeIds = DB::table('attribute') + ->where('codigo', 'event_date') + ->where('type', 'event_date') + ->pluck('id'); + + DB::table('item_attributes')->whereIn('attribute_id', $attributeIds)->delete(); + DB::table('attribute')->whereIn('id', $attributeIds)->delete(); + } +}; diff --git a/database/seeders/AttributeSeeder.php b/database/seeders/AttributeSeeder.php index c11a04a..417fbba 100644 --- a/database/seeders/AttributeSeeder.php +++ b/database/seeders/AttributeSeeder.php @@ -89,18 +89,12 @@ class AttributeSeeder extends Seeder ], ]); - // Seed Fecha attribute + // Las opciones de fecha se resuelven dinámicamente desde event_dates. $this->seedAttribute($tenant, [ - 'codigo' => 'fecha', + 'codigo' => 'event_date', 'nombre' => 'Fecha', - 'type' => FieldType::Select->value, + 'type' => FieldType::EventDate->value, 'is_required' => true, - 'options' => [ - ['value' => '2026-10-09', 'label' => '09/10/2026', 'sort_order' => 1], - ['value' => '2026-10-10', 'label' => '10/10/2026', 'sort_order' => 2], - ['value' => '2026-10-11', 'label' => '11/10/2026', 'sort_order' => 3], - ['value' => '2026-10-12', 'label' => '12/10/2026', 'sort_order' => 4], - ], ]); } @@ -113,6 +107,13 @@ class AttributeSeeder extends Seeder ->whereIn('codigo', ['talle_numerico', 'fecha']) ->delete(); + $this->seedAttribute($tenant, [ + 'codigo' => 'event_date', + 'nombre' => 'Fecha', + 'type' => FieldType::EventDate->value, + 'is_required' => true, + ]); + $breakfastValidityTime = $this->timeWindow('07:00:00', '12:00:00'); $lunchValidityTime = $this->timeWindow('12:00:00', '15:00:00'); $dinnerValidityTime = $this->timeWindow('20:00:00', '24:00:00'); diff --git a/database/seeders/FiestaFutbolInfantilProductSeeder.php b/database/seeders/FiestaFutbolInfantilProductSeeder.php index 2d533f2..b9ff008 100644 --- a/database/seeders/FiestaFutbolInfantilProductSeeder.php +++ b/database/seeders/FiestaFutbolInfantilProductSeeder.php @@ -91,6 +91,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder 'inventory_policy' => InventoryPolicy::Unlimited->value, 'has_tickets' => true, 'validity_time_id' => $eventValidityTime->id, + 'attribute_codes' => ['event_date'], 'variants' => array_map( fn (string $date): array => [ 'real_stock' => 0, diff --git a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php index accdcf3..b28fdc3 100644 --- a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php @@ -182,19 +182,37 @@ class CatalogItemDetailControllerTest extends TestCase 'time_start' => '09:00', 'time_end' => '18:00', ]); + $unusedEventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-10', + 'time_start' => '10:00', + 'time_end' => '19:00', + ]); $item = $this->createItem($tenant, 'Entry'); + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'event_date', + 'nombre' => 'Fecha', + 'is_required' => true, + 'type' => FieldType::EventDate, + ]); + $item->itemAttributes()->create(['attribute_id' => $attribute->id]); $variant = $this->createVariant($item, 10, 0); $variant->update(['event_date_id' => $eventDate->id]); $this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}") ->assertOk() - ->assertJsonPath('data.attributes.0.id', -1) + ->assertJsonPath('data.attributes.0.id', $attribute->id) ->assertJsonPath('data.attributes.0.codigo', 'event_date') ->assertJsonPath('data.attributes.0.type', 'event_date') + ->assertJsonCount(2, 'data.attributes.0.options') ->assertJsonPath('data.attributes.0.options.0.id', $eventDate->id) ->assertJsonPath('data.attributes.0.options.0.value', (string) $eventDate->id) ->assertJsonPath('data.attributes.0.options.0.label', '09/10/2026 · 09:00 a 18:00') + ->assertJsonPath('data.attributes.0.options.1.id', $unusedEventDate->id) + ->assertJsonPath('data.attributes.0.options.1.value', (string) $unusedEventDate->id) ->assertJsonPath('data.variants.0.values.event_date', (string) $eventDate->id); + + $this->assertDatabaseCount('attribute_options', 0); } private function createItem( diff --git a/tests/Feature/Catalog/CatalogServiceTest.php b/tests/Feature/Catalog/CatalogServiceTest.php index 264d5f0..3297699 100644 --- a/tests/Feature/Catalog/CatalogServiceTest.php +++ b/tests/Feature/Catalog/CatalogServiceTest.php @@ -96,6 +96,7 @@ class CatalogServiceTest extends TestCase public function test_it_allows_the_same_event_date_with_different_attribute_values(): void { $sector = $this->createAttribute('sector'); + $eventDateAttribute = $this->createAttribute('event_date', FieldType::EventDate); $eventDate = $this->tenant->eventDates()->create([ 'date' => '2026-10-09', 'time_start' => '09:00', @@ -107,7 +108,7 @@ class CatalogServiceTest extends TestCase 'slug' => 'entry-by-sector', 'nombre' => 'Entry by sector', 'precio' => 100, - 'attribute_codes' => [$sector->codigo], + 'attribute_codes' => [$eventDateAttribute->codigo, $sector->codigo], 'variants' => [ [ 'event_date_id' => $eventDate->id, @@ -125,11 +126,20 @@ class CatalogServiceTest extends TestCase [$eventDate->id, $eventDate->id], $item->variants->pluck('event_date_id')->all(), ); + $this->assertSame( + ['event_date', 'sector'], + $item->itemAttributes->pluck('attribute.codigo')->all(), + ); + $this->assertSame( + FieldType::EventDate, + $item->itemAttributes->first()->attribute->type, + ); } public function test_it_rejects_duplicate_variant_combinations(): void { $sector = $this->createAttribute('sector'); + $eventDateAttribute = $this->createAttribute('event_date', FieldType::EventDate); $eventDate = $this->tenant->eventDates()->create([ 'date' => '2026-10-09', 'time_start' => '09:00', @@ -142,7 +152,7 @@ class CatalogServiceTest extends TestCase 'slug' => 'duplicate-entry', 'nombre' => 'Duplicate entry', 'precio' => 100, - 'attribute_codes' => [$sector->codigo], + 'attribute_codes' => [$eventDateAttribute->codigo, $sector->codigo], 'variants' => [ ['event_date_id' => $eventDate->id, 'values' => ['sector' => 'VIP']], ['event_date_id' => $eventDate->id, 'values' => ['sector' => ' vip ']], @@ -277,13 +287,15 @@ class CatalogServiceTest extends TestCase ]); } - private function createAttribute(string $code): Attribute - { + private function createAttribute( + string $code, + FieldType $type = FieldType::String, + ): Attribute { return Attribute::query()->create([ 'tenant_codigo' => $this->tenant->codigo, 'codigo' => $code, 'nombre' => ucfirst($code), - 'type' => FieldType::String, + 'type' => $type, ]); } } diff --git a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php index 02f87fb..0f767f1 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -15,6 +15,7 @@ use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\Inventory; use App\Domains\Event\Models\EventDate; +use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Enums\ValidityTimeType; use Database\Seeders\AttributeSeeder; @@ -71,13 +72,15 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase ->get(); $this->assertSame( - ['servicio', 'color', 'horario', 'talle'], + ['event_date', 'servicio', 'color', 'horario', 'talle'], $attributes->pluck('codigo')->all(), ); - $this->assertSame(['Comedor', 'Vianda'], $attributes[0]->options->pluck('value')->all()); - $this->assertSame(['Verde', 'Blanco'], $attributes[1]->options->pluck('value')->all()); - $this->assertSame(['Desayuno', 'Almuerzo', 'Cena'], $attributes[2]->options->pluck('value')->all()); - $this->assertSame(['14', 'S', 'M', 'L', 'XL', 'XXL'], $attributes[3]->options->pluck('value')->all()); + $this->assertSame(FieldType::EventDate, $attributes[0]->type); + $this->assertEmpty($attributes[0]->options); + $this->assertSame(['Comedor', 'Vianda'], $attributes[1]->options->pluck('value')->all()); + $this->assertSame(['Verde', 'Blanco'], $attributes[2]->options->pluck('value')->all()); + $this->assertSame(['Desayuno', 'Almuerzo', 'Cena'], $attributes[3]->options->pluck('value')->all()); + $this->assertSame(['14', 'S', 'M', 'L', 'XL', 'XXL'], $attributes[4]->options->pluck('value')->all()); $this->assertSame( [ @@ -85,7 +88,7 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase ['Almuerzo', ValidityTimeType::TimeWindow, '12:00:00', '15:00:00'], ['Cena', ValidityTimeType::TimeWindow, '20:00:00', '24:00:00'], ], - $attributes[2]->options + $attributes[3]->options ->map(fn ($option): array => [ $option->value, $option->validityTime->type, @@ -109,7 +112,7 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase $this->assertNull($generalAdmission->inventory_id); $this->assertSame(EventProductType::Entry, $generalAdmission->event_product_type); - $this->assertCount(0, $generalAdmission->itemAttributes); + $this->assertCount(1, $generalAdmission->itemAttributes); $this->assertCount(4, $generalAdmission->variants); $this->assertEqualsCanonicalizing( ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'], diff --git a/tests/Unit/Catalog/CatalogModelsTest.php b/tests/Unit/Catalog/CatalogModelsTest.php index 356a964..bf78f9c 100644 --- a/tests/Unit/Catalog/CatalogModelsTest.php +++ b/tests/Unit/Catalog/CatalogModelsTest.php @@ -57,6 +57,18 @@ class CatalogModelsTest extends TestCase $this->assertInstanceOf(AttributeOption::class, $attribute->options()->getRelated()); } + public function test_event_date_is_an_attribute_type_with_dynamic_options(): void + { + $attribute = new Attribute; + $attribute->type = FieldType::EventDate->value; + + $this->assertSame(FieldType::EventDate, $attribute->type); + $this->assertTrue($attribute->type->supportsOptions()); + $this->assertTrue($attribute->type->usesDynamicOptions()); + $this->assertContains('event_date', FieldType::values()); + $this->assertInstanceOf(EventDate::class, $attribute->eventDates()->getRelated()); + } + public function test_catalog_item_is_the_catalog_root(): void { $item = new CatalogItem;