homo #16
@@ -6,18 +6,18 @@ use App\Domains\Ticketing\Desfile\Requests\ExportEntryReservationsRequest;
|
||||
use App\Domains\Ticketing\Desfile\Requests\IndexEntryReservationsRequest;
|
||||
use App\Domains\Ticketing\Desfile\Requests\StoreEntryReservationsRequest;
|
||||
use App\Domains\Ticketing\Desfile\Resources\EntryReservationResource;
|
||||
use App\Domains\Ticketing\Desfile\Services\DesfileEntryReservationService;
|
||||
use App\Domains\Ticketing\Desfile\Services\EntryReservationExcelService;
|
||||
use App\Domains\Ticketing\Desfile\Services\EntryReservationPdfService;
|
||||
use App\Domains\Ticketing\Desfile\Services\EntryReservationService;
|
||||
use App\Domains\Ticketing\Ticket\Services\TicketPdfService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class EntryReservationController extends Controller
|
||||
class DesfileEntryReservationController extends Controller
|
||||
{
|
||||
public function index(IndexEntryReservationsRequest $request, EntryReservationService $service): AnonymousResourceCollection
|
||||
public function index(IndexEntryReservationsRequest $request, DesfileEntryReservationService $service): AnonymousResourceCollection
|
||||
{
|
||||
return EntryReservationResource::collection(
|
||||
$service->reservations($request->user(), $request->validated()),
|
||||
@@ -26,7 +26,7 @@ class EntryReservationController extends Controller
|
||||
|
||||
public function downloadPdf(
|
||||
ExportEntryReservationsRequest $request,
|
||||
EntryReservationService $service,
|
||||
DesfileEntryReservationService $service,
|
||||
EntryReservationPdfService $pdf,
|
||||
): Response {
|
||||
$user = $request->user();
|
||||
@@ -40,7 +40,7 @@ class EntryReservationController extends Controller
|
||||
|
||||
public function downloadExcel(
|
||||
ExportEntryReservationsRequest $request,
|
||||
EntryReservationService $service,
|
||||
DesfileEntryReservationService $service,
|
||||
EntryReservationExcelService $excel,
|
||||
): StreamedResponse {
|
||||
$user = $request->user();
|
||||
@@ -55,7 +55,7 @@ class EntryReservationController extends Controller
|
||||
public function downloadTicketPdf(
|
||||
IndexEntryReservationsRequest $request,
|
||||
int $reservation,
|
||||
EntryReservationService $service,
|
||||
DesfileEntryReservationService $service,
|
||||
TicketPdfService $pdf,
|
||||
): Response {
|
||||
$user = $request->user();
|
||||
@@ -66,7 +66,7 @@ class EntryReservationController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StoreEntryReservationsRequest $request, EntryReservationService $service): AnonymousResourceCollection
|
||||
public function store(StoreEntryReservationsRequest $request, DesfileEntryReservationService $service): AnonymousResourceCollection
|
||||
{
|
||||
return EntryReservationResource::collection($service->reserve(
|
||||
$request->user(), $request->validated('idempotency_key'), $request->validated('rows'),
|
||||
@@ -76,7 +76,7 @@ class EntryReservationController extends Controller
|
||||
public function destroy(
|
||||
IndexEntryReservationsRequest $request,
|
||||
int $reservation,
|
||||
EntryReservationService $service,
|
||||
DesfileEntryReservationService $service,
|
||||
): Response {
|
||||
$service->cancel($request->user(), $reservation);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticketing\Desfile\Services;
|
||||
|
||||
use App\Domains\Ticketing\Ticket\Services\EntryReservationPolicy;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class DesfileEntryReservationPolicy implements EntryReservationPolicy
|
||||
{
|
||||
public function validate(array $selections): void
|
||||
{
|
||||
$errors = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($selections as $index => $selection) {
|
||||
$variant = $selection['variant'];
|
||||
if ($variant === null || $selection['inventory'] === null
|
||||
|| isset($seen[$variant->id])
|
||||
|| $variant->entryReservations()->lockForUpdate()->get()->isNotEmpty()) {
|
||||
$errors["rows.{$index}.variant_id"] = 'La entrada ya no está disponible.';
|
||||
}
|
||||
if ($variant !== null) {
|
||||
$seen[$variant->id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticketing\Desfile\Services;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Commerce\Catalog\Models\Variant;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use App\Domains\Ticketing\Ticket\Models\EntryReservation;
|
||||
use App\Domains\Ticketing\Ticket\Models\Ticket;
|
||||
use App\Domains\Ticketing\Ticket\Services\EntryReservationService as TicketEntryReservationService;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** Adapts the Desfile API and seat rules to the catalog-based reservation workflow. */
|
||||
class DesfileEntryReservationService
|
||||
{
|
||||
private const SELECTION_RELATIONS = [
|
||||
'variant.catalogItem.itemAttributes.attribute.options',
|
||||
'variant.definitions.itemAttribute.attribute.options',
|
||||
'variant.eventDates',
|
||||
'variant.eventDate',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly TicketEntryReservationService $reservations,
|
||||
private readonly DesfileEntryReservationPolicy $policy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{tipo_pago?: string|null, page?: int, per_page?: int} $filters
|
||||
* @return LengthAwarePaginator<EntryReservation>
|
||||
*/
|
||||
public function reservations(User $user, array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$result = $this->reservations->reservations($user, [
|
||||
...$filters, 'catalog_item_id' => $this->entryId($user),
|
||||
]);
|
||||
$result->getCollection()->loadMissing(self::SELECTION_RELATIONS);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{tipo_pago?: string|null} $filters
|
||||
* @return Collection<int, EntryReservation>
|
||||
*/
|
||||
public function reservationsForExport(User $user, array $filters = []): Collection
|
||||
{
|
||||
return $this->reservations->reservationsForExport($user, [
|
||||
...$filters, 'catalog_item_id' => $this->entryId($user),
|
||||
])->loadMissing(self::SELECTION_RELATIONS);
|
||||
}
|
||||
|
||||
public function reservationTicket(User $user, int $reservationId): Ticket
|
||||
{
|
||||
return $this->reservations->reservationTicket($user, $reservationId, $this->entryId($user));
|
||||
}
|
||||
|
||||
public function cancel(User $user, int $reservationId): void
|
||||
{
|
||||
$this->reservations->cancel($user, $reservationId, $this->entryId($user));
|
||||
}
|
||||
|
||||
/** @param list<array{variant_id: int, tipo_pago: string}> $rows */
|
||||
public function reserve(User $user, string $key, array $rows): Collection
|
||||
{
|
||||
$this->authorize($user);
|
||||
$entry = CatalogItem::query()->where('tenant_code', $user->tenant_codigo)
|
||||
->where('slug', 'entrada')->firstOrFail();
|
||||
$catalogRows = array_map(fn (array $row): array => [
|
||||
...$row, 'catalog_item_id' => $entry->id,
|
||||
], $rows);
|
||||
|
||||
try {
|
||||
return $this->reservations->reserve($user, $key, $catalogRows, $this->policy)
|
||||
->loadMissing(self::SELECTION_RELATIONS);
|
||||
} catch (ValidationException $exception) {
|
||||
// Keep the row fields used by the form and describe the selected seat.
|
||||
$variants = $entry->variants()->whereKey(array_column($rows, 'variant_id'))
|
||||
->with(['definitions.itemAttribute.attribute.options'])->get()->keyBy('id');
|
||||
$errors = [];
|
||||
foreach ($exception->errors() as $field => $messages) {
|
||||
if (preg_match('/^rows\.(\d+)\.variant_id$/', $field, $match)) {
|
||||
$index = (int) $match[1];
|
||||
$label = $this->entryLabel($variants->get($rows[$index]['variant_id'] ?? null), $index);
|
||||
$messages = array_map(fn (string $message): string => $label.': '.$message, $messages);
|
||||
}
|
||||
$errors[$field] = $messages;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
}
|
||||
|
||||
private function authorize(User $user): void
|
||||
{
|
||||
abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403);
|
||||
}
|
||||
|
||||
private function entryId(User $user): int
|
||||
{
|
||||
$this->authorize($user);
|
||||
|
||||
// Historical reservations remain accessible when the active event changes.
|
||||
// A missing entry must not remove the catalog filter from the generic query.
|
||||
return CatalogItem::query()->where('tenant_code', $user->tenant_codigo)
|
||||
->where('slug', 'entrada')->value('id') ?? -1;
|
||||
}
|
||||
|
||||
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(', ');
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticketing\Desfile\Services;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Commerce\Catalog\Models\Inventory;
|
||||
use App\Domains\Commerce\Catalog\Models\Variant;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use App\Domains\Ticketing\Ticket\Enums\EntryReservationPaymentType;
|
||||
use App\Domains\Ticketing\Ticket\Exceptions\TicketGenerationException;
|
||||
use App\Domains\Ticketing\Ticket\Models\EntryReservation;
|
||||
use App\Domains\Ticketing\Ticket\Models\ReservationBatch;
|
||||
use App\Domains\Ticketing\Ticket\Models\Ticket;
|
||||
use App\Domains\Ticketing\Ticket\Services\TicketGeneratorService;
|
||||
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
|
||||
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EntryReservationService
|
||||
{
|
||||
public function __construct(private readonly TicketGeneratorService $tickets) {}
|
||||
|
||||
/**
|
||||
* @param array{tipo_pago?: string|null, page?: int, per_page?: int} $filters
|
||||
* @return LengthAwarePaginator<EntryReservation>
|
||||
*/
|
||||
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<int, EntryReservation>
|
||||
*/
|
||||
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<array{variant_id: int, tipo_pago: string}> $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 = ReservationBatch::query()
|
||||
->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',
|
||||
'entryReservations' => 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);
|
||||
}
|
||||
|
||||
$batch = ReservationBatch::query()->create([
|
||||
'user_id' => $user->id, 'tenant_code' => $tenant->codigo,
|
||||
'idempotency_key' => $key, 'request_hash' => $hash,
|
||||
]);
|
||||
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' => $batch->id, 'ticket_id' => $ticket->id,
|
||||
'catalog_item_id' => $entry->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(', ');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Ticketing\Desfile\Controllers\DesfileEntryReservationController;
|
||||
use App\Domains\Ticketing\Desfile\Controllers\EntryController;
|
||||
use App\Domains\Ticketing\Desfile\Controllers\EntryReservationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'index'])
|
||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations', [DesfileEntryReservationController::class, 'index'])
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||
->name('adminapp.desfile.entry-reservations.index');
|
||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf'])
|
||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [DesfileEntryReservationController::class, 'downloadPdf'])
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||
->name('adminapp.desfile.entry-reservations.pdf');
|
||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel'])
|
||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [DesfileEntryReservationController::class, 'downloadExcel'])
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||
->name('adminapp.desfile.entry-reservations.excel');
|
||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf'])
|
||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [DesfileEntryReservationController::class, 'downloadTicketPdf'])
|
||||
->whereNumber('reservation')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||
->name('adminapp.desfile.entry-reservations.ticket.pdf');
|
||||
Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store'])
|
||||
Route::post('v1/adminapp/tenant/desfile/entry-reservations', [DesfileEntryReservationController::class, 'store'])
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||
->name('adminapp.desfile.entry-reservations.store');
|
||||
Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy'])
|
||||
Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [DesfileEntryReservationController::class, 'destroy'])
|
||||
->whereNumber('reservation')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||
->name('adminapp.desfile.entry-reservations.destroy');
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticketing\Ticket\Services;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Commerce\Catalog\Models\Inventory;
|
||||
use App\Domains\Commerce\Catalog\Models\Variant;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
interface EntryReservationPolicy
|
||||
{
|
||||
/**
|
||||
* Validate additional rules after selections are resolved and locked, before
|
||||
* stock or tickets change. Idempotent replays do not run this validation.
|
||||
*
|
||||
* @param array<int, array{item: CatalogItem, variant: Variant|null, inventory: Inventory|null}> $selections
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function validate(array $selections): void;
|
||||
}
|
||||
@@ -13,8 +13,8 @@ use App\Domains\Ticketing\Ticket\Models\EntryReservation;
|
||||
use App\Domains\Ticketing\Ticket\Models\ReservationBatch;
|
||||
use App\Domains\Ticketing\Ticket\Models\Ticket;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -88,7 +88,7 @@ class EntryReservationService
|
||||
* @param list<array{catalog_item_id: int, variant_id?: int|null, tipo_pago: string}> $rows
|
||||
* @return Collection<int, EntryReservation>
|
||||
*/
|
||||
public function reserve(User $user, string $key, array $rows): Collection
|
||||
public function reserve(User $user, string $key, array $rows, ?EntryReservationPolicy $policy = null): Collection
|
||||
{
|
||||
$validated = Validator::make(['idempotency_key' => $key, 'rows' => $rows], [
|
||||
'idempotency_key' => ['required', 'uuid'],
|
||||
@@ -105,7 +105,7 @@ class EntryReservationService
|
||||
], $validated['rows']);
|
||||
$hash = $this->requestHash($rows);
|
||||
|
||||
return DB::transaction(function () use ($user, $key, $rows, $hash): Collection {
|
||||
return DB::transaction(function () use ($user, $key, $rows, $hash, $policy): Collection {
|
||||
User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail();
|
||||
$tenant = $user->tenant()->firstOrFail();
|
||||
$batch = ReservationBatch::query()->where('user_id', $user->id)
|
||||
@@ -183,6 +183,8 @@ class EntryReservationService
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
|
||||
$policy?->validate($selections);
|
||||
|
||||
foreach ($requirements as $inventoryId => $requirement) {
|
||||
if ($requirement['tracked'] && $inventories[$inventoryId]->availableStock() < $requirement['quantity']) {
|
||||
foreach ($selections as $index => $selection) {
|
||||
@@ -204,7 +206,7 @@ class EntryReservationService
|
||||
$inventories[$inventoryId]->reserveEntry($requirement['quantity'], $requirement['tracked']);
|
||||
}
|
||||
|
||||
$reservations = collect();
|
||||
$reservations = new Collection;
|
||||
foreach ($selections as $index => $selection) {
|
||||
$item = $selection['item'];
|
||||
$variant = $selection['variant'];
|
||||
|
||||
@@ -9,15 +9,18 @@ use App\Domains\Ticketing\Desfile\Requests\ExportEntryReservationsRequest;
|
||||
use App\Domains\Ticketing\Desfile\Requests\IndexEntryReservationsRequest;
|
||||
use App\Domains\Ticketing\Desfile\Requests\StoreEntryReservationsRequest;
|
||||
use App\Domains\Ticketing\Desfile\Resources\EntryReservationResource;
|
||||
use App\Domains\Ticketing\Desfile\Services\DesfileEntryReservationPolicy;
|
||||
use App\Domains\Ticketing\Desfile\Services\DesfileEntryReservationService;
|
||||
use App\Domains\Ticketing\Desfile\Services\EntryReservationExcelService;
|
||||
use App\Domains\Ticketing\Desfile\Services\EntryReservationPdfService;
|
||||
use App\Domains\Ticketing\Desfile\Services\EntryReservationReportService;
|
||||
use App\Domains\Ticketing\Desfile\Services\EntryReservationService;
|
||||
use App\Domains\Ticketing\Ticket\Models\Ticket;
|
||||
use App\Domains\Ticketing\Ticket\Services\EntryReservationService as TicketEntryReservationService;
|
||||
use App\Domains\Ticketing\Ticket\Services\ResolvedTicketValidity;
|
||||
use App\Domains\Ticketing\Ticket\Services\TicketGeneratorService;
|
||||
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
|
||||
use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -166,7 +169,12 @@ class EntryReservationServiceTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
private function service(bool $failSecond = false): EntryReservationService
|
||||
private function service(bool $failSecond = false): DesfileEntryReservationService
|
||||
{
|
||||
return new DesfileEntryReservationService($this->ticketService($failSecond), new DesfileEntryReservationPolicy);
|
||||
}
|
||||
|
||||
private function ticketService(bool $failSecond = false): TicketEntryReservationService
|
||||
{
|
||||
$generator = Mockery::mock(TicketGeneratorService::class);
|
||||
$generator->shouldReceive('generate')->andReturnUsing(function ($entry, $user, $quantity, $variantId) use ($failSecond) {
|
||||
@@ -183,7 +191,7 @@ class EntryReservationServiceTest extends TestCase
|
||||
return collect([Ticket::query()->findOrFail($id)]);
|
||||
});
|
||||
|
||||
return new EntryReservationService($generator);
|
||||
return new TicketEntryReservationService($generator);
|
||||
}
|
||||
|
||||
private function rows(): array
|
||||
@@ -251,6 +259,120 @@ class EntryReservationServiceTest extends TestCase
|
||||
$service->reserve($user, (string) Str::uuid(), $this->rows());
|
||||
}
|
||||
|
||||
public function test_a_seat_cannot_be_reserved_twice_even_when_inventory_has_more_stock(): void
|
||||
{
|
||||
DB::table('inventories')->where('id', 1)->update(['real_stock' => 3]);
|
||||
$service = $this->service();
|
||||
$user = User::findOrFail(1);
|
||||
$row = $this->rows()[0];
|
||||
$reservation = $service->reserve($user, (string) Str::uuid(), [$row])->sole();
|
||||
|
||||
try {
|
||||
$service->reserve($user, (string) Str::uuid(), [$row]);
|
||||
$this->fail('Expected a unique seat despite remaining stock');
|
||||
} catch (ValidationException $error) {
|
||||
$this->assertArrayHasKey('rows.0.variant_id', $error->errors());
|
||||
$this->assertStringContainsString('Asiento: 17', $error->errors()['rows.0.variant_id'][0]);
|
||||
}
|
||||
$this->assertDatabaseCount('tickets', 1);
|
||||
$this->assertDatabaseCount('reservation_batches', 1);
|
||||
$this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock);
|
||||
|
||||
$service->cancel($user, $reservation->id);
|
||||
$replacement = $service->reserve($user, (string) Str::uuid(), [$row])->sole();
|
||||
$this->assertNotSame($reservation->id, $replacement->id);
|
||||
$this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock);
|
||||
}
|
||||
|
||||
public function test_duplicate_seats_in_one_batch_are_rejected_without_changing_stock(): void
|
||||
{
|
||||
DB::table('inventories')->where('id', 1)->update(['real_stock' => 3]);
|
||||
$row = $this->rows()[0];
|
||||
try {
|
||||
$this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), [$row, $row]);
|
||||
$this->fail('Expected duplicate seat validation');
|
||||
} catch (ValidationException $error) {
|
||||
$this->assertArrayHasKey('rows.1.variant_id', $error->errors());
|
||||
}
|
||||
$this->assertDatabaseCount('tickets', 0);
|
||||
$this->assertDatabaseCount('reservation_batches', 0);
|
||||
$this->assertSame(0, Inventory::findOrFail(1)->entry_reserved_stock);
|
||||
}
|
||||
|
||||
public function test_legacy_batches_replay_after_cancellation_and_an_active_event_change(): void
|
||||
{
|
||||
$service = $this->service();
|
||||
$user = User::findOrFail(1);
|
||||
$key = (string) Str::uuid();
|
||||
$result = $service->reserve($user, $key, $this->rows());
|
||||
DB::table('reservation_batches')->update([
|
||||
'request_hash' => hash('sha256', json_encode($this->rows(), JSON_THROW_ON_ERROR)),
|
||||
]);
|
||||
$service->cancel($user, $result->first()->id);
|
||||
DB::table('tenants')->update(['active_event_id' => 20]);
|
||||
|
||||
$replay = $service->reserve($user, $key, array_reverse($this->rows()));
|
||||
|
||||
$this->assertSame($result->last()->id, $replay->sole()->id);
|
||||
$this->assertSame($result->last()->id, $service->reservations($user)->sole()->id);
|
||||
$this->assertSame($result->last()->ticket_id, $service->reservationTicket($user, $result->last()->id)->id);
|
||||
$this->assertSame('NORMAL', $replay->sole()->variant->selectionValues()->get('tipo'));
|
||||
$this->assertDatabaseCount('tickets', 2);
|
||||
$this->assertDatabaseCount('reservation_batches', 1);
|
||||
}
|
||||
|
||||
public function test_desfile_cannot_list_export_download_or_cancel_other_catalog_item_reservations(): void
|
||||
{
|
||||
DB::table('catalog_items')->insert([
|
||||
'id' => 2, 'tenant_code' => 'desfile_pura_tendencia', 'slug' => 'other-ticket',
|
||||
'inventory_policy' => 'tracked', 'precio' => 100,
|
||||
]);
|
||||
DB::table('inventories')->insert(['id' => 3, 'real_stock' => 1]);
|
||||
DB::table('variantes')->insert(['id' => 3, 'catalog_item_id' => 2, 'inventory_id' => 3, 'precio' => 100]);
|
||||
$user = User::findOrFail(1);
|
||||
$other = $this->ticketService()->reserve($user, (string) Str::uuid(), [
|
||||
['catalog_item_id' => 2, 'variant_id' => 3, 'tipo_pago' => 'sin_cargo'],
|
||||
])->sole();
|
||||
$service = $this->service();
|
||||
$own = $service->reserve($user, (string) Str::uuid(), [$this->rows()[0]])->sole();
|
||||
|
||||
$this->assertSame([$own->id], $service->reservations($user)->pluck('id')->all());
|
||||
$this->assertSame([$own->id], $service->reservationsForExport($user)->pluck('id')->all());
|
||||
foreach (['reservationTicket', 'cancel'] as $method) {
|
||||
try {
|
||||
$service->$method($user, $other->id);
|
||||
$this->fail('Expected the Desfile catalog scope');
|
||||
} catch (ModelNotFoundException) {
|
||||
$this->assertNull(Ticket::findOrFail($other->ticket_id)->cancelled_at);
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('catalog_items')->where('id', 1)->update(['deleted_at' => now()]);
|
||||
$this->assertSame(0, $service->reservations($user)->total());
|
||||
$this->assertCount(0, $service->reservationsForExport($user));
|
||||
}
|
||||
|
||||
public function test_desfile_service_rejects_other_tenants_for_every_operation(): void
|
||||
{
|
||||
$user = new User(['tenant_codigo' => 'other']);
|
||||
$service = $this->service();
|
||||
$operations = [
|
||||
fn () => $service->reservations($user),
|
||||
fn () => $service->reservationsForExport($user),
|
||||
fn () => $service->reservationTicket($user, 1),
|
||||
fn () => $service->cancel($user, 1),
|
||||
fn () => $service->reserve($user, (string) Str::uuid(), $this->rows()),
|
||||
];
|
||||
foreach ($operations as $operation) {
|
||||
try {
|
||||
$operation();
|
||||
$this->fail('Expected the Desfile tenant restriction');
|
||||
} catch (HttpException $error) {
|
||||
$this->assertSame(403, $error->getStatusCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_cancels_the_ticket_releases_stock_and_soft_deletes_the_reservation(): void
|
||||
{
|
||||
$service = $this->service();
|
||||
@@ -441,7 +563,10 @@ class EntryReservationServiceTest extends TestCase
|
||||
$validity->shouldReceive('resolveVariant')->andReturn(
|
||||
ResolvedTicketValidity::unrestricted(),
|
||||
);
|
||||
$service = new EntryReservationService(new TicketGeneratorService($validity));
|
||||
$service = new DesfileEntryReservationService(
|
||||
new TicketEntryReservationService(new TicketGeneratorService($validity)),
|
||||
new DesfileEntryReservationPolicy,
|
||||
);
|
||||
$result = $service->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows());
|
||||
foreach ($result as $reservation) {
|
||||
$this->assertSame(1, $reservation->catalog_item_id);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Ticket;
|
||||
use App\Domains\Commerce\Catalog\Models\Inventory;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use App\Domains\Ticketing\Ticket\Models\Ticket;
|
||||
use App\Domains\Ticketing\Ticket\Services\EntryReservationPolicy;
|
||||
use App\Domains\Ticketing\Ticket\Services\EntryReservationService;
|
||||
use App\Domains\Ticketing\Ticket\Services\ResolvedTicketValidity;
|
||||
use App\Domains\Ticketing\Ticket\Services\TicketGeneratorService;
|
||||
@@ -224,6 +225,45 @@ class EntryReservationServiceTest extends TestCase
|
||||
$service->reserve($user, (string) Str::uuid(), [$row]);
|
||||
}
|
||||
|
||||
public function test_custom_policy_validates_resolved_selections_inside_the_transaction_before_stock_changes(): void
|
||||
{
|
||||
$policy = Mockery::mock(EntryReservationPolicy::class);
|
||||
$policy->shouldReceive('validate')->once()->andReturnUsing(function (array $selections): void {
|
||||
$this->assertGreaterThan(0, DB::transactionLevel());
|
||||
$this->assertSame(1, $selections[0]['item']->id);
|
||||
$this->assertSame(1, $selections[0]['variant']->id);
|
||||
$this->assertSame(2, $selections[1]['item']->id);
|
||||
$this->assertNull($selections[1]['variant']);
|
||||
$this->assertSame(0, $selections[0]['inventory']->entry_reserved_stock);
|
||||
throw ValidationException::withMessages(['rows.0.variant_id' => 'Custom selection rule.']);
|
||||
});
|
||||
|
||||
try {
|
||||
$this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows(), $policy);
|
||||
$this->fail('Expected custom selection validation');
|
||||
} catch (ValidationException $error) {
|
||||
$this->assertSame(['rows.0.variant_id' => ['Custom selection rule.']], $error->errors());
|
||||
}
|
||||
$this->assertDatabaseCount('tickets', 0);
|
||||
$this->assertDatabaseCount('reservation_batches', 0);
|
||||
$this->assertSame(0, (int) Inventory::sum('entry_reserved_stock'));
|
||||
}
|
||||
|
||||
public function test_idempotent_replays_do_not_validate_the_custom_policy_again(): void
|
||||
{
|
||||
$policy = Mockery::mock(EntryReservationPolicy::class);
|
||||
$policy->shouldReceive('validate')->once();
|
||||
$service = $this->service();
|
||||
$user = User::findOrFail(1);
|
||||
$key = (string) Str::uuid();
|
||||
$result = $service->reserve($user, $key, $this->rows(), $policy);
|
||||
$replay = $service->reserve($user, $key, array_reverse($this->rows()), $policy);
|
||||
|
||||
$this->assertSame($result->pluck('id')->all(), $replay->pluck('id')->all());
|
||||
$this->assertDatabaseCount('tickets', 2);
|
||||
$this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock);
|
||||
}
|
||||
|
||||
public function test_shared_inventory_is_checked_for_the_entire_batch(): void
|
||||
{
|
||||
DB::table('catalog_items')->where('id', 2)->update(['inventory_id' => 1]);
|
||||
|
||||
Reference in New Issue
Block a user