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:
parent
2115d2384d
commit
5197862a62
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class AdminAppSaleService
|
|||
{
|
||||
return $this->findForTenant($tenant, $saleId)
|
||||
->tickets()
|
||||
->with('validityTime')
|
||||
->with('validityGroups.validityTimes')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('catalog_items', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): 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_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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('event_dates', function (Blueprint $table): void {
|
||||
$table->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();
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ticket_validity_groups', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<int, array<int, ValidityTime>> $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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue