refactor(catalog): add event date attribute with dynamic options and update related logic

This commit is contained in:
2026-08-07 10:03:31 -03:00
parent 14d53d1e0b
commit 717ee5d194
11 changed files with 224 additions and 86 deletions

View File

@@ -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<EventDate, $this>
*/
public function eventDates(): HasMany
{
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo')
->orderBy('date')
->orderBy('time_start');
}
}

View File

@@ -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<int, array<string, mixed>> */
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<int, array<string, mixed>> */
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<int, array<string, mixed>> */
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<string, mixed> */

View File

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

View File

@@ -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;
}
/**

View File

@@ -0,0 +1,75 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$now = now();
DB::table('tenants')
->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();
}
};

View File

@@ -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');

View File

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

View File

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

View File

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

View File

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

View File

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