diff --git a/app/Domains/Commerce/Catalog/Models/CatalogItem.php b/app/Domains/Commerce/Catalog/Models/CatalogItem.php index 9fe5d1c6..c64adb8f 100644 --- a/app/Domains/Commerce/Catalog/Models/CatalogItem.php +++ b/app/Domains/Commerce/Catalog/Models/CatalogItem.php @@ -8,6 +8,7 @@ use App\Domains\Commerce\Catalog\Enums\InventorySubject; use App\Domains\Commerce\Catalog\Services\CatalogInventoryService; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Event\Models\Event; +use App\Domains\Ticketing\Ticket\Models\EntryReservation; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Shared\Attachable\Models\Attachment; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -147,6 +148,12 @@ class CatalogItem extends Model return $this->hasMany(Ticket::class, 'source_catalog_item_id'); } + /** @return HasMany */ + public function entryReservations(): HasMany + { + return $this->hasMany(EntryReservation::class); + } + /** @return BelongsToMany */ public function attributes(): BelongsToMany { @@ -266,13 +273,13 @@ class CatalogItem extends Model { $this->variants ->filter(fn (Variant $variant): bool => $variant->exists) - ->loadMissing('desfileEntryReservations'); + ->loadMissing('entryReservations'); return $this->variants ->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $this)) ->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates() - && (! $variant->relationLoaded('desfileEntryReservations') - || $variant->desfileEntryReservations->isEmpty()) + && (! $variant->relationLoaded('entryReservations') + || $variant->entryReservations->isEmpty()) && (($includedVariantId !== null && $variant->id === $includedVariantId) || ($variant->isSellable() && ( $this->inventory_policy === InventoryPolicy::Unlimited diff --git a/app/Domains/Commerce/Catalog/Models/Variant.php b/app/Domains/Commerce/Catalog/Models/Variant.php index dd5a622e..94786c52 100644 --- a/app/Domains/Commerce/Catalog/Models/Variant.php +++ b/app/Domains/Commerce/Catalog/Models/Variant.php @@ -2,8 +2,8 @@ namespace App\Domains\Commerce\Catalog\Models; -use App\Domains\Ticketing\Desfile\Models\EntryReservation; use App\Domains\Ticketing\Event\Models\EventDate; +use App\Domains\Ticketing\Ticket\Models\EntryReservation; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Shared\Attachable\Models\Attachment; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -76,7 +76,7 @@ class Variant extends Model } /** @return HasMany */ - public function desfileEntryReservations(): HasMany + public function entryReservations(): HasMany { return $this->hasMany(EntryReservation::class); } diff --git a/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php b/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php index 317c4972..088c97c9 100644 --- a/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Commerce/Catalog/Services/VariantReplacementService.php @@ -7,8 +7,8 @@ use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\StockReservation; use App\Domains\Commerce\Catalog\Models\StockReservationLine; use App\Domains\Commerce\Catalog\Models\Variant; -use App\Domains\Ticketing\Desfile\Models\EntryReservation; use App\Domains\Ticketing\Event\Models\EventDate; +use App\Domains\Ticketing\Ticket\Models\EntryReservation; use Illuminate\Support\Collection; class VariantReplacementService diff --git a/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php b/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php index 37931061..d6e372fd 100644 --- a/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php +++ b/app/Domains/Commerce/Purchase/Services/TenantTransactionResetService.php @@ -25,9 +25,8 @@ class TenantTransactionResetService 'carts' => $scope['cart_ids']->count(), 'cart_items' => $scope['cart_item_ids']->count(), 'tickets' => DB::table('tickets')->where('tenant_code', $tenantCode)->count(), - 'entry_reservations' => DB::table('desfile_entry_reservations') - ->whereIn('variant_id', DB::table('variantes')->whereIn('catalog_item_id', - DB::table('catalog_items')->where('tenant_code', $tenantCode)->select('id'))->select('id'))->count(), + 'entry_reservations' => DB::table('entry_reservations') + ->whereIn('catalog_item_id', DB::table('catalog_items')->where('tenant_code', $tenantCode)->select('id'))->count(), 'stock_reservations' => $this->reservationQuery($scope)->count(), 'purchase_changes' => DB::table('value_changes') ->where('tenant_code', $tenantCode) @@ -50,10 +49,9 @@ class TenantTransactionResetService $telepagosQr = DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count(); $summary = [ 'stock_reservations_deleted' => $this->reservationQuery($scope)->delete(), - 'entry_reservations_deleted' => DB::table('desfile_entry_reservations') - ->whereIn('variant_id', DB::table('variantes')->whereIn('catalog_item_id', - DB::table('catalog_items')->where('tenant_code', $tenantCode)->select('id'))->select('id'))->delete(), - 'entry_reservation_batches_deleted' => DB::table('desfile_reservation_batches')->where('tenant_code', $tenantCode)->delete(), + 'entry_reservations_deleted' => DB::table('entry_reservations') + ->whereIn('catalog_item_id', DB::table('catalog_items')->where('tenant_code', $tenantCode)->select('id'))->delete(), + 'entry_reservation_batches_deleted' => DB::table('reservation_batches')->where('tenant_code', $tenantCode)->delete(), 'tickets_deleted' => DB::table('tickets')->where('tenant_code', $tenantCode)->delete(), 'purchase_changes_deleted' => DB::table('value_changes') ->where('tenant_code', $tenantCode) diff --git a/app/Domains/Ticketing/Desfile/Models/EntryReservation.php b/app/Domains/Ticketing/Desfile/Models/EntryReservation.php deleted file mode 100644 index a9c20498..00000000 --- a/app/Domains/Ticketing/Desfile/Models/EntryReservation.php +++ /dev/null @@ -1,54 +0,0 @@ - 'integer', - 'fecha_reserva' => 'datetime', - 'importe' => 'decimal:2', - 'tipo_pago' => EntryReservationPaymentType::class, - ]; - } - - /** @return BelongsTo */ - public function variant(): BelongsTo - { - return $this->belongsTo(Variant::class)->withTrashed(); - } - - public function ticket(): BelongsTo - { - return $this->belongsTo(Ticket::class); - } - - public function inventory(): BelongsTo - { - return $this->belongsTo(Inventory::class); - } -} diff --git a/app/Domains/Ticketing/Desfile/Requests/IndexEntryReservationsRequest.php b/app/Domains/Ticketing/Desfile/Requests/IndexEntryReservationsRequest.php index e546136b..ef38d5d4 100644 --- a/app/Domains/Ticketing/Desfile/Requests/IndexEntryReservationsRequest.php +++ b/app/Domains/Ticketing/Desfile/Requests/IndexEntryReservationsRequest.php @@ -2,7 +2,7 @@ namespace App\Domains\Ticketing\Desfile\Requests; -use App\Domains\Ticketing\Desfile\Enums\EntryReservationPaymentType; +use App\Domains\Ticketing\Ticket\Enums\EntryReservationPaymentType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; diff --git a/app/Domains/Ticketing/Desfile/Requests/StoreEntryReservationsRequest.php b/app/Domains/Ticketing/Desfile/Requests/StoreEntryReservationsRequest.php index d4799926..1cb9cfdd 100644 --- a/app/Domains/Ticketing/Desfile/Requests/StoreEntryReservationsRequest.php +++ b/app/Domains/Ticketing/Desfile/Requests/StoreEntryReservationsRequest.php @@ -2,7 +2,7 @@ namespace App\Domains\Ticketing\Desfile\Requests; -use App\Domains\Ticketing\Desfile\Enums\EntryReservationPaymentType; +use App\Domains\Ticketing\Ticket\Enums\EntryReservationPaymentType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; diff --git a/app/Domains/Ticketing/Desfile/Resources/EntryReservationResource.php b/app/Domains/Ticketing/Desfile/Resources/EntryReservationResource.php index 9ef77d37..89a51d5f 100644 --- a/app/Domains/Ticketing/Desfile/Resources/EntryReservationResource.php +++ b/app/Domains/Ticketing/Desfile/Resources/EntryReservationResource.php @@ -15,6 +15,7 @@ class EntryReservationResource extends JsonResource return [ 'id' => $this->id, + 'catalog_item_id' => $this->catalog_item_id, 'variant_id' => $this->variant_id, 'ticket_id' => $this->ticket_id, 'fecha_reserva' => $this->fecha_reserva->toIso8601String(), diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationExcelService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationExcelService.php index fb213cbc..8d391c71 100644 --- a/app/Domains/Ticketing/Desfile/Services/EntryReservationExcelService.php +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationExcelService.php @@ -3,7 +3,7 @@ namespace App\Domains\Ticketing\Desfile\Services; use App\Domains\Core\Tenant\Models\Tenant; -use App\Domains\Ticketing\Desfile\Models\EntryReservation; +use App\Domains\Ticketing\Ticket\Models\EntryReservation; use Illuminate\Support\Collection; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Shared\Date; diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationPdfService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationPdfService.php index 4df1da4c..c63ab332 100644 --- a/app/Domains/Ticketing/Desfile/Services/EntryReservationPdfService.php +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationPdfService.php @@ -3,7 +3,7 @@ namespace App\Domains\Ticketing\Desfile\Services; use App\Domains\Core\Tenant\Models\Tenant; -use App\Domains\Ticketing\Desfile\Models\EntryReservation; +use App\Domains\Ticketing\Ticket\Models\EntryReservation; use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\PDF as DomPdf; use Illuminate\Http\Response; diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationReportService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationReportService.php index e401c5a2..a1ca4e2a 100644 --- a/app/Domains/Ticketing/Desfile/Services/EntryReservationReportService.php +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationReportService.php @@ -2,7 +2,7 @@ namespace App\Domains\Ticketing\Desfile\Services; -use App\Domains\Ticketing\Desfile\Models\EntryReservation; +use App\Domains\Ticketing\Ticket\Models\EntryReservation; use Illuminate\Support\Collection; class EntryReservationReportService diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php index e8cb948d..2f2bd734 100644 --- a/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php @@ -7,9 +7,10 @@ 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\Desfile\Enums\EntryReservationPaymentType; -use App\Domains\Ticketing\Desfile\Models\EntryReservation; +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; @@ -139,7 +140,7 @@ class EntryReservationService 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') + $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, @@ -157,7 +158,7 @@ class EntryReservationService ->orderBy('id')->lockForUpdate()->get()->keyBy('id'); $variants->load([ 'eventDates', 'eventDate', - 'desfileEntryReservations' => fn ($query) => $query->lockForUpdate(), + 'entryReservations' => fn ($query) => $query->lockForUpdate(), ]); foreach ($variants as $variant) { $variant->setRelation('inventory', $inventories->get($variant->inventory_id)); @@ -193,10 +194,9 @@ class EntryReservationService throw ValidationException::withMessages($errors); } - $batchId = DB::table('desfile_reservation_batches')->insertGetId([ + $batch = ReservationBatch::query()->create([ '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); @@ -213,7 +213,8 @@ class EntryReservationService ]); } $reservation = EntryReservation::query()->create([ - 'batch_id' => $batchId, 'ticket_id' => $ticket->id, + '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(), diff --git a/app/Domains/Ticketing/Desfile/Enums/EntryReservationPaymentType.php b/app/Domains/Ticketing/Ticket/Enums/EntryReservationPaymentType.php similarity index 92% rename from app/Domains/Ticketing/Desfile/Enums/EntryReservationPaymentType.php rename to app/Domains/Ticketing/Ticket/Enums/EntryReservationPaymentType.php index bec60e8d..79654871 100644 --- a/app/Domains/Ticketing/Desfile/Enums/EntryReservationPaymentType.php +++ b/app/Domains/Ticketing/Ticket/Enums/EntryReservationPaymentType.php @@ -1,6 +1,6 @@ 'integer', + 'variant_id' => 'integer', + 'fecha_reserva' => 'datetime', + 'importe' => 'decimal:2', + 'tipo_pago' => EntryReservationPaymentType::class, + ]; + } + + protected static function booted(): void + { + static::saving(function (self $reservation): void { + if ($reservation->variant_id === null) { + return; + } + + $variant = Variant::withTrashed()->findOrFail($reservation->variant_id); + // Existing variant-based callers can keep creating reservations as before. + $reservation->catalog_item_id ??= $variant->catalog_item_id; + if ($reservation->catalog_item_id !== $variant->catalog_item_id) { + throw ValidationException::withMessages([ + 'variant_id' => 'La variante no pertenece al ítem del catálogo seleccionado.', + ]); + } + }); + } + + /** @return BelongsTo */ + public function catalogItem(): BelongsTo + { + return $this->belongsTo(CatalogItem::class)->withTrashed(); + } + + /** @return BelongsTo */ + public function batch(): BelongsTo + { + return $this->belongsTo(ReservationBatch::class, 'batch_id'); + } + + /** @return BelongsTo */ + public function variant(): BelongsTo + { + return $this->belongsTo(Variant::class)->withTrashed(); + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + public function inventory(): BelongsTo + { + return $this->belongsTo(Inventory::class); + } +} diff --git a/app/Domains/Ticketing/Ticket/Models/ReservationBatch.php b/app/Domains/Ticketing/Ticket/Models/ReservationBatch.php new file mode 100644 index 00000000..515508c1 --- /dev/null +++ b/app/Domains/Ticketing/Ticket/Models/ReservationBatch.php @@ -0,0 +1,37 @@ + 'integer']; + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class)->withTrashed(); + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); + } + + /** @return HasMany */ + public function reservations(): HasMany + { + return $this->hasMany(EntryReservation::class, 'batch_id'); + } +} diff --git a/app/Domains/Ticketing/Ticket/Models/Ticket.php b/app/Domains/Ticketing/Ticket/Models/Ticket.php index 12560bc3..838763cc 100644 --- a/app/Domains/Ticketing/Ticket/Models/Ticket.php +++ b/app/Domains/Ticketing/Ticket/Models/Ticket.php @@ -7,7 +7,6 @@ use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Purchase\Models\PurchaseItem; use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Tenant\Models\Tenant; -use App\Domains\Ticketing\Desfile\Models\EntryReservation; use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticketing\Ticket\Services\ResolvedValidityGroup; diff --git a/app/Shared/Forms/Services/DesfileEntryReservationFormService.php b/app/Shared/Forms/Services/DesfileEntryReservationFormService.php index 3c37bde4..e27637e8 100644 --- a/app/Shared/Forms/Services/DesfileEntryReservationFormService.php +++ b/app/Shared/Forms/Services/DesfileEntryReservationFormService.php @@ -5,7 +5,7 @@ namespace App\Shared\Forms\Services; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Core\Tenant\Models\Tenant; -use App\Domains\Ticketing\Desfile\Enums\EntryReservationPaymentType; +use App\Domains\Ticketing\Ticket\Enums\EntryReservationPaymentType; class DesfileEntryReservationFormService { diff --git a/database/migrations/2026_10_02_000000_generalize_ticket_reservations.php b/database/migrations/2026_10_02_000000_generalize_ticket_reservations.php new file mode 100644 index 00000000..acb8295f --- /dev/null +++ b/database/migrations/2026_10_02_000000_generalize_ticket_reservations.php @@ -0,0 +1,53 @@ +leftJoin('variantes', 'variantes.id', '=', 'reservations.variant_id') + ->leftJoin('catalog_items', 'catalog_items.id', '=', 'variantes.catalog_item_id') + ->whereNull('catalog_items.id')->exists()) { + throw new LogicException('No se puede determinar el catálogo de todas las reservas existentes.'); + } + + Schema::rename('desfile_reservation_batches', 'reservation_batches'); + Schema::rename('desfile_entry_reservations', 'entry_reservations'); + + Schema::table('entry_reservations', function (Blueprint $table): void { + $table->foreignId('catalog_item_id')->nullable() + ->constrained('catalog_items')->restrictOnDelete(); + $table->unsignedBigInteger('variant_id')->nullable()->change(); + }); + + DB::table('entry_reservations')->update([ + 'catalog_item_id' => DB::raw('(SELECT catalog_item_id FROM variantes WHERE variantes.id = entry_reservations.variant_id)'), + ]); + + Schema::table('entry_reservations', function (Blueprint $table): void { + $table->unsignedBigInteger('catalog_item_id')->nullable(false)->change(); + }); + } + + public function down(): void + { + // The old schema cannot represent tickets without variants. Never discard them. + if (DB::table('entry_reservations')->whereNull('variant_id')->exists()) { + throw new LogicException('No se puede revertir mientras existan reservas sin variante.'); + } + + Schema::table('entry_reservations', function (Blueprint $table): void { + $table->dropConstrainedForeignId('catalog_item_id'); + $table->unsignedBigInteger('variant_id')->nullable(false)->change(); + }); + + Schema::rename('entry_reservations', 'desfile_entry_reservations'); + Schema::rename('reservation_batches', 'desfile_reservation_batches'); + } +}; diff --git a/tests/Feature/Desfile/EntryReservationServiceTest.php b/tests/Feature/Desfile/EntryReservationServiceTest.php index b3c54b19..14dee02e 100644 --- a/tests/Feature/Desfile/EntryReservationServiceTest.php +++ b/tests/Feature/Desfile/EntryReservationServiceTest.php @@ -136,6 +136,7 @@ class EntryReservationServiceTest extends TestCase (require database_path('migrations/2026_09_23_010000_create_desfile_entry_reservations_table.php'))->up(); (require database_path('migrations/2026_09_24_000000_add_administrative_entry_reservation_stock.php'))->up(); (require database_path('migrations/2026_09_24_010000_add_soft_deletes_to_desfile_entry_reservations_table.php'))->up(); + (require database_path('migrations/2026_10_02_000000_generalize_ticket_reservations.php'))->up(); DB::table('tenants')->insert(['codigo' => 'desfile_pura_tendencia']); DB::table('users')->insert(['id' => 1, 'tenant_codigo' => 'desfile_pura_tendencia']); DB::table('catalog_items')->insert([ @@ -200,8 +201,8 @@ class EntryReservationServiceTest extends TestCase $replayed = $service->reserve($user, $key, $this->rows()); $this->assertSame($result->pluck('id')->all(), $replayed->pluck('id')->all()); $this->assertDatabaseCount('tickets', 2); - $this->assertDatabaseCount('desfile_entry_reservations', 2); - $this->assertDatabaseCount('desfile_reservation_batches', 1); + $this->assertDatabaseCount('entry_reservations', 2); + $this->assertDatabaseCount('reservation_batches', 1); foreach (Inventory::all() as $inventory) { $this->assertSame(1, $inventory->entry_reserved_stock); $this->assertSame(1, $inventory->real_stock); @@ -223,7 +224,7 @@ class EntryReservationServiceTest extends TestCase $this->assertStringContainsString('Tipo: NORMAL, Sector: A, Fila: 3, Asiento: 17', $error->errors()['rows.1.variant_id'][0]); } $this->assertDatabaseCount('tickets', 0); - $this->assertDatabaseCount('desfile_entry_reservations', 0); + $this->assertDatabaseCount('entry_reservations', 0); $this->assertSame(0, (int) Inventory::sum('entry_reserved_stock')); } @@ -236,8 +237,8 @@ class EntryReservationServiceTest extends TestCase $this->assertSame('Ticket generation failed', $error->getMessage()); } $this->assertDatabaseCount('tickets', 0); - $this->assertDatabaseCount('desfile_entry_reservations', 0); - $this->assertDatabaseCount('desfile_reservation_batches', 0); + $this->assertDatabaseCount('entry_reservations', 0); + $this->assertDatabaseCount('reservation_batches', 0); $this->assertSame(0, (int) Inventory::sum('entry_reserved_stock')); } @@ -258,7 +259,7 @@ class EntryReservationServiceTest extends TestCase $service->cancel($user, $reservation->id); - $this->assertSoftDeleted('desfile_entry_reservations', ['id' => $reservation->id]); + $this->assertSoftDeleted('entry_reservations', ['id' => $reservation->id]); $this->assertNotNull(Ticket::findOrFail($reservation->ticket_id)->cancelled_at); $this->assertSame(0, Inventory::findOrFail(1)->entry_reserved_stock); $this->assertSame(1, Inventory::findOrFail(1)->availableStock()); @@ -280,7 +281,7 @@ class EntryReservationServiceTest extends TestCase $this->assertArrayHasKey('status', $error->errors()); } - $this->assertDatabaseHas('desfile_entry_reservations', [ + $this->assertDatabaseHas('entry_reservations', [ 'id' => $reservation->id, 'deleted_at' => null, ]); @@ -443,6 +444,7 @@ class EntryReservationServiceTest extends TestCase $service = new EntryReservationService(new TicketGeneratorService($validity)); $result = $service->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); foreach ($result as $reservation) { + $this->assertSame(1, $reservation->catalog_item_id); $this->assertDatabaseHas('tickets', [ 'id' => $reservation->ticket_id, 'user_id' => 1, 'source_variant_id' => $reservation->variant_id, diff --git a/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php b/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php index d7a90e93..6135e909 100644 --- a/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php @@ -99,7 +99,7 @@ class AdminAppDesfileEntryReservationFormControllerTest extends TestCase ]); } if ($state === 'administrative') { - $variant->desfileEntryReservations()->create([ + $variant->entryReservations()->create([ 'fecha_reserva' => now(), 'importe' => 0, 'tipo_pago' => 'sin_cargo', ]); } @@ -111,7 +111,7 @@ class AdminAppDesfileEntryReservationFormControllerTest extends TestCase ->assertOk()->assertJsonCount(1, 'data.variants') ->assertJsonPath('data.variants.0.id', $availableId) ->assertJsonPath('data.fields.3.options', [['value' => '1', 'label' => '1']]); - $this->assertDatabaseCount('desfile_entry_reservations', 1); + $this->assertDatabaseCount('entry_reservations', 1); $this->assertSame(1, (int) Inventory::query()->sum('reserved_stock')); } diff --git a/tests/Feature/Migrations/GeneralizeTicketReservationsTest.php b/tests/Feature/Migrations/GeneralizeTicketReservationsTest.php new file mode 100644 index 00000000..0ae1e616 --- /dev/null +++ b/tests/Feature/Migrations/GeneralizeTicketReservationsTest.php @@ -0,0 +1,206 @@ +id(); + $table->string('codigo'); + }); + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->softDeletes(); + }); + Schema::create('catalog_items', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->softDeletes(); + }); + Schema::create('variantes', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('catalog_item_id'); + $table->softDeletes(); + }); + Schema::create('tickets', fn (Blueprint $table) => $table->id()); + Schema::create('inventories', fn (Blueprint $table) => $table->id()); + (require database_path('migrations/2026_09_23_010000_create_desfile_entry_reservations_table.php'))->up(); + (require database_path('migrations/2026_09_24_000000_add_administrative_entry_reservation_stock.php'))->up(); + (require database_path('migrations/2026_09_24_010000_add_soft_deletes_to_desfile_entry_reservations_table.php'))->up(); + DB::table('users')->insert(['id' => 1]); + DB::table('tenants')->insert([ + ['codigo' => 'desfile_pura_tendencia'], ['codigo' => 'other'], + ]); + DB::table('catalog_items')->insert([ + ['id' => 1, 'tenant_code' => 'desfile_pura_tendencia'], + ['id' => 2, 'tenant_code' => 'other'], + ]); + DB::table('variantes')->insert([ + ['id' => 1, 'catalog_item_id' => 1, 'deleted_at' => null], + ['id' => 2, 'catalog_item_id' => 2, 'deleted_at' => now()], + ]); + DB::table('tickets')->insert(['id' => 10]); + DB::table('inventories')->insert(['id' => 20, 'entry_reserved_stock' => 1]); + DB::table('desfile_reservation_batches')->insert([ + 'id' => 30, 'user_id' => 1, 'tenant_code' => 'desfile_pura_tendencia', + 'idempotency_key' => 'aab7c678-7f4d-486a-87ec-7f613ad6f58d', + 'request_hash' => str_repeat('a', 64), + 'created_at' => now(), 'updated_at' => now(), + ]); + DB::table('desfile_entry_reservations')->insert([ + ['id' => 40, 'variant_id' => 1, 'ticket_id' => 10, 'inventory_id' => 20, + 'batch_id' => 30, 'fecha_reserva' => now(), 'importe' => 250, + 'tipo_pago' => 'otro_metodo', 'deleted_at' => null], + ['id' => 41, 'variant_id' => 2, 'ticket_id' => null, 'inventory_id' => null, + 'batch_id' => null, 'fecha_reserva' => now(), 'importe' => 0, + 'tipo_pago' => 'sin_cargo', 'deleted_at' => now()], + ]); + } + + private function migration(): Migration + { + return require database_path('migrations/2026_10_02_000000_generalize_ticket_reservations.php'); + } + + public function test_upgrade_preserves_reservations_batches_stock_and_deleted_history(): void + { + $this->migration()->up(); + $this->assertFalse(Schema::hasTable('desfile_entry_reservations')); + $this->assertFalse(Schema::hasTable('desfile_reservation_batches')); + $this->assertDatabaseCount('entry_reservations', 2); + $this->assertDatabaseCount('reservation_batches', 1); + $reservation = EntryReservation::findOrFail(40); + $this->assertSame(1, $reservation->catalog_item_id); + $this->assertSame(1, $reservation->catalogItem->id); + $this->assertSame(30, $reservation->batch->id); + $this->assertSame(40, $reservation->batch->reservations->sole()->id); + $this->assertSame(10, $reservation->ticket->id); + $this->assertSame('250.00', $reservation->importe); + $this->assertSame(str_repeat('a', 64), $reservation->batch->request_hash); + $this->assertSame(1, $reservation->inventory->entry_reserved_stock); + $historical = EntryReservation::withTrashed()->findOrFail(41); + $this->assertTrue($historical->trashed()); + $this->assertTrue($historical->variant->trashed()); + $this->assertSame(2, $historical->catalog_item_id); + } + + public function test_reservations_can_belong_to_other_tenants_and_have_no_variant(): void + { + $this->migration()->up(); + $batch = ReservationBatch::create([ + 'user_id' => 1, 'tenant_code' => 'other', + 'idempotency_key' => '9495eb8e-a424-4f1b-8911-e05c51e1caf7', + 'request_hash' => str_repeat('b', 64), + ]); + $reservation = $batch->reservations()->create([ + 'catalog_item_id' => 2, 'fecha_reserva' => now(), + 'tipo_pago' => 'sin_cargo', 'importe' => 0, + ]); + $this->assertNull($reservation->variant_id); + $this->assertSame('other', $reservation->catalogItem->tenant_code); + $this->assertSame('other', $reservation->batch->tenant->codigo); + $this->assertSame(1, $reservation->batch->user->id); + } + + public function test_variant_based_callers_infer_the_catalog_item(): void + { + $this->migration()->up(); + $reservation = EntryReservation::create([ + 'variant_id' => 1, 'fecha_reserva' => now(), 'tipo_pago' => 'sin_cargo', + ]); + $this->assertSame(1, $reservation->fresh()->catalog_item_id); + } + + public function test_a_variant_from_another_catalog_item_is_rejected(): void + { + $this->migration()->up(); + $this->expectException(ValidationException::class); + EntryReservation::create([ + 'catalog_item_id' => 2, 'variant_id' => 1, + 'fecha_reserva' => now(), 'tipo_pago' => 'sin_cargo', + ]); + } + + public function test_the_catalog_item_is_required_at_the_database_level(): void + { + $this->migration()->up(); + $this->expectException(QueryException::class); + DB::table('entry_reservations')->insert([ + 'fecha_reserva' => now(), 'tipo_pago' => 'sin_cargo', + ]); + } + + public function test_upgrade_preserves_foreign_keys_and_batch_idempotency(): void + { + $this->migration()->up(); + $this->assertSame([], DB::select('PRAGMA foreign_key_check')); + foreach (['catalog_items' => 1, 'variantes' => 1, 'tickets' => 10, 'inventories' => 20, 'reservation_batches' => 30] as $table => $id) { + try { + DB::table($table)->where('id', $id)->delete(); + $this->fail("Expected {$table} to remain referenced"); + } catch (QueryException) { + $this->assertDatabaseHas($table, ['id' => $id]); + } + } + $batch = ReservationBatch::findOrFail(30); + $this->expectException(QueryException::class); + ReservationBatch::create($batch->only(['user_id', 'tenant_code', 'idempotency_key', 'request_hash'])); + } + + public function test_rollback_restores_the_original_schema_and_keeps_data(): void + { + $migration = $this->migration(); + $migration->up(); + $migration->down(); + $this->assertFalse(Schema::hasTable('entry_reservations')); + $this->assertFalse(Schema::hasTable('reservation_batches')); + $this->assertDatabaseCount('desfile_entry_reservations', 2); + $this->assertDatabaseCount('desfile_reservation_batches', 1); + $this->assertFalse(Schema::hasColumn('desfile_entry_reservations', 'catalog_item_id')); + $this->assertSame([], DB::select('PRAGMA foreign_key_check')); + $migration->up(); + $this->assertSame(1, EntryReservation::findOrFail(40)->catalog_item_id); + } + + public function test_rollback_does_not_discard_reservations_without_variants(): void + { + $migration = $this->migration(); + $migration->up(); + EntryReservation::create([ + 'catalog_item_id' => 2, 'fecha_reserva' => now(), 'tipo_pago' => 'sin_cargo', + ]); + try { + $migration->down(); + $this->fail('Expected incompatible rollback to be rejected'); + } catch (\LogicException) { + $this->assertDatabaseCount('entry_reservations', 3); + $this->assertTrue(Schema::hasTable('reservation_batches')); + } + } + + public function test_an_orphaned_catalog_is_rejected_before_renaming_tables(): void + { + DB::table('variantes')->where('id', 2)->update(['catalog_item_id' => 999]); + try { + $this->migration()->up(); + $this->fail('Expected orphaned catalog to be rejected'); + } catch (\LogicException) { + $this->assertDatabaseCount('desfile_entry_reservations', 2); + $this->assertFalse(Schema::hasTable('entry_reservations')); + } + } +} diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 20f83794..0591e37a 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -13,8 +13,8 @@ use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Tenant\Models\AdminWebsiteType; use App\Domains\Core\Tenant\Models\Tenant; -use App\Domains\Ticketing\Desfile\Enums\EntryReservationPaymentType; -use App\Domains\Ticketing\Desfile\Models\EntryReservation; +use App\Domains\Ticketing\Ticket\Enums\EntryReservationPaymentType; +use App\Domains\Ticketing\Ticket\Models\EntryReservation; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\TicketRefund; use App\Shared\Attachable\Enums\AttachmentType; diff --git a/tests/Feature/Ticket/AdminAppTicketEventScopeTest.php b/tests/Feature/Ticket/AdminAppTicketEventScopeTest.php index 3b698584..37e3a039 100644 --- a/tests/Feature/Ticket/AdminAppTicketEventScopeTest.php +++ b/tests/Feature/Ticket/AdminAppTicketEventScopeTest.php @@ -72,7 +72,7 @@ class AdminAppTicketEventScopeTest extends TestCase $table->unsignedBigInteger('purchase_item_id'); $table->decimal('amount', 12, 2); }); - Schema::create('desfile_entry_reservations', function (Blueprint $table): void { + Schema::create('entry_reservations', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('ticket_id'); $table->softDeletes(); diff --git a/tests/Feature/Desfile/EntryReservationSchemaTest.php b/tests/Feature/Ticket/EntryReservationSchemaTest.php similarity index 76% rename from tests/Feature/Desfile/EntryReservationSchemaTest.php rename to tests/Feature/Ticket/EntryReservationSchemaTest.php index 7cc68d82..8c97499f 100644 --- a/tests/Feature/Desfile/EntryReservationSchemaTest.php +++ b/tests/Feature/Ticket/EntryReservationSchemaTest.php @@ -1,6 +1,6 @@ assertTrue(Schema::hasColumns('desfile_entry_reservations', [ + $this->assertTrue(Schema::hasColumns('entry_reservations', [ 'id', + 'catalog_item_id', 'variant_id', 'fecha_reserva', 'importe', diff --git a/tests/Unit/Catalog/VariantAvailabilityTest.php b/tests/Unit/Catalog/VariantAvailabilityTest.php index 24b37cd7..3260e2fa 100644 --- a/tests/Unit/Catalog/VariantAvailabilityTest.php +++ b/tests/Unit/Catalog/VariantAvailabilityTest.php @@ -6,7 +6,7 @@ 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\Ticketing\Desfile\Models\EntryReservation; +use App\Domains\Ticketing\Ticket\Models\EntryReservation; use Illuminate\Database\Eloquent\Collection; use Tests\TestCase; @@ -16,10 +16,10 @@ class VariantAvailabilityTest extends TestCase { $reserved = new Variant; $reserved->id = 1; - $reserved->setRelation('desfileEntryReservations', new Collection([new EntryReservation])); + $reserved->setRelation('entryReservations', new Collection([new EntryReservation])); $available = new Variant; $available->id = 2; - $available->setRelation('desfileEntryReservations', new Collection); + $available->setRelation('entryReservations', new Collection); $item = new CatalogItem(['inventory_policy' => InventoryPolicy::Unlimited]); $item->setRelation('variants', new Collection([$reserved, $available])); diff --git a/tests/Unit/Desfile/EntryReservationTest.php b/tests/Unit/Ticket/EntryReservationTest.php similarity index 80% rename from tests/Unit/Desfile/EntryReservationTest.php rename to tests/Unit/Ticket/EntryReservationTest.php index e422a693..0610355d 100644 --- a/tests/Unit/Desfile/EntryReservationTest.php +++ b/tests/Unit/Ticket/EntryReservationTest.php @@ -1,9 +1,9 @@ assertSame([ + 'catalog_item_id', 'variant_id', 'ticket_id', 'inventory_id', diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index e0f93480..3e946958 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -6,8 +6,8 @@ use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Tenant\Models\Tenant; -use App\Domains\Ticketing\Desfile\Models\EntryReservation; use App\Domains\Ticketing\Ticket\Enums\ValidityTimeType; +use App\Domains\Ticketing\Ticket\Models\EntryReservation; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\ValidityTime; use App\Domains\Ticketing\Ticket\Services\ResolvedTicketValidity;