diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 70d1645..5e9616e 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -31,6 +32,7 @@ use Illuminate\Support\Collection; 'inventory_policy', 'max_units_per_user', 'has_tickets', + 'ticket_generation_policy', 'validity_time_id', ])] class CatalogItem extends Model @@ -45,6 +47,7 @@ class CatalogItem extends Model 'type' => CatalogItemType::Standard->value, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => false, + 'ticket_generation_policy' => TicketGenerationPolicy::PerEventDate->value, ]; protected function casts(): array @@ -58,6 +61,7 @@ class CatalogItem extends Model 'inventory_policy' => InventoryPolicy::class, 'max_units_per_user' => 'integer', 'has_tickets' => 'boolean', + 'ticket_generation_policy' => TicketGenerationPolicy::class, 'validity_time_id' => 'integer', ]; } diff --git a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php index 4a60875..09c8762 100644 --- a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php +++ b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php @@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Requests; use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Shared\Rules\ImageOrBase64Rule; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -55,6 +56,7 @@ class StoreCatalogItemRequest extends FormRequest 'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)], 'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'], 'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'], + 'ticket_generation_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(TicketGenerationPolicy::class)], 'validity_time_id' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'integer', Rule::exists('validity_times', 'id')], 'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'], 'inventory_id' => ['prohibited'], diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index 06e1125..c303174 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -31,6 +31,7 @@ class CatalogFeaturedItemResource extends JsonResource 'nombre' => $catalogItem->nombre, 'descripcion' => $catalogItem->descripcion, 'precio' => $catalogItem->precio, + 'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value, 'validity_time_id' => $catalogItem->validity_time_id, 'validity_time' => ValidityTimeResource::make($catalogItem->validityTime), 'stock_tecnico' => $catalogItem->availableStock(), @@ -65,6 +66,7 @@ class CatalogFeaturedItemResource extends JsonResource 'type' => $catalogItem->type->value, 'nombre' => $catalogItem->nombre, 'precio' => $catalogItem->precio, + 'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value, 'validity_time_id' => $catalogItem->validity_time_id, 'validity_time' => ValidityTimeResource::make($catalogItem->validityTime), 'image' => $attachment?->getTemporaryUrl(1440), diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index aba2f3c..6fc11c1 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -35,6 +35,7 @@ class CatalogItemDetailResource extends JsonResource 'inventory_policy' => $this->inventory_policy?->value, 'max_units_per_user' => $this->max_units_per_user, 'has_tickets' => $this->has_tickets, + 'ticket_generation_policy' => $this->ticket_generation_policy->value, 'validity_time_id' => $this->validity_time_id, 'validity_time' => $this->validityTime === null ? null @@ -106,8 +107,8 @@ class CatalogItemDetailResource extends JsonResource 'value' => (string) $eventDate->id, 'label' => $eventDate->date->format('d/m/Y'), 'sort_order' => $index, - 'validity_time_id' => null, - 'validity_time' => null, + 'validity_time_id' => $eventDate->validity_time_id, + 'validity_time' => ValidityTimeResource::make($eventDate->validityTime), 'metadata' => [ 'date' => $eventDate->date->format('Y-m-d'), 'time_start' => $eventDate->time_start, diff --git a/app/Domains/Catalog/Resources/CatalogItemResource.php b/app/Domains/Catalog/Resources/CatalogItemResource.php index 38af5a4..e25495a 100644 --- a/app/Domains/Catalog/Resources/CatalogItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemResource.php @@ -25,6 +25,7 @@ class CatalogItemResource extends JsonResource 'inventory_policy' => $this->inventory_policy?->value, 'max_units_per_user' => $this->max_units_per_user, 'has_tickets' => $this->has_tickets, + 'ticket_generation_policy' => $this->ticket_generation_policy->value, 'validity_time_id' => $this->validity_time_id, 'validity_time' => $this->whenLoaded( 'validityTime', diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index a336bfb..ba12b94 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -26,6 +26,7 @@ class CatalogSearchItemResource extends JsonResource 'nombre' => $this->nombre, 'descripcion' => $this->descripcion, 'precio' => $this->precio, + 'ticket_generation_policy' => $this->ticket_generation_policy->value, 'validity_time_id' => $this->validity_time_id, 'validity_time' => ValidityTimeResource::make($this->validityTime), 'image' => $attachment?->getTemporaryUrl(1440), diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index 0619892..f2edfe3 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -167,7 +167,7 @@ class CatalogService 'brand', 'validityTime', 'itemAttributes.attribute.options.validityTime', - 'itemAttributes.attribute.eventDates', + 'itemAttributes.attribute.eventDates.validityTime', 'variants' => fn ($query) => $query->orderBy('id'), 'variants.inventory', 'variants.attachments', @@ -442,6 +442,7 @@ class CatalogService 'attribute_codes', 'variants', 'has_tickets', + 'ticket_generation_policy', 'validity_time_id', ] as $field) { if (array_key_exists($field, $data)) { diff --git a/app/Domains/Event/Models/EventDate.php b/app/Domains/Event/Models/EventDate.php index ce0ede9..665bacd 100644 --- a/app/Domains/Event/Models/EventDate.php +++ b/app/Domains/Event/Models/EventDate.php @@ -5,6 +5,8 @@ namespace App\Domains\Event\Models; use App\Domains\Catalog\Models\Variant; use App\Domains\Event\Services\EventDateTextFormatter; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\ValidityTimeType; +use App\Domains\Ticket\Models\ValidityTime; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -28,14 +30,29 @@ class EventDate extends Model protected static function booted(): void { - static::saved(fn (self $eventDate) => $eventDate->syncTenantDateText()); - static::deleted(fn (self $eventDate) => $eventDate->syncTenantDateText()); + static::creating(fn (self $eventDate) => $eventDate->syncValidityTime()); + static::created(fn (self $eventDate) => $eventDate->syncTenantDateText()); + static::updated(function (self $eventDate): void { + if ($eventDate->wasChanged(['date', 'time_start', 'time_end'])) { + $eventDate->syncValidityTime(); + } + + $eventDate->syncTenantDateText(); + }); + static::deleted(function (self $eventDate): void { + $eventDate->syncTenantDateText(); + ValidityTime::query() + ->whereKey($eventDate->validity_time_id) + ->whereDoesntHave('ticketValidityGroups') + ->delete(); + }); } protected function casts(): array { return [ 'date' => 'date:Y-m-d', + 'validity_time_id' => 'integer', ]; } @@ -45,6 +62,12 @@ class EventDate extends Model return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); } + /** @return BelongsTo */ + public function validityTime(): BelongsTo + { + return $this->belongsTo(ValidityTime::class); + } + /** @return HasMany */ public function variants(): HasMany { @@ -86,4 +109,33 @@ class EventDate extends Model ), ]); } + + private function syncValidityTime(): void + { + $startsAt = $this->startsAt(); + $expiresAt = $this->endsAt(); + + if ($expiresAt->lessThanOrEqualTo($startsAt)) { + $expiresAt = $expiresAt->addDay(); + } + + $attributes = [ + 'type' => ValidityTimeType::FixedWindow, + 'start_time' => null, + 'end_time' => null, + 'fixed_starts_at' => $startsAt, + 'fixed_expires_at' => $expiresAt, + ]; + + if ($this->validity_time_id === null) { + $validityTime = ValidityTime::query()->create($attributes); + $this->validity_time_id = $validityTime->getKey(); + $this->setRelation('validityTime', $validityTime); + + return; + } + + $this->validityTime()->update($attributes); + $this->unsetRelation('validityTime'); + } } diff --git a/app/Domains/Event/Resources/EventResource.php b/app/Domains/Event/Resources/EventResource.php index 4ef58cd..1dd4735 100644 --- a/app/Domains/Event/Resources/EventResource.php +++ b/app/Domains/Event/Resources/EventResource.php @@ -3,6 +3,7 @@ namespace App\Domains\Event\Resources; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -20,6 +21,8 @@ class EventResource extends JsonResource 'location' => $this->event_location, 'dates' => $this->eventDates->map(fn ($eventDate): array => [ 'id' => $eventDate->id, + 'validity_time_id' => $eventDate->validity_time_id, + 'validity_time' => ValidityTimeResource::make($eventDate->validityTime), 'date' => $eventDate->date->format('Y-m-d'), 'start_time' => substr($eventDate->time_start, 0, 5), 'end_time' => substr($eventDate->time_end, 0, 5), diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 793d546..308814e 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -15,7 +15,7 @@ class EventService public function forTenant(Tenant $tenant): Tenant { - return $tenant->load(['eventDates', 'socialMedia']); + return $tenant->load(['eventDates.validityTime', 'socialMedia']); } /** @param array $data */ @@ -35,7 +35,7 @@ class EventService $this->syncLegacyContact($tenant, $data['contact']); } - return $tenant->load(['eventDates', 'socialMedia']); + return $tenant->load(['eventDates.validityTime', 'socialMedia']); }); } diff --git a/app/Domains/FiestaFutbolInfantil/Services/AccommodationService.php b/app/Domains/FiestaFutbolInfantil/Services/AccommodationService.php index 85f0d4a..7d68d98 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/AccommodationService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/AccommodationService.php @@ -12,6 +12,7 @@ use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -136,6 +137,7 @@ class AccommodationService 'category_id' => $category->id, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, ]); return $accommodation; @@ -150,6 +152,7 @@ class AccommodationService 'precio' => collect($variants)->min('price') ?? 0, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, 'inventory_id' => null, ]); } diff --git a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php index b500372..e1d4af1 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php @@ -9,6 +9,7 @@ use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -62,6 +63,7 @@ class EntryService 'category_id' => $category->id, 'precio' => $entry['price'], 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, 'inventory_policy' => InventoryPolicy::Tracked->value, 'attribute_codes' => ['event_date'], 'multi_select_attribute_codes' => ['event_date'], @@ -133,6 +135,7 @@ class EntryService 'category_id' => $category->id, 'precio' => $entry['price'], 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, 'inventory_policy' => InventoryPolicy::Tracked->value, ]); $variant->update([ diff --git a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php index aa32933..09430ef 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php @@ -12,6 +12,7 @@ use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -147,6 +148,7 @@ class FoodService 'category_id' => $category->id, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, ]); return $food; @@ -161,6 +163,7 @@ class FoodService 'precio' => collect($variants)->min('price') ?? 0, 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, 'inventory_id' => null, ]); } diff --git a/app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php b/app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php index 4307e51..bbbb152 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php @@ -12,6 +12,7 @@ use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -74,6 +75,7 @@ class MerchandiseService 'max_units_per_user' => (int) $data['max_units_per_user'], 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, ]); $itemAttributes = $this->itemAttributes($item, $attributes); @@ -198,6 +200,7 @@ class MerchandiseService 'max_units_per_user' => (int) $data['max_units_per_user'], 'inventory_policy' => InventoryPolicy::Tracked->value, 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, 'inventory_id' => null, ]); } diff --git a/app/Domains/Forms/Resources/FoodFormResource.php b/app/Domains/Forms/Resources/FoodFormResource.php index 4aa20eb..611b2b9 100644 --- a/app/Domains/Forms/Resources/FoodFormResource.php +++ b/app/Domains/Forms/Resources/FoodFormResource.php @@ -4,6 +4,7 @@ namespace App\Domains\Forms\Resources; use App\Domains\Catalog\Models\AttributeOption; use App\Domains\Event\Models\EventDate; +use App\Domains\Ticket\Resources\ValidityTimeResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Collection; @@ -17,6 +18,8 @@ class FoodFormResource extends JsonResource 'event_dates' => $this->resource['event_dates']->map( fn (EventDate $eventDate): array => [ 'id' => $eventDate->id, + 'validity_time_id' => $eventDate->validity_time_id, + 'validity_time' => ValidityTimeResource::make($eventDate->validityTime), 'date' => $eventDate->date->format('Y-m-d'), ] )->values(), diff --git a/app/Domains/Forms/Services/FoodFormService.php b/app/Domains/Forms/Services/FoodFormService.php index 03235f5..72f2b07 100644 --- a/app/Domains/Forms/Services/FoodFormService.php +++ b/app/Domains/Forms/Services/FoodFormService.php @@ -27,7 +27,7 @@ class FoodFormService ->keyBy('codigo'); return [ - 'event_dates' => $tenant->eventDates()->get(), + 'event_dates' => $tenant->eventDates()->with('validityTime')->get(), 'schedules' => $attributes->get('horario')?->options ?? new Collection, 'services' => $attributes->get('servicio')?->options ?? new Collection, ]; diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index 35c05e5..d52d83a 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -58,7 +58,7 @@ class AdminAppSaleService { return $this->findForTenant($tenant, $saleId) ->tickets() - ->with('validityTime') + ->with('validityGroups.validityTimes') ->orderBy('id') ->get(); } diff --git a/app/Domains/Ticket/Controllers/TicketController.php b/app/Domains/Ticket/Controllers/TicketController.php index 1ffd50a..85b7456 100644 --- a/app/Domains/Ticket/Controllers/TicketController.php +++ b/app/Domains/Ticket/Controllers/TicketController.php @@ -22,7 +22,7 @@ class TicketController extends Controller $tickets = Ticket::query() ->where('tenant_code', $tenant->codigo) ->where('user_id', $request->user()->getKey()) - ->with('validityTime', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') + ->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') ->orderByDesc('id') ->get(); @@ -36,7 +36,7 @@ class TicketController extends Controller ->where('tenant_code', $tenant->codigo) ->where('user_id', $request->user()->getKey()) ->whereIn('id', $ticketIds) - ->with('validityTime', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') + ->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') ->orderByDesc('id') ->get(); diff --git a/app/Domains/Ticket/Enums/TicketGenerationPolicy.php b/app/Domains/Ticket/Enums/TicketGenerationPolicy.php new file mode 100644 index 0000000..c26a177 --- /dev/null +++ b/app/Domains/Ticket/Enums/TicketGenerationPolicy.php @@ -0,0 +1,15 @@ + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 5c370e0..7c278cd 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -9,9 +9,12 @@ use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Collection; #[Fillable([ 'tenant_code', @@ -21,7 +24,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'source_purchase_id', 'source_catalog_item_id', 'source_variant_id', - 'validity_time_id', 'used_at', 'scanner_user_id', 'user_id', @@ -51,7 +53,6 @@ class Ticket extends Model 'source_catalog_item_id' => 'integer', 'source_variant_id' => 'integer', 'source_purchase_id' => 'integer', - 'validity_time_id' => 'integer', 'used_at' => 'datetime', 'scanner_user_id' => 'integer', 'user_id' => 'integer', @@ -94,18 +95,23 @@ class Ticket extends Model return $this->belongsTo(Variant::class, 'source_variant_id'); } - /** @return BelongsTo */ - public function validityTime(): BelongsTo + /** @return HasMany */ + public function validityGroups(): HasMany { - return $this->belongsTo(ValidityTime::class); + return $this->hasMany(TicketValidityGroup::class); } public function isValid(): bool { - return $this->used_at === null - && ( - $this->validityTime === null - || $this->validityTime->isValid() + if ($this->used_at !== null) { + return false; + } + + $validityGroups = $this->resolvedValidityGroups(); + + return $validityGroups->isEmpty() + || $validityGroups->contains( + fn (TicketValidityGroup $group): bool => $group->isValid() ); } @@ -116,11 +122,13 @@ class Ticket extends Model public function getIsExpiredAttribute(): bool { - $expiresAt = $this->getEffectiveExpiresAt(); + $validityGroups = $this->resolvedValidityGroups(); return $this->used_at === null - && $expiresAt !== null - && $expiresAt->lessThanOrEqualTo(now()); + && $validityGroups->isNotEmpty() + && $validityGroups->every( + fn (TicketValidityGroup $group): bool => $group->isExpired() + ); } public function getIsUsedAttribute(): bool @@ -143,11 +151,50 @@ class Ticket extends Model public function getEffectiveStartsAt(): ?CarbonInterface { - return $this->validityTime?->startsAt(); + return $this->resolvedValidityGroups() + ->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveStartsAt()) + ->filter() + ->sortBy(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) + ->first(); } public function getEffectiveExpiresAt(): ?CarbonInterface { - return $this->validityTime?->expiresAt(); + return $this->resolvedValidityGroups() + ->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveExpiresAt()) + ->filter() + ->sortByDesc(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp()) + ->first(); + } + + /** @return Collection */ + public function allValidityTimes(): Collection + { + return $this->resolvedValidityGroups() + ->flatMap(fn (TicketValidityGroup $group): EloquentCollection => $group->resolvedValidityTimes()) + ->unique( + fn (ValidityTime $validityTime): int => $validityTime->getKey() + ?? spl_object_id($validityTime) + ) + ->values(); + } + + /** @return EloquentCollection */ + public function resolvedValidityGroups(): EloquentCollection + { + if ($this->relationLoaded('validityGroups')) { + return $this->getRelation('validityGroups'); + } + + if (! $this->exists) { + return new EloquentCollection; + } + + $validityGroups = $this->validityGroups() + ->with('validityTimes') + ->get(); + $this->setRelation('validityGroups', $validityGroups); + + return $validityGroups; } } diff --git a/app/Domains/Ticket/Models/TicketValidityGroup.php b/app/Domains/Ticket/Models/TicketValidityGroup.php new file mode 100644 index 0000000..8e14d4f --- /dev/null +++ b/app/Domains/Ticket/Models/TicketValidityGroup.php @@ -0,0 +1,117 @@ + */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + /** @return BelongsToMany */ + public function validityTimes(): BelongsToMany + { + return $this->belongsToMany( + ValidityTime::class, + 'ticket_validity_group_times', + 'ticket_validity_group_id', + 'validity_time_id', + ); + } + + public function isValid(?CarbonInterface $at = null): bool + { + $at ??= now(); + $validityTimes = $this->resolvedValidityTimes(); + + return $validityTimes->isNotEmpty() + && $validityTimes->every( + fn (ValidityTime $validityTime): bool => $validityTime->isValid($at) + ); + } + + public function isExpired(?CarbonInterface $at = null): bool + { + $at ??= now(); + $expiresAt = $this->effectiveExpiresAt($at); + + return $expiresAt !== null && $expiresAt->lessThanOrEqualTo($at); + } + + public function effectiveStartsAt(?CarbonInterface $at = null): ?CarbonInterface + { + $at ??= now(); + $anchor = $this->dateAnchor() ?? $at; + + return $this->resolvedValidityTimes() + ->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->startsAt($anchor)) + ->filter() + ->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) + ->first(); + } + + public function effectiveExpiresAt(?CarbonInterface $at = null): ?CarbonInterface + { + $at ??= now(); + $anchor = $this->dateAnchor() ?? $at; + + return $this->resolvedValidityTimes() + ->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface { + $startsAt = $validityTime->startsAt($anchor); + $expiresAt = $validityTime->expiresAt($anchor); + + if ( + $startsAt !== null + && $expiresAt !== null + && $expiresAt->lessThanOrEqualTo($startsAt) + ) { + return $expiresAt->addDay(); + } + + return $expiresAt; + }) + ->filter() + ->sortBy(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp()) + ->first(); + } + + /** @return EloquentCollection */ + public function resolvedValidityTimes(): EloquentCollection + { + if ($this->relationLoaded('validityTimes')) { + return $this->getRelation('validityTimes'); + } + + if (! $this->exists) { + return new EloquentCollection; + } + + $validityTimes = $this->validityTimes()->get(); + $this->setRelation('validityTimes', $validityTimes); + + return $validityTimes; + } + + private function dateAnchor(): ?CarbonInterface + { + return $this->resolvedValidityTimes() + ->filter(fn (ValidityTime $validityTime): bool => $validityTime->type === ValidityTimeType::FixedWindow) + ->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->fixed_starts_at) + ->filter() + ->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp()) + ->first(); + } +} diff --git a/app/Domains/Ticket/Models/ValidityTime.php b/app/Domains/Ticket/Models/ValidityTime.php index d36e2db..5210091 100644 --- a/app/Domains/Ticket/Models/ValidityTime.php +++ b/app/Domains/Ticket/Models/ValidityTime.php @@ -4,13 +4,16 @@ namespace App\Domains\Ticket\Models; use App\Domains\Catalog\Models\AttributeOption; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Event\Models\EventDate; use App\Domains\Ticket\Enums\ValidityTimeType; use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; #[Fillable([ 'type', @@ -44,10 +47,21 @@ class ValidityTime extends Model return $this->hasMany(AttributeOption::class); } - /** @return HasMany */ - public function tickets(): HasMany + /** @return HasOne */ + public function eventDate(): HasOne { - return $this->hasMany(Ticket::class); + return $this->hasOne(EventDate::class); + } + + /** @return BelongsToMany */ + public function ticketValidityGroups(): BelongsToMany + { + return $this->belongsToMany( + TicketValidityGroup::class, + 'ticket_validity_group_times', + 'validity_time_id', + 'ticket_validity_group_id', + ); } public function startsAt( diff --git a/app/Domains/Ticket/Resources/TicketResource.php b/app/Domains/Ticket/Resources/TicketResource.php index ab9adcb..8d6c074 100644 --- a/app/Domains/Ticket/Resources/TicketResource.php +++ b/app/Domains/Ticket/Resources/TicketResource.php @@ -20,8 +20,10 @@ class TicketResource extends JsonResource 'description' => $this->description, 'source_catalog_item_id' => $this->source_catalog_item_id, 'source_variant_id' => $this->source_variant_id, - 'validity_time_id' => $this->validity_time_id, - 'validity_time' => ValidityTimeResource::make($this->validityTime), + 'validity_times' => ValidityTimeResource::collection($this->allValidityTimes()), + 'validity_groups' => TicketValidityGroupResource::collection( + $this->resolvedValidityGroups() + ), 'starts_at' => $this->getEffectiveStartsAt(), 'expires_at' => $this->getEffectiveExpiresAt(), 'used_at' => $this->used_at, diff --git a/app/Domains/Ticket/Resources/TicketValidityGroupResource.php b/app/Domains/Ticket/Resources/TicketValidityGroupResource.php new file mode 100644 index 0000000..c56383c --- /dev/null +++ b/app/Domains/Ticket/Resources/TicketValidityGroupResource.php @@ -0,0 +1,24 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'validity_times' => ValidityTimeResource::collection($this->resolvedValidityTimes()), + 'starts_at' => $this->effectiveStartsAt(), + 'expires_at' => $this->effectiveExpiresAt(), + 'is_valid' => $this->isValid(), + 'is_expired' => $this->isExpired(), + ]; + } +} diff --git a/app/Domains/Ticket/Services/TicketGeneratorService.php b/app/Domains/Ticket/Services/TicketGeneratorService.php index b169fac..a4bc4ca 100644 --- a/app/Domains/Ticket/Services/TicketGeneratorService.php +++ b/app/Domains/Ticket/Services/TicketGeneratorService.php @@ -6,10 +6,12 @@ use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; use App\Domains\Event\Models\EventDate; -use App\Domains\Ticket\Enums\ValidityTimeType; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketValidityGroup; use App\Domains\Ticket\Models\ValidityTime; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -36,25 +38,22 @@ class TicketGeneratorService $quantity, $sourceVariantId, ); - $fixedValidityTimes = []; return $targets->map(function (array $target) use ( - &$fixedValidityTimes, $sourcePurchaseId, $user, ): Ticket { $item = $target['catalog_item']; $variant = $target['variant']; $eventDate = $target['event_date']; - $validityTime = $this->resolveValidityTime($item, $variant); - $validityTime = $this->materializeEventDateValidityTime( + $validityGroups = $this->buildTicketValidityGroups( + $item, $variant, $eventDate, - $validityTime, - $fixedValidityTimes, + $this->resolveValidityTime($item, $variant), ); - return Ticket::query()->create([ + $ticket = Ticket::query()->create([ 'tenant_code' => $item->tenant_code, 'ticket' => (string) Str::uuid(), 'name' => $this->ticketName($item, $variant, $eventDate), @@ -62,10 +61,28 @@ class TicketGeneratorService 'source_purchase_id' => $sourcePurchaseId, 'source_catalog_item_id' => $item->getKey(), 'source_variant_id' => $variant?->getKey(), - 'validity_time_id' => $validityTime?->getKey(), 'used_at' => null, 'user_id' => $user->getKey(), ]); + + $groups = $validityGroups->map(function (Collection $validityTimes) use ($ticket): TicketValidityGroup { + $group = $ticket->validityGroups()->create(); + $group->validityTimes()->attach( + $validityTimes + ->map(fn (ValidityTime $validityTime): int => $validityTime->getKey()) + ->all() + ); + $group->setRelation( + 'validityTimes', + new EloquentCollection($validityTimes->all()), + ); + + return $group; + }); + + $ticket->setRelation('validityGroups', new EloquentCollection($groups->all())); + + return $ticket; }); }); } @@ -125,8 +142,21 @@ class TicketGeneratorService ]); } - $variant->loadMissing(['eventDates', 'eventDate']); + $variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']); $selectedEventDates = $variant->selectedEventDates(); + + if ($catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit) { + $eventDate = $selectedEventDates->count() === 1 + ? $selectedEventDates->first() + : null; + + return Collection::times($quantity, fn (): array => [ + 'catalog_item' => $catalogItem, + 'variant' => $variant, + 'event_date' => $eventDate, + ]); + } + $eventDates = $selectedEventDates->isEmpty() ? collect([null]) : $selectedEventDates; @@ -246,52 +276,37 @@ class TicketGeneratorService return $catalogItem->nombre.' ('.$properties->implode(', ').')'; } - /** - * Convert a recurring time window into the fixed date and time purchased by - * the customer. Equal tickets generated together share the same snapshot. - * - * @param array $fixedValidityTimes - */ - private function materializeEventDateValidityTime( + /** @return Collection> */ + private function buildTicketValidityGroups( + CatalogItem $catalogItem, ?Variant $variant, ?EventDate $eventDate, ?ValidityTime $validityTime, - array &$fixedValidityTimes, - ): ?ValidityTime { - if ( - $variant === null - || $eventDate === null - ) { - return $validityTime; - } - - if ($validityTime !== null && $validityTime->type !== ValidityTimeType::TimeWindow) { - return $validityTime; - } - - $cacheKey = $eventDate->getKey().':'.($validityTime?->getKey() ?? 'event'); - - if (isset($fixedValidityTimes[$cacheKey])) { - return $fixedValidityTimes[$cacheKey]; - } - - $startsAt = $validityTime?->startsAt($eventDate->date) ?? $eventDate->startsAt(); - $expiresAt = $validityTime?->expiresAt($eventDate->date) ?? $eventDate->endsAt(); + ): Collection { + $eventDates = collect([$eventDate]); if ( - $startsAt !== null - && $expiresAt !== null - && $expiresAt->lessThanOrEqualTo($startsAt) + $catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit + && $variant !== null ) { - $expiresAt = $expiresAt->addDay(); + $variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']); + $selectedEventDates = $variant->selectedEventDates(); + + if ($selectedEventDates->isNotEmpty()) { + $eventDates = $selectedEventDates; + } } - return $fixedValidityTimes[$cacheKey] = ValidityTime::query()->create([ - 'type' => ValidityTimeType::FixedWindow, - 'start_time' => null, - 'end_time' => null, - 'fixed_starts_at' => $startsAt, - 'fixed_expires_at' => $expiresAt, - ]); + return $eventDates + ->map(function (?EventDate $date) use ($validityTime): Collection { + $date?->loadMissing('validityTime'); + + return collect([$date?->validityTime, $validityTime]) + ->filter() + ->unique(fn (ValidityTime $time): int => $time->getKey()) + ->values(); + }) + ->filter(fn (Collection $group): bool => $group->isNotEmpty()) + ->values(); } } diff --git a/database/migrations/2026_08_11_130000_add_ticket_generation_policy_to_catalog_items.php b/database/migrations/2026_08_11_130000_add_ticket_generation_policy_to_catalog_items.php new file mode 100644 index 0000000..5a5d18f --- /dev/null +++ b/database/migrations/2026_08_11_130000_add_ticket_generation_policy_to_catalog_items.php @@ -0,0 +1,32 @@ +enum('ticket_generation_policy', TicketGenerationPolicy::values()) + ->default(TicketGenerationPolicy::PerEventDate->value) + ->after('has_tickets'); + }); + + DB::table('catalog_items') + ->where('tenant_code', 'fiesta_futbol_infantil') + ->update([ + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, + ]); + } + + public function down(): void + { + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropColumn('ticket_generation_policy'); + }); + } +}; diff --git a/database/migrations/2026_08_11_140000_allow_multiple_validity_times_per_ticket.php b/database/migrations/2026_08_11_140000_allow_multiple_validity_times_per_ticket.php new file mode 100644 index 0000000..a7d973f --- /dev/null +++ b/database/migrations/2026_08_11_140000_allow_multiple_validity_times_per_ticket.php @@ -0,0 +1,63 @@ +foreignId('ticket_id') + ->constrained('tickets') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->foreignId('validity_time_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->restrictOnDelete(); + + $table->primary(['ticket_id', 'validity_time_id']); + }); + + DB::table('ticket_validity_times')->insertUsing( + ['ticket_id', 'validity_time_id'], + DB::table('tickets') + ->select(['id', 'validity_time_id']) + ->whereNotNull('validity_time_id'), + ); + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropConstrainedForeignId('validity_time_id'); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->foreignId('validity_time_id') + ->nullable() + ->after('source_variant_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + + DB::table('ticket_validity_times') + ->orderBy('ticket_id') + ->orderBy('validity_time_id') + ->get() + ->groupBy('ticket_id') + ->each(function ($validityTimes, int|string $ticketId): void { + DB::table('tickets') + ->where('id', $ticketId) + ->update([ + 'validity_time_id' => $validityTimes->first()->validity_time_id, + ]); + }); + + Schema::dropIfExists('ticket_validity_times'); + } +}; diff --git a/database/migrations/2026_08_11_150000_add_validity_time_to_event_dates.php b/database/migrations/2026_08_11_150000_add_validity_time_to_event_dates.php new file mode 100644 index 0000000..724fc77 --- /dev/null +++ b/database/migrations/2026_08_11_150000_add_validity_time_to_event_dates.php @@ -0,0 +1,68 @@ +unsignedBigInteger('validity_time_id') + ->nullable() + ->unique() + ->after('id'); + }); + + DB::table('event_dates') + ->orderBy('id') + ->each(function (object $eventDate): void { + $startsAt = $eventDate->date.' '.$eventDate->time_start; + $expiresAt = $eventDate->date.' '.$eventDate->time_end; + + if (strtotime($expiresAt) <= strtotime($startsAt)) { + $expiresAt = date('Y-m-d H:i:s', strtotime($expiresAt.' +1 day')); + } + + $validityTimeId = DB::table('validity_times')->insertGetId([ + 'type' => 'fixed_window', + 'start_time' => null, + 'end_time' => null, + 'fixed_starts_at' => $startsAt, + 'fixed_expires_at' => $expiresAt, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('event_dates') + ->where('id', $eventDate->id) + ->update(['validity_time_id' => $validityTimeId]); + }); + + Schema::table('event_dates', function (Blueprint $table): void { + $table->unsignedBigInteger('validity_time_id')->nullable(false)->change(); + $table->foreign('validity_time_id') + ->references('id') + ->on('validity_times') + ->restrictOnDelete(); + }); + } + + public function down(): void + { + $validityTimeIds = DB::table('event_dates') + ->pluck('validity_time_id') + ->filter() + ->all(); + + Schema::table('event_dates', function (Blueprint $table): void { + $table->dropForeign(['validity_time_id']); + $table->dropUnique(['validity_time_id']); + $table->dropColumn('validity_time_id'); + }); + + DB::table('validity_times')->whereIn('id', $validityTimeIds)->delete(); + } +}; diff --git a/database/migrations/2026_08_11_160000_group_ticket_validity_times.php b/database/migrations/2026_08_11_160000_group_ticket_validity_times.php new file mode 100644 index 0000000..393563c --- /dev/null +++ b/database/migrations/2026_08_11_160000_group_ticket_validity_times.php @@ -0,0 +1,88 @@ +id(); + $table->foreignId('ticket_id') + ->constrained('tickets') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + }); + + Schema::create('ticket_validity_group_times', function (Blueprint $table): void { + $table->foreignId('ticket_validity_group_id') + ->constrained('ticket_validity_groups') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->foreignId('validity_time_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->restrictOnDelete(); + + $table->primary(['ticket_validity_group_id', 'validity_time_id']); + }); + + // Every former pivot row was an OR alternative, so each one becomes + // an independent group to preserve existing ticket behavior. + DB::table('ticket_validity_times') + ->orderBy('ticket_id') + ->orderBy('validity_time_id') + ->each(function (object $association): void { + $groupId = DB::table('ticket_validity_groups')->insertGetId([ + 'ticket_id' => $association->ticket_id, + ]); + + DB::table('ticket_validity_group_times')->insert([ + 'ticket_validity_group_id' => $groupId, + 'validity_time_id' => $association->validity_time_id, + ]); + }); + + Schema::dropIfExists('ticket_validity_times'); + } + + public function down(): void + { + Schema::create('ticket_validity_times', function (Blueprint $table): void { + $table->foreignId('ticket_id') + ->constrained('tickets') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->foreignId('validity_time_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->restrictOnDelete(); + + $table->primary(['ticket_id', 'validity_time_id']); + }); + + DB::table('ticket_validity_group_times') + ->join( + 'ticket_validity_groups', + 'ticket_validity_groups.id', + '=', + 'ticket_validity_group_times.ticket_validity_group_id', + ) + ->select([ + 'ticket_validity_groups.ticket_id', + 'ticket_validity_group_times.validity_time_id', + ]) + ->distinct() + ->orderBy('ticket_validity_groups.ticket_id') + ->each(fn (object $association) => DB::table('ticket_validity_times')->insert([ + 'ticket_id' => $association->ticket_id, + 'validity_time_id' => $association->validity_time_id, + ])); + + Schema::dropIfExists('ticket_validity_group_times'); + Schema::dropIfExists('ticket_validity_groups'); + } +}; diff --git a/database/seeders/FiestaFutbolInfantilProductSeeder.php b/database/seeders/FiestaFutbolInfantilProductSeeder.php index dc3c9fe..5a518a7 100644 --- a/database/seeders/FiestaFutbolInfantilProductSeeder.php +++ b/database/seeders/FiestaFutbolInfantilProductSeeder.php @@ -11,6 +11,7 @@ use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\TicketGenerationPolicy; use Illuminate\Database\Seeder; use RuntimeException; @@ -145,6 +146,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder 'inventory_policy' => InventoryPolicy::Tracked->value, ...$data, 'has_tickets' => true, + 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value, ]); } diff --git a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php index 3d409de..18e9c28 100644 --- a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php @@ -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) diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php index 93c867f..28a01d8 100644 --- a/tests/Feature/Catalog/CatalogSchemaTest.php +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -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')); } diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 3d317b1..cdf9a98 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -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, diff --git a/tests/Feature/Forms/AdminAppFoodFormControllerTest.php b/tests/Feature/Forms/AdminAppFoodFormControllerTest.php index 78553c9..f3e9db3 100644 --- a/tests/Feature/Forms/AdminAppFoodFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppFoodFormControllerTest.php @@ -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'); diff --git a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php index 615c30e..0a5ceee 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -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, diff --git a/tests/Feature/Ticket/TicketControllerTest.php b/tests/Feature/Ticket/TicketControllerTest.php index a04a1dd..5653062 100644 --- a/tests/Feature/Ticket/TicketControllerTest.php +++ b/tests/Feature/Ticket/TicketControllerTest.php @@ -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'); diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index 4e2c1b4..f2082c6 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -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]); diff --git a/tests/Feature/Ticket/TicketValiditySchemaTest.php b/tests/Feature/Ticket/TicketValiditySchemaTest.php index e6cce20..c327a72 100644 --- a/tests/Feature/Ticket/TicketValiditySchemaTest.php +++ b/tests/Feature/Ticket/TicketValiditySchemaTest.php @@ -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')); diff --git a/tests/Unit/Event/EventModelsTest.php b/tests/Unit/Event/EventModelsTest.php index de442d5..0650596 100644 --- a/tests/Unit/Event/EventModelsTest.php +++ b/tests/Unit/Event/EventModelsTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Event; use App\Domains\Catalog\Models\Variant; use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Models\ValidityTime; use Tests\TestCase; class EventModelsTest extends TestCase @@ -14,6 +15,7 @@ class EventModelsTest extends TestCase $eventDate = new EventDate; $eventDate->setRawAttributes([ 'tenant_code' => 'acme', + 'validity_time_id' => '12', 'date' => '2026-10-09', 'time_start' => '09:00:00', 'time_end' => '18:30:00', @@ -21,9 +23,12 @@ class EventModelsTest extends TestCase $this->assertFalse($eventDate->usesTimestamps()); $this->assertSame('acme', $eventDate->tenant_code); + $this->assertSame(12, $eventDate->validity_time_id); $this->assertSame('2026-10-09 09:00:00', $eventDate->startsAt()->format('Y-m-d H:i:s')); $this->assertSame('2026-10-09 18:30:00', $eventDate->endsAt()->format('Y-m-d H:i:s')); $this->assertInstanceOf(Tenant::class, $eventDate->tenant()->getRelated()); + $this->assertInstanceOf(ValidityTime::class, $eventDate->validityTime()->getRelated()); + $this->assertInstanceOf(EventDate::class, (new ValidityTime)->eventDate()->getRelated()); $this->assertInstanceOf(Variant::class, $eventDate->variants()->getRelated()); } diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index 8db6f51..7163ec8 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -8,7 +8,9 @@ use App\Domains\Catalog\Models\Variant; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketValidityGroup; use App\Domains\Ticket\Models\ValidityTime; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Carbon; use Tests\TestCase; @@ -27,7 +29,6 @@ class TicketTest extends TestCase $ticket->setRawAttributes([ 'source_catalog_item_id' => '20', 'source_variant_id' => '30', - 'validity_time_id' => '40', 'used_at' => null, 'scanner_user_id' => '15', 'user_id' => '10', @@ -37,7 +38,6 @@ class TicketTest extends TestCase $this->assertFalse($ticket->usesTimestamps()); $this->assertSame(20, $ticket->source_catalog_item_id); $this->assertSame(30, $ticket->source_variant_id); - $this->assertSame(40, $ticket->validity_time_id); $this->assertNull($ticket->used_at); $this->assertSame(15, $ticket->scanner_user_id); $this->assertSame(10, $ticket->user_id); @@ -46,7 +46,8 @@ class TicketTest extends TestCase $this->assertInstanceOf(User::class, $ticket->scannerUser()->getRelated()); $this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated()); $this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated()); - $this->assertInstanceOf(ValidityTime::class, $ticket->validityTime()->getRelated()); + $this->assertInstanceOf(TicketValidityGroup::class, $ticket->validityGroups()->getRelated()); + $this->assertInstanceOf(ValidityTime::class, (new TicketValidityGroup)->validityTimes()->getRelated()); } public function test_unused_ticket_without_validity_time_is_valid(): void @@ -73,12 +74,11 @@ class TicketTest extends TestCase public function test_time_window_is_resolved_for_current_date(): void { Carbon::setTestNow('2026-07-21 12:00:00'); - $ticket = new Ticket; - $ticket->setRelation('validityTime', new ValidityTime([ + $ticket = $this->ticketWithValidityGroups([[new ValidityTime([ 'type' => ValidityTimeType::TimeWindow, 'start_time' => '10:00:00', 'end_time' => '14:00:00', - ])); + ])]]); $this->assertSame('2026-07-21 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); $this->assertSame('2026-07-21 14:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); @@ -98,6 +98,49 @@ class TicketTest extends TestCase $this->assertSame(Ticket::STATUS_EXPIRED, $ticket->status); } + public function test_ticket_is_valid_when_any_validity_time_is_active(): void + { + Carbon::setTestNow('2026-07-21 10:00:00'); + $ticket = $this->ticketWithValidityGroups([ + [new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->subDays(2), + 'fixed_expires_at' => now()->subDay(), + ])], + [new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->subHour(), + 'fixed_expires_at' => now()->addHour(), + ])], + ]); + + $this->assertTrue($ticket->isValid()); + $this->assertFalse($ticket->is_expired); + $this->assertSame('2026-07-19 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-07-21 11:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); + } + + public function test_ticket_is_invalid_but_not_expired_between_validity_times(): void + { + Carbon::setTestNow('2026-07-21 10:00:00'); + $ticket = $this->ticketWithValidityGroups([ + [new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->subDays(2), + 'fixed_expires_at' => now()->subDay(), + ])], + [new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->addDay(), + 'fixed_expires_at' => now()->addDays(2), + ])], + ]); + + $this->assertFalse($ticket->isValid()); + $this->assertFalse($ticket->is_expired); + $this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status); + } + public function test_used_ticket_is_invalid_and_not_reported_as_expired(): void { $ticket = $this->ticketWithValidityTime(new ValidityTime([ @@ -112,10 +155,48 @@ class TicketTest extends TestCase $this->assertSame(Ticket::STATUS_USED, $ticket->status); } + public function test_all_validity_times_in_the_same_group_must_be_active(): void + { + Carbon::setTestNow('2026-08-20 13:00:00'); + $ticket = $this->ticketWithValidityGroups([[ + new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => '2026-08-20 09:00:00', + 'fixed_expires_at' => '2026-08-20 18:00:00', + ]), + new ValidityTime([ + 'type' => ValidityTimeType::TimeWindow, + 'start_time' => '12:00:00', + 'end_time' => '15:00:00', + ]), + ]]); + + $this->assertTrue($ticket->isValid()); + $this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-08-20 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); + + Carbon::setTestNow('2026-08-20 16:00:00'); + + $this->assertFalse($ticket->isValid()); + $this->assertTrue($ticket->is_expired); + } + private function ticketWithValidityTime(ValidityTime $validityTime): Ticket + { + return $this->ticketWithValidityGroups([[$validityTime]]); + } + + /** @param array> $validityGroups */ + private function ticketWithValidityGroups(array $validityGroups): Ticket { $ticket = new Ticket; - $ticket->setRelation('validityTime', $validityTime); + $groups = collect($validityGroups)->map(function (array $validityTimes): TicketValidityGroup { + $group = new TicketValidityGroup; + $group->setRelation('validityTimes', new EloquentCollection($validityTimes)); + + return $group; + }); + $ticket->setRelation('validityGroups', new EloquentCollection($groups->all())); return $ticket; } diff --git a/tests/Unit/Ticket/ValidityTimeTest.php b/tests/Unit/Ticket/ValidityTimeTest.php index 567b04a..f5d2a25 100644 --- a/tests/Unit/Ticket/ValidityTimeTest.php +++ b/tests/Unit/Ticket/ValidityTimeTest.php @@ -5,7 +5,7 @@ namespace Tests\Unit\Ticket; use App\Domains\Catalog\Models\AttributeOption; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Ticket\Enums\ValidityTimeType; -use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketValidityGroup; use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Support\Carbon; use Tests\TestCase; @@ -26,7 +26,10 @@ class ValidityTimeTest extends TestCase $this->assertInstanceOf(Carbon::class, $validityTime->fixed_expires_at); $this->assertInstanceOf(CatalogItem::class, $validityTime->catalogItems()->getRelated()); $this->assertInstanceOf(AttributeOption::class, $validityTime->attributeOptions()->getRelated()); - $this->assertInstanceOf(Ticket::class, $validityTime->tickets()->getRelated()); + $this->assertInstanceOf( + TicketValidityGroup::class, + $validityTime->ticketValidityGroups()->getRelated(), + ); } public function test_it_exposes_supported_type_values(): void