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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)) {

View File

@@ -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<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
/** @return HasMany<Variant, $this> */
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');
}
}

View File

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

View File

@@ -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<string, mixed> $data */
@@ -35,7 +35,7 @@ class EventService
$this->syncLegacyContact($tenant, $data['contact']);
}
return $tenant->load(['eventDates', 'socialMedia']);
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ class AdminAppSaleService
{
return $this->findForTenant($tenant, $saleId)
->tickets()
->with('validityTime')
->with('validityGroups.validityTimes')
->orderBy('id')
->get();
}

View File

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

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Ticket\Enums;
enum TicketGenerationPolicy: string
{
case PerEventDate = 'per_event_date';
case OnePerUnit = 'one_per_unit';
/** @return list<string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -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<ValidityTime, $this> */
public function validityTime(): BelongsTo
/** @return HasMany<TicketValidityGroup, $this> */
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<int, ValidityTime> */
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<int, TicketValidityGroup> */
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;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Fillable(['ticket_id'])]
class TicketValidityGroup extends Model
{
public $timestamps = false;
/** @return BelongsTo<Ticket, $this> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
/** @return BelongsToMany<ValidityTime, $this> */
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<int, ValidityTime> */
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();
}
}

View File

@@ -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<Ticket, $this> */
public function tickets(): HasMany
/** @return HasOne<EventDate, $this> */
public function eventDate(): HasOne
{
return $this->hasMany(Ticket::class);
return $this->hasOne(EventDate::class);
}
/** @return BelongsToMany<TicketValidityGroup, $this> */
public function ticketValidityGroups(): BelongsToMany
{
return $this->belongsToMany(
TicketValidityGroup::class,
'ticket_validity_group_times',
'validity_time_id',
'ticket_validity_group_id',
);
}
public function startsAt(

View File

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

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Ticket\Resources;
use App\Domains\Ticket\Models\TicketValidityGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin TicketValidityGroup */
class TicketValidityGroupResource extends JsonResource
{
/** @return array<string, mixed> */
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(),
];
}
}

View File

@@ -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<string, ValidityTime> $fixedValidityTimes
*/
private function materializeEventDateValidityTime(
/** @return Collection<int, Collection<int, ValidityTime>> */
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();
}
}