*/ public function reservations(User $user, array $filters = []): LengthAwarePaginator { abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); return $this->reservationsQuery($user, $filters) ->paginate( perPage: $filters['per_page'] ?? 15, pageName: 'page', page: $filters['page'] ?? 1, ) ->withQueryString(); } /** * @param array{tipo_pago?: string|null} $filters * @return Collection */ public function reservationsForExport(User $user, array $filters = []): Collection { abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); return $this->reservationsQuery($user, $filters)->get(); } public function reservationTicket(User $user, int $reservationId): Ticket { abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); $reservation = EntryReservation::query() ->whereKey($reservationId) ->whereNotNull('ticket_id') ->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query ->where('tenant_code', $user->tenant_codigo) ->where('slug', 'entrada')) ->with(['ticket' => fn ($query) => $query->with([ ...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, ])]) ->firstOrFail(); return $reservation->ticket; } public function cancel(User $user, int $reservationId): void { abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); DB::transaction(function () use ($user, $reservationId): void { $reservation = EntryReservation::query() ->whereKey($reservationId) ->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query ->where('tenant_code', $user->tenant_codigo) ->where('slug', 'entrada')) ->lockForUpdate() ->firstOrFail(); if ($reservation->ticket_id !== null) { $ticket = Ticket::query()->lockForUpdate()->findOrFail($reservation->ticket_id); if (! $ticket->can_cancel()) { throw ValidationException::withMessages([ 'status' => 'El ticket debe estar activo para poder cancelar la reserva.', ]); } $ticket->markAsCancelled(); $ticket->save(); } if ($reservation->inventory_id !== null) { $inventory = Inventory::query()->lockForUpdate()->findOrFail($reservation->inventory_id); $inventory->releaseEntry(1); } $reservation->delete(); }, 3); } /** @param array{tipo_pago?: string|null} $filters */ private function reservationsQuery(User $user, array $filters = []): Builder { return EntryReservation::query() ->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query ->where('tenant_code', $user->tenant_codigo) ->where('slug', 'entrada')) ->when( $filters['tipo_pago'] ?? null, fn (Builder $query, string $paymentType): Builder => $query->where('tipo_pago', $paymentType), ) ->with([ 'variant.catalogItem.itemAttributes.attribute.options', 'variant.definitions.itemAttribute.attribute.options', 'variant.eventDates', 'variant.eventDate', ]) ->orderByDesc('fecha_reserva') ->orderByDesc('id'); } /** @param list $rows */ public function reserve(User $user, string $key, array $rows): Collection { abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); $normalized = collect($rows)->map(fn (array $row): array => [ 'variant_id' => (int) $row['variant_id'], 'tipo_pago' => $row['tipo_pago'], ])->sortBy('variant_id')->values()->all(); $hash = hash('sha256', json_encode($normalized, JSON_THROW_ON_ERROR)); return DB::transaction(function () use ($user, $key, $rows, $hash): Collection { // Serialize retries by the same administrator, including the first insert. User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail(); $batch = DB::table('desfile_reservation_batches') ->where('user_id', $user->id)->where('idempotency_key', $key)->lockForUpdate()->first(); if ($batch !== null) { abort_unless($batch->tenant_code === $user->tenant_codigo && hash_equals($batch->request_hash, $hash), 409, 'La clave de envío ya fue utilizada con otras entradas.'); return EntryReservation::query()->where('batch_id', $batch->id)->with('ticket')->orderBy('id')->get(); } $tenant = $user->tenant()->firstOrFail(); $entry = CatalogItem::query()->forTenantCatalog($tenant)->where('slug', 'entrada') ->lockForUpdate()->firstOrFail(); $ids = array_column($rows, 'variant_id'); $variants = $entry->variants()->whereKey($ids)->orderBy('id')->lockForUpdate()->get(); $inventories = Inventory::query()->whereKey($variants->pluck('inventory_id')->filter()->unique()) ->orderBy('id')->lockForUpdate()->get()->keyBy('id'); $variants->load([ 'eventDates', 'eventDate', 'desfileEntryReservations' => fn ($query) => $query->lockForUpdate(), ]); foreach ($variants as $variant) { $variant->setRelation('inventory', $inventories->get($variant->inventory_id)); } $entry->setRelation('variants', $variants); $available = $entry->visibleVariants()->keyBy('id'); $requirements = []; $errors = []; foreach ($rows as $index => $row) { $variant = $available->get($row['variant_id']); if ($variant === null || $variant->inventory === null) { $errors["rows.{$index}.variant_id"] = $this->entryLabel($variants->firstWhere('id', $row['variant_id']), $index).': la entrada ya no está disponible.'; continue; } $requirements[$variant->inventory_id] = ($requirements[$variant->inventory_id] ?? 0) + 1; } if ($errors !== []) { throw ValidationException::withMessages($errors); } $tracked = $entry->inventory_policy !== InventoryPolicy::Unlimited; foreach ($requirements as $inventoryId => $quantity) { if ($tracked && $inventories[$inventoryId]->availableStock() < $quantity) { foreach ($rows as $index => $row) { $variant = $available[$row['variant_id']]; if ($variant->inventory_id === $inventoryId) { $errors["rows.{$index}.variant_id"] = $this->entryLabel($variant, $index).': no hay stock suficiente para reservar las entradas seleccionadas.'; } } } } if ($errors !== []) { throw ValidationException::withMessages($errors); } $batchId = DB::table('desfile_reservation_batches')->insertGetId([ 'user_id' => $user->id, 'tenant_code' => $tenant->codigo, 'idempotency_key' => $key, 'request_hash' => $hash, 'created_at' => now(), 'updated_at' => now(), ]); foreach ($requirements as $inventoryId => $quantity) { $inventories[$inventoryId]->reserveEntry($quantity, $tracked); } $reservations = collect(); foreach ($rows as $index => $row) { $variant = $available[$row['variant_id']]; $payment = EntryReservationPaymentType::from($row['tipo_pago']); try { $ticket = $this->tickets->generate($entry, $user, 1, $variant->id)->sole(); } catch (TicketGenerationException $exception) { throw ValidationException::withMessages([ "rows.{$index}.variant_id" => $this->entryLabel($variant, $index).': no se pudo emitir el ticket. '.$exception->getMessage(), ]); } $reservation = EntryReservation::query()->create([ 'batch_id' => $batchId, 'ticket_id' => $ticket->id, 'variant_id' => $variant->id, 'inventory_id' => $variant->inventory_id, 'fecha_reserva' => now(), 'tipo_pago' => $payment, 'importe' => $payment === EntryReservationPaymentType::Free ? 0 : $variant->getPrice(), ]); $reservations->push($reservation->setRelation('ticket', $ticket)); } return $reservations; }, 3); } private function entryLabel(?Variant $variant, int $index): string { if ($variant === null) { return 'Entrada '.($index + 1); } $values = $variant->selectionValues(); return collect(['tipo' => 'Tipo', 'sector' => 'Sector', 'fila' => 'Fila', 'asiento' => 'Asiento']) ->map(fn (string $label, string $key): string => $label.': '.($values->get($key) ?? 'sin especificar')) ->implode(', '); } }