From 2632a80722b3c3b59d8d2fa34777f6c19a6b6a14 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 19 Aug 2026 14:33:08 -0300 Subject: [PATCH] feat(invitation): implement InvitationPurchaseProvisioner and related migration and seeder updates --- .../InvitationPurchaseProvisioner.php | 417 ++++++++++++++++++ ...000_create_desfile_invitation_purchase.php | 17 + .../seeders/DesfilePuraTendenciaSeeder.php | 16 +- .../DesfilePuraTendenciaSeederTest.php | 75 +++- 4 files changed, 516 insertions(+), 9 deletions(-) create mode 100644 app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php create mode 100644 database/migrations/2026_08_19_030000_create_desfile_invitation_purchase.php diff --git a/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php new file mode 100644 index 0000000..0275cd5 --- /dev/null +++ b/app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php @@ -0,0 +1,417 @@ + + */ + private const ALLOCATIONS = [ + ['sector' => 'A', 'row' => 1, 'seats' => 16, 'type' => 'NORMAL'], + ['sector' => 'A', 'row' => 3, 'seats' => 14, 'type' => 'NORMAL'], + ['sector' => 'C', 'row' => 1, 'seats' => 16, 'type' => 'VIP + LUNCH'], + ]; + + public function provision(): void + { + if (! $this->prerequisitesExist()) { + return; + } + + DB::transaction(function (): void { + $now = now(); + $userId = $this->userId($now); + $purchaseId = $this->purchaseId($userId, $now); + $catalogItem = DB::table('catalog_items') + ->where('tenant_code', self::TENANT_CODE) + ->where('slug', 'entrada') + ->first(); + + if ($catalogItem === null) { + throw new RuntimeException('No se encontró el catálogo de entradas del desfile.'); + } + + foreach (self::ALLOCATIONS as $allocation) { + foreach (range(1, $allocation['seats']) as $seat) { + $variant = $this->variant( + (int) $catalogItem->id, + $allocation['sector'], + $allocation['row'], + $seat, + $allocation['type'], + ); + + $this->createPurchaseItem( + $purchaseId, + $catalogItem, + $variant, + $allocation['sector'], + $allocation['row'], + $seat, + $allocation['type'], + $now, + ); + $this->createTicketAndCommitStock( + $purchaseId, + $userId, + (int) $catalogItem->id, + $variant, + $now, + ); + } + } + }); + } + + private function prerequisitesExist(): bool + { + if (! DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) { + return false; + } + + if (! DB::table('roles')->where('codigo', 'user')->exists()) { + throw new RuntimeException('No se encontró el rol de usuario común.'); + } + + if (! DB::table('catalog_items') + ->where('tenant_code', self::TENANT_CODE) + ->where('slug', 'entrada') + ->exists()) { + throw new RuntimeException('No se encontró el catálogo de entradas del desfile.'); + } + + return true; + } + + private function userId(DateTimeInterface $now): int + { + $user = DB::table('users')->where('email', self::USER_EMAIL)->first(); + + if ($user !== null) { + if ($user->tenant_codigo !== self::TENANT_CODE) { + throw new RuntimeException('El email de invitados ya pertenece a otro tenant.'); + } + + DB::table('users')->where('id', $user->id)->update([ + 'rol_codigo' => 'user', + 'nombre_apellido' => 'Invitados Pura Tendencia', + 'updated_at' => $now, + ]); + + return (int) $user->id; + } + + return DB::table('users')->insertGetId([ + 'rol_codigo' => 'user', + 'tenant_codigo' => self::TENANT_CODE, + 'nombre_apellido' => 'Invitados Pura Tendencia', + 'email' => self::USER_EMAIL, + 'email_verified_at' => $now, + 'password' => Hash::make(Str::random(64)), + 'dni' => null, + 'telefono' => null, + 'remember_token' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function purchaseId(int $userId, DateTimeInterface $now): int + { + $purchaseId = DB::table('compras') + ->where('tenant_codigo', self::TENANT_CODE) + ->where('user_id', $userId) + ->where('payment_method', self::PAYMENT_METHOD) + ->value('id'); + + if ($purchaseId !== null) { + DB::table('compras')->where('id', $purchaseId)->update([ + 'status' => 'paid', + 'expires_at' => null, + 'total' => 0, + 'updated_at' => $now, + ]); + + return (int) $purchaseId; + } + + return DB::table('compras')->insertGetId([ + 'tenant_codigo' => self::TENANT_CODE, + 'user_id' => $userId, + 'cart_id' => null, + 'status' => 'paid', + 'payment_method' => self::PAYMENT_METHOD, + 'expires_at' => null, + 'total' => 0, + 'dni' => null, + 'transfer_payer_dni' => null, + 'telefono' => null, + 'nombre_apellido' => 'Invitados Pura Tendencia', + 'email' => self::USER_EMAIL, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function variant( + int $catalogItemId, + string $sector, + int $row, + int $seat, + string $type, + ): object { + $variant = $this->findVariant($catalogItemId, $sector, $row, $seat); + + if ($variant === null) { + $variant = $this->createVariant($catalogItemId, $sector, $row, $seat, $type); + } + + $this->ensureAttributeOptions($catalogItemId, [ + 'tipo' => $type, + 'sector' => $sector, + 'fila' => (string) $row, + 'asiento' => (string) $seat, + ]); + + $typeValueId = DB::table('variant_values as variant_value') + ->join('item_attributes as item_attribute', 'item_attribute.id', '=', 'variant_value.item_attribute_id') + ->join('attribute', 'attribute.id', '=', 'item_attribute.attribute_id') + ->where('variant_value.variant_id', $variant->id) + ->where('attribute.codigo', 'tipo') + ->value('variant_value.id'); + + if ($typeValueId === null) { + throw new RuntimeException("La variante {$variant->id} no tiene definido el tipo de entrada."); + } + + DB::table('variant_values')->where('id', $typeValueId)->update(['value' => $type]); + DB::table('variantes')->where('id', $variant->id)->update([ + 'descripcion' => "Sector {$sector} - Fila {$row} - Asiento {$seat} - {$type}", + ]); + + return DB::table('variantes')->where('id', $variant->id)->first(); + } + + /** @param array $values */ + private function ensureAttributeOptions(int $catalogItemId, array $values): void + { + $attributes = DB::table('item_attributes') + ->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id') + ->where('item_attributes.catalog_item_id', $catalogItemId) + ->pluck('attribute.id', 'attribute.codigo'); + + foreach ($values as $code => $value) { + $attributeId = $attributes[$code] ?? null; + + if ($attributeId === null) { + throw new RuntimeException("Falta el atributo {$code} en el catálogo de entradas."); + } + + if (DB::table('attribute_options') + ->where('attribute_id', $attributeId) + ->where('value', $value) + ->exists()) { + continue; + } + + $lastSortOrder = (int) DB::table('attribute_options') + ->where('attribute_id', $attributeId) + ->max('sort_order'); + + DB::table('attribute_options')->insert([ + 'attribute_id' => $attributeId, + 'validity_time_id' => null, + 'value' => $value, + 'label' => $value, + 'sort_order' => $lastSortOrder + 1, + 'metadata' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + private function findVariant(int $catalogItemId, string $sector, int $row, int $seat): ?object + { + $query = DB::table('variantes')->where('catalog_item_id', $catalogItemId); + + foreach (['sector' => $sector, 'fila' => (string) $row, 'asiento' => (string) $seat] as $code => $value) { + $query->whereExists(fn ($subquery) => $subquery + ->selectRaw('1') + ->from('variant_values as selected_value') + ->join('item_attributes as selected_item_attribute', 'selected_item_attribute.id', '=', 'selected_value.item_attribute_id') + ->join('attribute as selected_attribute', 'selected_attribute.id', '=', 'selected_item_attribute.attribute_id') + ->whereColumn('selected_value.variant_id', 'variantes.id') + ->where('selected_attribute.codigo', $code) + ->where('selected_value.value', $value)); + } + + return $query->first(); + } + + private function createVariant( + int $catalogItemId, + string $sector, + int $row, + int $seat, + string $type, + ): object { + $itemAttributes = DB::table('item_attributes') + ->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id') + ->where('item_attributes.catalog_item_id', $catalogItemId) + ->pluck('item_attributes.id', 'attribute.codigo'); + + foreach (['tipo', 'sector', 'fila', 'asiento'] as $requiredCode) { + if (! isset($itemAttributes[$requiredCode])) { + throw new RuntimeException("Falta el atributo {$requiredCode} en el catálogo de entradas."); + } + } + + $inventoryId = DB::table('inventories')->insertGetId([ + 'sold_units' => 0, + 'reserved_stock' => 0, + 'real_stock' => 1, + ]); + $variantId = DB::table('variantes')->insertGetId([ + 'catalog_item_id' => $catalogItemId, + 'event_date_id' => null, + 'inventory_id' => $inventoryId, + 'descripcion' => "Sector {$sector} - Fila {$row} - Asiento {$seat} - {$type}", + 'precio' => $this->catalogPrice($sector, $row), + ]); + + foreach (['tipo' => $type, 'sector' => $sector, 'fila' => (string) $row, 'asiento' => (string) $seat] as $code => $value) { + DB::table('variant_values')->insert([ + 'variant_id' => $variantId, + 'item_attribute_id' => $itemAttributes[$code], + 'value' => $value, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + return DB::table('variantes')->where('id', $variantId)->first(); + } + + private function catalogPrice(string $sector, int $row): int + { + $prices = in_array($sector, ['A', 'C'], true) + ? [1 => 250000, 2 => 200000, 3 => 100000, 4 => 75000, 5 => 50000] + : [1 => 240000, 2 => 190000, 3 => 90000, 4 => 65000, 5 => 40000]; + + return $prices[$row]; + } + + private function createPurchaseItem( + int $purchaseId, + object $catalogItem, + object $variant, + string $sector, + int $row, + int $seat, + string $type, + DateTimeInterface $now, + ): void { + if (DB::table('compra_items') + ->where('compra_id', $purchaseId) + ->where('source_variant_id', $variant->id) + ->exists()) { + return; + } + + $attributes = [ + ['name' => 'Tipo', 'value' => $type], + ['name' => 'Sector', 'value' => $sector], + ['name' => 'Fila', 'value' => (string) $row], + ['name' => 'Asiento', 'value' => (string) $seat], + ]; + + DB::table('compra_items')->insert([ + 'compra_id' => $purchaseId, + 'source_catalog_item_id' => $catalogItem->id, + 'source_variant_id' => $variant->id, + 'image_attachment_id' => DB::table('catalog_items_attachments') + ->where('catalog_item_id', $catalogItem->id) + ->whereNull('variant_id') + ->orderBy('orden') + ->value('attachment_id'), + 'nombre' => $catalogItem->nombre, + 'descripcion' => $variant->descripcion, + 'slug' => $catalogItem->slug, + 'item_nombre' => "Entrada (Sector {$sector}, Fila {$row}, Asiento {$seat})", + 'variant_attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'cantidad' => 1, + 'precio_unitario' => 0, + 'discount_total' => 0, + 'tax_total' => 0, + 'total' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function createTicketAndCommitStock( + int $purchaseId, + int $userId, + int $catalogItemId, + object $variant, + DateTimeInterface $now, + ): void { + if (DB::table('tickets') + ->where('source_purchase_id', $purchaseId) + ->where('source_variant_id', $variant->id) + ->exists()) { + return; + } + + $inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first(); + + if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0) { + throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible."); + } + + DB::table('inventories')->where('id', $inventory->id)->update([ + 'real_stock' => $inventory->real_stock - 1, + 'sold_units' => $inventory->sold_units + 1, + ]); + + DB::table('stock_reservations')->insert([ + 'inventory_id' => $inventory->id, + 'cart_item_id' => null, + 'purchase_id' => $purchaseId, + 'quantity' => 1, + 'status' => 'committed', + 'expires_at' => null, + 'committed_at' => $now, + 'released_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('tickets')->insert([ + 'tenant_code' => self::TENANT_CODE, + 'ticket' => (string) Str::uuid(), + 'name' => null, + 'description' => null, + 'source_purchase_id' => $purchaseId, + 'source_catalog_item_id' => $catalogItemId, + 'source_variant_id' => $variant->id, + 'used_at' => null, + 'scanner_user_id' => null, + 'user_id' => $userId, + ]); + } +} diff --git a/database/migrations/2026_08_19_030000_create_desfile_invitation_purchase.php b/database/migrations/2026_08_19_030000_create_desfile_invitation_purchase.php new file mode 100644 index 0000000..a1b6da1 --- /dev/null +++ b/database/migrations/2026_08_19_030000_create_desfile_invitation_purchase.php @@ -0,0 +1,17 @@ +provision(); + } + + public function down(): void + { + // Intentionally irreversible: issued invitation tickets may already be used. + } +}; diff --git a/database/seeders/DesfilePuraTendenciaSeeder.php b/database/seeders/DesfilePuraTendenciaSeeder.php index 0357067..bfc1447 100644 --- a/database/seeders/DesfilePuraTendenciaSeeder.php +++ b/database/seeders/DesfilePuraTendenciaSeeder.php @@ -12,6 +12,7 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Client\Models\Client; +use App\Domains\Desfile\Services\InvitationPurchaseProvisioner; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Services\TenantService; use Illuminate\Database\Seeder; @@ -28,6 +29,7 @@ class DesfilePuraTendenciaSeeder extends Seeder public function __construct( private readonly TenantService $tenantService, private readonly CatalogService $catalogService, + private readonly InvitationPurchaseProvisioner $invitationPurchaseProvisioner, ) {} public function run(): void @@ -41,15 +43,15 @@ class DesfilePuraTendenciaSeeder extends Seeder if ($tenant) { $tenant->update(['client_id' => $client->id]); - - return; + } else { + DB::transaction(function () use ($client): void { + $this->createTenant($client); + $this->createEventDate(); + $this->createEntryCatalog(); + }); } - DB::transaction(function () use ($client): void { - $this->createTenant($client); - $this->createEventDate(); - $this->createEntryCatalog(); - }); + $this->invitationPurchaseProvisioner->provision(); } private function createTenant(Client $client): void diff --git a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php index 64159d1..6d3d231 100644 --- a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php +++ b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php @@ -216,12 +216,18 @@ class DesfilePuraTendenciaSeederTest extends TestCase ->whereIn('variant_id', (clone $variants)->pluck('id')) ->count()); $this->assertSame(330, (clone $variants)->whereNull('event_date_id')->count()); - $this->assertSame(330, DB::table('inventories') + $this->assertSame(284, DB::table('inventories') ->whereIn('id', (clone $variants)->pluck('inventory_id')) ->where('real_stock', 1) ->where('reserved_stock', 0) ->where('sold_units', 0) ->count()); + $this->assertSame(46, DB::table('inventories') + ->whereIn('id', (clone $variants)->pluck('inventory_id')) + ->where('real_stock', 0) + ->where('reserved_stock', 0) + ->where('sold_units', 1) + ->count()); $this->assertDatabaseHas('variantes', [ 'catalog_item_id' => $catalogItem->id, 'descripcion' => 'Sector A - Fila 1 - Asiento 17 - VIP + LUNCH', @@ -247,7 +253,7 @@ class DesfilePuraTendenciaSeederTest extends TestCase 'sector' => ['B', 'D'], 'asiento' => ['17'], ])); - $this->assertSame(66, $this->variantCountForSelection($catalogItem->id, [ + $this->assertSame(50, $this->variantCountForSelection($catalogItem->id, [ 'tipo' => ['VIP + LUNCH'], 'fila' => ['1'], ])); @@ -255,6 +261,71 @@ class DesfilePuraTendenciaSeederTest extends TestCase 'tipo' => ['NORMAL'], 'fila' => ['5'], ])); + + $this->assertInvitationPurchase($catalogItem->id); + + $this->seed(DesfilePuraTendenciaSeeder::class); + + $this->assertInvitationPurchase($catalogItem->id); + } + + private function assertInvitationPurchase(int $catalogItemId): void + { + $user = DB::table('users')->where('email', 'invitados@puratendencia.com')->sole(); + + $this->assertSame('user', $user->rol_codigo); + $this->assertSame('desfile_pura_tendencia', $user->tenant_codigo); + + $purchase = DB::table('compras') + ->where('tenant_codigo', 'desfile_pura_tendencia') + ->where('user_id', $user->id) + ->where('payment_method', 'invitation') + ->sole(); + + $this->assertSame('paid', $purchase->status); + $this->assertEquals(0, $purchase->total); + + $items = DB::table('compra_items')->where('compra_id', $purchase->id); + + $this->assertSame(46, (clone $items)->count()); + $this->assertSame(46, (clone $items) + ->where('precio_unitario', 0) + ->where('discount_total', 0) + ->where('tax_total', 0) + ->where('total', 0) + ->count()); + $this->assertSame(46, DB::table('tickets') + ->where('source_purchase_id', $purchase->id) + ->where('user_id', $user->id) + ->where('source_catalog_item_id', $catalogItemId) + ->count()); + $this->assertSame(46, DB::table('stock_reservations') + ->where('purchase_id', $purchase->id) + ->where('status', 'committed') + ->count()); + + foreach ([ + ['sector' => 'A', 'fila' => '1', 'tipo' => 'NORMAL', 'count' => 16], + ['sector' => 'A', 'fila' => '3', 'tipo' => 'NORMAL', 'count' => 14], + ['sector' => 'C', 'fila' => '1', 'tipo' => 'VIP + LUNCH', 'count' => 16], + ] as $allocation) { + $allocatedVariants = DB::table('variantes') + ->where('catalog_item_id', $catalogItemId) + ->whereIn('id', (clone $items)->pluck('source_variant_id')); + + foreach (['sector', 'fila', 'tipo'] as $code) { + $allocatedVariants->whereExists(fn ($query) => $query + ->selectRaw('1') + ->from('variant_values') + ->join('item_attributes', 'item_attributes.id', '=', 'variant_values.item_attribute_id') + ->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id') + ->whereColumn('variant_values.variant_id', 'variantes.id') + ->where('attribute.codigo', $code) + ->where('variant_values.value', $allocation[$code])); + } + + $this->assertSame($allocation['count'], $allocatedVariants->count()); + } } /** @param array> $selection */