refactor(catalog): add event date attribute with dynamic options and update related logic
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user