feat(ticket): implement ticket generation policies and validity time management

- Added `ticket_generation_policy` column to `catalog_items` table to manage ticket generation strategies.
- Created `ticket_validity_times` table to allow multiple validity times per ticket.
- Introduced `validity_time_id` column in `event_dates` table to associate event dates with validity times.
- Established `ticket_validity_groups` and `ticket_validity_group_times` tables to group validity times for tickets.
- Updated seeder to set default ticket generation policy for specific tenant.
- Enhanced tests to cover new functionality, including ticket generation based on event dates and validity times.
- Refactored ticket validity checks to accommodate multiple validity times in a group.
This commit is contained in:
2026-08-11 12:29:32 -03:00
parent 2115d2384d
commit 5197862a62
41 changed files with 950 additions and 102 deletions

View File

@@ -218,6 +218,8 @@ class CatalogItemDetailControllerTest extends TestCase
->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')
->assertJsonPath('data.attributes.0.options.0.validity_time_id', $eventDate->validity_time_id)
->assertJsonPath('data.attributes.0.options.0.validity_time.type', 'fixed_window')
->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.value', (string) $eventDate->id)

View File

@@ -32,6 +32,7 @@ class CatalogSchemaTest extends TestCase
{
$this->assertEqualsCanonicalizing([
'id',
'validity_time_id',
'tenant_code',
'category_id',
'brand_id',
@@ -44,6 +45,7 @@ class CatalogSchemaTest extends TestCase
'inventory_policy',
'max_units_per_user',
'has_tickets',
'ticket_generation_policy',
'validity_time_id',
], Schema::getColumnListing('catalog_items'));
}

View File

@@ -8,6 +8,7 @@ use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Database\Seeders\AuthorizationSeeder;
use Database\Seeders\SocialMediaSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -45,6 +46,7 @@ class AdminAppEventControllerTest extends TestCase
->assertJsonPath('data.title', 'Festival Acme')
->assertJsonPath('data.location', 'Predio Ferial, Rosario')
->assertJsonPath('data.dates.0.date', '2026-10-09')
->assertJsonPath('data.dates.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.dates.0.start_time', '09:00')
->assertJsonPath('data.dates.0.end_time', '18:30')
->assertJsonPath('data.contact.whatsapp_url', 'https://wa.me/5493415550101')
@@ -64,6 +66,17 @@ class AdminAppEventControllerTest extends TestCase
'time_start' => '09:00:00',
'time_end' => '18:30:00',
]);
$eventDate = $tenant->eventDates()->with('validityTime')->sole();
$this->assertSame($eventDate->validity_time_id, $response->json('data.dates.0.validity_time_id'));
$this->assertSame(ValidityTimeType::FixedWindow, $eventDate->validityTime->type);
$this->assertSame(
'2026-10-09 09:00:00',
$eventDate->validityTime->fixed_starts_at->format('Y-m-d H:i:s'),
);
$this->assertSame(
'2026-10-09 18:30:00',
$eventDate->validityTime->fixed_expires_at->format('Y-m-d H:i:s'),
);
$this->assertDatabaseHas('tenant_social_media', [
'tenant_code' => $tenant->codigo,
'social_media_code' => 'whatsapp',
@@ -110,6 +123,8 @@ class AdminAppEventControllerTest extends TestCase
'time_start' => '08:00',
'time_end' => '12:00',
]);
$firstValidityTimeId = $firstDate->validity_time_id;
$removedValidityTimeId = $removedDate->validity_time_id;
$tenant->socialMedia()->attach('facebook', [
'url' => 'https://facebook.com/old',
'orden' => 2,
@@ -135,9 +150,17 @@ class AdminAppEventControllerTest extends TestCase
$this->assertDatabaseHas('event_dates', [
'id' => $firstDate->id,
'validity_time_id' => $firstValidityTimeId,
'date' => '2026-11-15',
]);
$this->assertDatabaseHas('validity_times', [
'id' => $firstValidityTimeId,
'type' => ValidityTimeType::FixedWindow->value,
'fixed_starts_at' => '2026-11-15 10:00:00',
'fixed_expires_at' => '2026-11-15 20:00:00',
]);
$this->assertDatabaseMissing('event_dates', ['id' => $removedDate->id]);
$this->assertDatabaseMissing('validity_times', ['id' => $removedValidityTimeId]);
$this->assertSame('15 de Noviembre 2026', $tenant->fresh()->event_date_text);
$this->assertDatabaseMissing('tenant_social_media', [
'tenant_code' => $tenant->codigo,

View File

@@ -38,7 +38,7 @@ class AdminAppFoodFormControllerTest extends TestCase
'dominio' => 'fiesta.test',
'website_type_code' => 'onticket',
]);
$tenant->eventDates()->create([
$eventDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
@@ -60,6 +60,8 @@ class AdminAppFoodFormControllerTest extends TestCase
->assertOk()
->assertJsonCount(1, 'data.event_dates')
->assertJsonPath('data.event_dates.0.date', '2026-10-09')
->assertJsonPath('data.event_dates.0.validity_time_id', $eventDate->validity_time_id)
->assertJsonPath('data.event_dates.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.schedules.0.value', 'Almuerzo')
->assertJsonPath('data.schedules.1.value', 'Cena')
->assertJsonPath('data.services.0.value', 'Comedor');

View File

@@ -12,6 +12,7 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Database\Seeders\AttributeSeeder;
use Database\Seeders\FiestaFutbolInfantilProductSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -66,6 +67,12 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
->get()
->every(fn (CatalogItem $item): bool => $item->has_tickets),
);
$this->assertTrue(
CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->get()
->every(fn (CatalogItem $item): bool => $item->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit),
);
$expectedVariantCounts = [
'camiseta' => 12,

View File

@@ -6,7 +6,9 @@ use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
@@ -21,6 +23,22 @@ class TicketControllerTest extends TestCase
$user = User::factory()->create();
$olderTicket = $this->createTicket($tenant, $user, 'Older ticket');
$newerTicket = $this->createTicket($tenant, $user, 'Newer ticket');
$validityTimes = collect([
ValidityTime::query()->create([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subHour(),
'fixed_expires_at' => now()->addHour(),
]),
ValidityTime::query()->create([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->addDay(),
'fixed_expires_at' => now()->addDays(2),
]),
]);
$validityTimes->each(function (ValidityTime $validityTime) use ($newerTicket): void {
$group = $newerTicket->validityGroups()->create();
$group->validityTimes()->attach($validityTime);
});
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/tickets")
@@ -31,6 +49,10 @@ class TicketControllerTest extends TestCase
->assertJsonPath('data.0.is_valid', true)
->assertJsonPath('data.0.is_expired', false)
->assertJsonPath('data.0.is_used', false)
->assertJsonCount(2, 'data.0.validity_times')
->assertJsonCount(2, 'data.0.validity_groups')
->assertJsonCount(1, 'data.0.validity_groups.0.validity_times')
->assertJsonMissingPath('data.0.validity_time')
->assertJsonPath('data.1.id', $olderTicket->id)
->assertJsonMissingPath('meta')
->assertJsonMissingPath('links');

View File

@@ -14,6 +14,7 @@ use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Models\ValidityTime;
@@ -69,7 +70,7 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertSame($item->descripcion, $ticket->description);
$this->assertSame($item->id, $ticket->source_catalog_item_id);
$this->assertNull($ticket->source_variant_id);
$this->assertNull($ticket->validity_time_id);
$this->assertTrue($ticket->validityGroups->isEmpty());
}
}
@@ -231,7 +232,7 @@ class TicketGeneratorServiceTest extends TestCase
]);
}
public function test_event_date_and_time_window_are_snapshotted_as_a_fixed_window(): void
public function test_event_date_and_time_window_are_combined_in_the_same_and_group(): void
{
$item = $this->createTicketableItem('scheduled-meal');
$eventDate = EventDate::query()->create([
@@ -272,13 +273,22 @@ class TicketGeneratorServiceTest extends TestCase
$tickets = $this->service->generate($item, $this->user, 2, $variant->id);
$this->assertCount(2, $tickets);
$this->assertSame(1, $tickets->pluck('validity_time_id')->unique()->count());
$this->assertNotSame($timeWindow->id, $tickets->first()->validity_time_id);
$fixedWindow = $tickets->first()->validityTime;
$this->assertSame(ValidityTimeType::FixedWindow, $fixedWindow->type);
$this->assertSame('2026-08-20 22:00:00', $fixedWindow->fixed_starts_at->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-21 02:00:00', $fixedWindow->fixed_expires_at->format('Y-m-d H:i:s'));
$this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 1));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->validityGroups->sole()->validityTimes
->pluck('id')
->sort()
->values()
->all() === collect([$eventDate->validity_time_id, $timeWindow->id])->sort()->values()->all()
));
$this->assertSame(
'2026-08-20 22:00:00',
$tickets->first()->getEffectiveStartsAt()->format('Y-m-d H:i:s'),
);
$this->assertSame(
'2026-08-20 23:59:59',
$tickets->first()->getEffectiveExpiresAt()->format('Y-m-d H:i:s'),
);
}
public function test_a_multi_date_variant_generates_one_ticket_for_each_selected_date(): void
@@ -296,12 +306,16 @@ class TicketGeneratorServiceTest extends TestCase
$variant->eventDates()->sync($dates->pluck('id'));
$tickets = $this->service->generate($item, $this->user, 1, $variant->id);
$tickets->each->loadMissing('validityTime');
$tickets->each->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $tickets);
$this->assertSame(
$dates->pluck('validity_time_id')->all(),
$tickets->map(fn ($ticket): int => $ticket->validityGroups->sole()->validityTimes->sole()->id)->all(),
);
$this->assertSame(
['2026-08-20 00:00:00', '2026-08-21 00:00:00'],
$tickets->map(fn ($ticket): string => $ticket->validityTime->fixed_starts_at->format('Y-m-d H:i:s'))->all(),
$tickets->map(fn ($ticket): string => $ticket->validityGroups->sole()->validityTimes->sole()->fixed_starts_at->format('Y-m-d H:i:s'))->all(),
);
$this->assertSame(
['Multi-date-pass (20/08/2026)', 'Multi-date-pass (21/08/2026)'],
@@ -310,6 +324,116 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertSame([$variant->id], $tickets->pluck('source_variant_id')->unique()->values()->all());
}
public function test_one_per_unit_policy_generates_one_ticket_covering_all_selected_dates(): void
{
$item = $this->createTicketableItem('multi-date-pass');
$item->update([
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit,
]);
$dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([
'tenant_code' => $this->tenant->codigo,
'date' => $date,
'time_start' => '00:00:00',
'time_end' => '23:59:59',
]));
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->eventDates()->sync($dates->pluck('id'));
$tickets = $this->service->generate($item, $this->user, 2, $variant->id);
$tickets->each->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $tickets);
$this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 2));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->validityGroups->every(
fn ($group): bool => $group->validityTimes->count() === 1
)
));
$this->assertEqualsCanonicalizing(
$dates->pluck('validity_time_id')->all(),
$tickets->flatMap(fn ($ticket) => $ticket->allValidityTimes())->pluck('id')->unique()->all(),
);
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->name === 'Multi-date-pass (20/08/2026, 21/08/2026)'
));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->allValidityTimes()
->map(fn (ValidityTime $validityTime): array => [
$validityTime->fixed_starts_at->format('Y-m-d H:i:s'),
$validityTime->fixed_expires_at->format('Y-m-d H:i:s'),
])
->all() === [
['2026-08-20 00:00:00', '2026-08-20 23:59:59'],
['2026-08-21 00:00:00', '2026-08-21 23:59:59'],
]
));
}
public function test_common_schedule_is_anded_into_each_alternative_event_date_group(): void
{
$item = $this->createTicketableItem('multi-date-lunch');
$item->update(['ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit]);
$dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([
'tenant_code' => $this->tenant->codigo,
'date' => $date,
'time_start' => '09:00:00',
'time_end' => '18:00:00',
]));
$lunch = ValidityTime::query()->create([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '12:00:00',
'end_time' => '15:00:00',
]);
$schedule = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'schedule',
'nombre' => 'Schedule',
'type' => FieldType::Select,
]);
$schedule->options()->create([
'value' => 'lunch',
'label' => 'Lunch',
'validity_time_id' => $lunch->id,
]);
$itemSchedule = $item->itemAttributes()->create(['attribute_id' => $schedule->id]);
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->eventDates()->sync($dates->pluck('id'));
$variant->definitions()->create([
'item_attribute_id' => $itemSchedule->id,
'value' => 'lunch',
]);
$ticket = $this->service->generate($item, $this->user, 1, $variant->id)->sole();
$ticket->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $ticket->validityGroups);
$this->assertTrue($ticket->validityGroups->every(
fn ($group): bool => $group->validityTimes->count() === 2
&& $group->validityTimes->contains($lunch)
));
$this->assertEqualsCanonicalizing(
$dates->pluck('validity_time_id')->all(),
$ticket->validityGroups
->flatMap->validityTimes
->reject(fn (ValidityTime $validityTime): bool => $validityTime->is($lunch))
->pluck('id')
->all(),
);
$this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-21 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
Carbon::setTestNow('2026-08-20 13:00:00');
$this->assertTrue($ticket->isValid());
Carbon::setTestNow('2026-08-20 16:00:00');
$this->assertFalse($ticket->isValid());
$this->assertFalse($ticket->is_expired);
}
public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void
{
Event::fake([TicketsAvailable::class]);

View File

@@ -23,9 +23,16 @@ class TicketValiditySchemaTest extends TestCase
'updated_at',
], Schema::getColumnListing('validity_times'));
$this->assertTrue(Schema::hasColumns('tickets', [
$this->assertFalse(Schema::hasColumn('tickets', 'validity_time_id'));
$this->assertFalse(Schema::hasTable('ticket_validity_times'));
$this->assertEqualsCanonicalizing([
'id',
'ticket_id',
], Schema::getColumnListing('ticket_validity_groups'));
$this->assertEqualsCanonicalizing([
'ticket_validity_group_id',
'validity_time_id',
]));
], Schema::getColumnListing('ticket_validity_group_times'));
$this->assertFalse(Schema::hasColumn('tickets', 'service_date'));
$this->assertFalse(Schema::hasColumn('tickets', 'starts_at'));
$this->assertFalse(Schema::hasColumn('tickets', 'expires_at'));
@@ -34,6 +41,7 @@ class TicketValiditySchemaTest extends TestCase
$this->assertFalse(Schema::hasColumn('catalog_items', 'minimum_use_date'));
$this->assertFalse(Schema::hasColumn('catalog_items', 'maximum_use_date'));
$this->assertTrue(Schema::hasColumn('attribute_options', 'validity_time_id'));
$this->assertTrue(Schema::hasColumn('event_dates', 'validity_time_id'));
$this->assertFalse(Schema::hasColumn('variantes', 'minimum_use_date'));
$this->assertFalse(Schema::hasColumn('variantes', 'maximum_use_date'));