From 3671b95a833ad5fe3f32fb46734f550c8b4c9fca Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 23 Sep 2026 16:55:35 -0300 Subject: [PATCH] feat(desfile): enhance entry reservation form and service with tenant-specific data --- .../Commerce/Catalog/Models/CatalogItem.php | 8 ++- .../DesfileEntryReservationFormController.php | 7 ++- .../DesfileEntryReservationFormResource.php | 2 + .../DesfileEntryReservationFormService.php | 37 ++++++++++- ...fileEntryReservationFormControllerTest.php | 62 ++++++++++++++++++- .../Unit/Catalog/VariantAvailabilityTest.php | 60 ++++++++++++++++++ 6 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Catalog/VariantAvailabilityTest.php diff --git a/app/Domains/Commerce/Catalog/Models/CatalogItem.php b/app/Domains/Commerce/Catalog/Models/CatalogItem.php index b01ca18..a808198 100644 --- a/app/Domains/Commerce/Catalog/Models/CatalogItem.php +++ b/app/Domains/Commerce/Catalog/Models/CatalogItem.php @@ -2,7 +2,6 @@ namespace App\Domains\Commerce\Catalog\Models; -use App\Shared\Attachable\Models\Attachment; use App\Domains\Commerce\Catalog\Enums\CatalogItemType; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Enums\InventorySubject; @@ -10,6 +9,7 @@ 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\Ticket; +use App\Shared\Attachable\Models\Attachment; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -264,9 +264,15 @@ class CatalogItem extends Model /** @return Collection */ public function visibleVariants(?int $includedVariantId = null): Collection { + $this->variants + ->filter(fn (Variant $variant): bool => $variant->exists) + ->loadMissing('desfileEntryReservations'); + return $this->variants ->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $this)) ->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates() + && (! $variant->relationLoaded('desfileEntryReservations') + || $variant->desfileEntryReservations->isEmpty()) && (($includedVariantId !== null && $variant->id === $includedVariantId) || ($variant->isSellable() && ( $this->inventory_policy === InventoryPolicy::Unlimited diff --git a/app/Shared/Forms/Controllers/AdminApp/DesfileEntryReservationFormController.php b/app/Shared/Forms/Controllers/AdminApp/DesfileEntryReservationFormController.php index 4a2a242..425fa29 100644 --- a/app/Shared/Forms/Controllers/AdminApp/DesfileEntryReservationFormController.php +++ b/app/Shared/Forms/Controllers/AdminApp/DesfileEntryReservationFormController.php @@ -5,6 +5,7 @@ namespace App\Shared\Forms\Controllers\AdminApp; use App\Http\Controllers\Controller; use App\Shared\Forms\Resources\DesfileEntryReservationFormResource; use App\Shared\Forms\Services\DesfileEntryReservationFormService; +use Illuminate\Http\Request; class DesfileEntryReservationFormController extends Controller { @@ -12,8 +13,10 @@ class DesfileEntryReservationFormController extends Controller private readonly DesfileEntryReservationFormService $formService, ) {} - public function __invoke(): DesfileEntryReservationFormResource + public function __invoke(Request $request): DesfileEntryReservationFormResource { - return DesfileEntryReservationFormResource::make($this->formService->get()); + return DesfileEntryReservationFormResource::make( + $this->formService->get($request->user()->tenant()->firstOrFail()), + ); } } diff --git a/app/Shared/Forms/Resources/DesfileEntryReservationFormResource.php b/app/Shared/Forms/Resources/DesfileEntryReservationFormResource.php index 8750f6d..11ef844 100644 --- a/app/Shared/Forms/Resources/DesfileEntryReservationFormResource.php +++ b/app/Shared/Forms/Resources/DesfileEntryReservationFormResource.php @@ -12,6 +12,8 @@ class DesfileEntryReservationFormResource extends JsonResource { return [ 'payment_types' => $this->resource['payment_types'], + 'fields' => $this->resource['fields'], + 'variants' => $this->resource['variants'], ]; } } diff --git a/app/Shared/Forms/Services/DesfileEntryReservationFormService.php b/app/Shared/Forms/Services/DesfileEntryReservationFormService.php index 8741164..3c37bde 100644 --- a/app/Shared/Forms/Services/DesfileEntryReservationFormService.php +++ b/app/Shared/Forms/Services/DesfileEntryReservationFormService.php @@ -2,15 +2,48 @@ 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; class DesfileEntryReservationFormService { - /** @return array{payment_types: list} */ - public function get(): array + /** @return array */ + public function get(Tenant $tenant): array { + $entry = CatalogItem::query()->forTenantCatalog($tenant)->where('slug', 'entrada') + ->with([ + 'variants' => fn ($query) => $query->orderBy('id'), + 'variants.inventory', + 'variants.definitions.itemAttribute.attribute', + 'variants.eventDates', + 'variants.eventDate', + ])->first(); + $variants = $entry?->visibleVariants() + ->map(function (Variant $variant): array { + $values = $variant->selectionValues(); + + return [ + 'id' => $variant->id, + 'tipo' => (string) $values->get('tipo'), + 'sector' => (string) $values->get('sector'), + 'fila' => (string) $values->get('fila'), + 'asiento' => (string) $values->get('asiento'), + 'price' => $variant->getPrice(), + ]; + })->values() ?? collect(); + return [ 'payment_types' => EntryReservationPaymentType::options(), + 'fields' => collect(['tipo' => 'Tipo', 'sector' => 'Sector', 'fila' => 'Fila', 'asiento' => 'Asiento']) + ->map(fn (string $label, string $key): array => [ + 'key' => $key, + 'label' => $label, + 'options' => $variants->pluck($key)->unique()->sort(SORT_NATURAL)->values() + ->map(fn (string $value): array => ['value' => $value, 'label' => $value])->all(), + ])->values()->all(), + 'variants' => $variants->all(), ]; } } diff --git a/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php b/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php index ec818cc..d7a90e9 100644 --- a/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php @@ -2,10 +2,14 @@ namespace Tests\Feature\Forms; +use App\Domains\Commerce\Catalog\Models\Attribute; +use App\Domains\Commerce\Catalog\Models\CatalogItem; +use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Tenant\Models\Tenant; +use App\Shared\Enums\FieldType; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Sanctum\Sanctum; @@ -37,7 +41,7 @@ class AdminAppDesfileEntryReservationFormControllerTest extends TestCase ->assertNotFound(); } - public function test_it_only_returns_the_available_payment_types(): void + public function test_it_returns_payment_types_and_empty_variant_options_without_a_catalog(): void { $tenant = $this->createTenant('desfile_pura_tendencia'); $this->grantReservationsMenu($tenant); @@ -51,10 +55,66 @@ class AdminAppDesfileEntryReservationFormControllerTest extends TestCase ['value' => 'sin_cargo', 'label' => 'Sin cargo'], ['value' => 'otro_metodo', 'label' => 'Otro método'], ], + 'fields' => [ + ['key' => 'tipo', 'label' => 'Tipo', 'options' => []], + ['key' => 'sector', 'label' => 'Sector', 'options' => []], + ['key' => 'fila', 'label' => 'Fila', 'options' => []], + ['key' => 'asiento', 'label' => 'Asiento', 'options' => []], + ], + 'variants' => [], ], ]); } + public function test_it_returns_only_available_combinations_without_reserving_stock(): void + { + $tenant = $this->createTenant('desfile_pura_tendencia'); + $this->grantReservationsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + $entry = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, 'slug' => 'entrada', 'nombre' => 'Entrada', 'precio' => 100, + ]); + $attributes = []; + foreach (['tipo', 'sector', 'fila', 'asiento'] as $code) { + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, 'codigo' => $code, + 'nombre' => $code, 'type' => FieldType::Select, + ]); + $attributes[$code] = $entry->itemAttributes()->create(['attribute_id' => $attribute->id]); + } + $availableId = null; + foreach (['available', 'reserved', 'sold', 'disabled', 'administrative'] as $index => $state) { + $inventory = Inventory::query()->create([ + 'real_stock' => $state === 'sold' ? 0 : 1, + 'reserved_stock' => $state === 'reserved' ? 1 : 0, + ]); + $variant = $entry->variants()->create([ + 'inventory_id' => $inventory->id, 'precio' => 100, + 'sales_disabled_at' => $state === 'disabled' ? now() : null, + ]); + foreach ($attributes as $code => $attribute) { + $variant->definitions()->create([ + 'item_attribute_id' => $attribute->id, + 'value' => $code === 'asiento' ? (string) ($index + 1) : '1', + ]); + } + if ($state === 'administrative') { + $variant->desfileEntryReservations()->create([ + 'fecha_reserva' => now(), 'importe' => 0, 'tipo_pago' => 'sin_cargo', + ]); + } + if ($state === 'available') { + $availableId = $variant->id; + } + } + $this->getJson('/api/v1/adminapp/forms/desfile/entry-reservation') + ->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->assertSame(1, (int) Inventory::query()->sum('reserved_stock')); + } + private function createTenant(string $code): Tenant { return Tenant::query()->create([ diff --git a/tests/Unit/Catalog/VariantAvailabilityTest.php b/tests/Unit/Catalog/VariantAvailabilityTest.php new file mode 100644 index 0000000..24b37cd --- /dev/null +++ b/tests/Unit/Catalog/VariantAvailabilityTest.php @@ -0,0 +1,60 @@ +id = 1; + $reserved->setRelation('desfileEntryReservations', new Collection([new EntryReservation])); + $available = new Variant; + $available->id = 2; + $available->setRelation('desfileEntryReservations', new Collection); + $item = new CatalogItem(['inventory_policy' => InventoryPolicy::Unlimited]); + $item->setRelation('variants', new Collection([$reserved, $available])); + + $this->assertSame([2], $item->visibleVariants()->modelKeys()); + $this->assertSame([2], $item->visibleVariants(1)->modelKeys()); + } + + public function test_sale_closure_and_variant_status_apply_without_the_cart_exception(): void + { + $active = new Variant; + $active->id = 1; + $disabled = new Variant(['sales_disabled_at' => now()]); + $disabled->id = 2; + $replaced = new Variant(['replaced_by_variant_id' => 1]); + $replaced->id = 3; + $item = new CatalogItem(['inventory_policy' => InventoryPolicy::Unlimited]); + $item->setRelation('variants', new Collection([$active, $disabled, $replaced])); + + $this->assertSame([1], $item->visibleVariants()->modelKeys()); + $item->sales_end_at = now()->subMinute(); + $this->assertSame([], $item->visibleVariants()->modelKeys()); + $this->assertSame([1], $item->visibleVariants(1)->modelKeys()); + } + + public function test_only_the_cart_can_include_its_own_out_of_stock_variant(): void + { + $variant = new Variant; + $variant->id = 1; + $variant->setRelation('inventory', new Inventory(['real_stock' => 1, 'reserved_stock' => 1])); + $item = new CatalogItem(['inventory_policy' => InventoryPolicy::Tracked]); + $item->setRelation('variants', new Collection([$variant])); + + $this->assertSame([], $item->visibleVariants()->modelKeys()); + $this->assertSame([1], $item->visibleVariants(1)->modelKeys()); + $item->inventory_policy = InventoryPolicy::Unlimited; + $this->assertSame([1], $item->visibleVariants()->modelKeys()); + } +}