From 6a6d3690ccf40ef0027a3d98d848f22ef2d5c637 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 7 Aug 2026 12:34:38 -0300 Subject: [PATCH] feat(entry): implement EntryController, EntryService, and EntryResource for managing entries --- .../Controllers/EntryController.php | 30 +++ .../Requests/UpsertEntriesRequest.php | 76 ++++++ .../Resources/EntryResource.php | 26 ++ .../Services/EntryService.php | 143 +++++++++++ .../FiestaFutbolInfantil/routes/api.php | 11 + .../FiestaFutbolInfantilProductSeeder.php | 29 +-- routes/api.php | 1 + .../EntryControllerTest.php | 238 ++++++++++++++++++ .../FiestaFutbolInfantilProductSeederTest.php | 4 +- 9 files changed, 531 insertions(+), 27 deletions(-) create mode 100644 app/Domains/FiestaFutbolInfantil/Controllers/EntryController.php create mode 100644 app/Domains/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php create mode 100644 app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php create mode 100644 app/Domains/FiestaFutbolInfantil/Services/EntryService.php create mode 100644 app/Domains/FiestaFutbolInfantil/routes/api.php create mode 100644 tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/EntryController.php b/app/Domains/FiestaFutbolInfantil/Controllers/EntryController.php new file mode 100644 index 0000000..dd02d65 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Controllers/EntryController.php @@ -0,0 +1,30 @@ +user()->tenant()->firstOrFail(); + + abort_unless($tenant->codigo === 'fiesta_futbol_infantil', 404); + + $entries = $this->entryService->upsertMany( + $tenant, + $request->validated('entries'), + ); + + return EntryResource::collection($entries) + ->response() + ->setStatusCode(200); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php new file mode 100644 index 0000000..0bcccd0 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpsertEntriesRequest.php @@ -0,0 +1,76 @@ + */ + public function rules(): array + { + $tenantCode = $this->user()?->tenant_codigo; + + return [ + 'entries' => ['required', 'array', 'min:1', 'max:100'], + 'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,price'], + 'entries.*.id' => [ + 'sometimes', + 'nullable', + 'integer', + 'distinct', + Rule::exists('catalog_items', 'id')->where( + fn ($query) => $query + ->where('tenant_code', $tenantCode) + ->where('event_product_type', 'entrada') + ), + ], + 'entries.*.title' => ['required', 'string', 'max:255'], + 'entries.*.description' => ['sometimes', 'nullable', 'string'], + 'entries.*.event_date_ids' => ['required', 'array', 'min:1'], + 'entries.*.event_date_ids.*' => [ + 'required', + 'integer', + Rule::exists('event_dates', 'id')->where( + fn ($query) => $query->where('tenant_code', $tenantCode) + ), + ], + 'entries.*.stock' => ['required', 'integer', 'min:0'], + 'entries.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], + ]; + } + + /** @return array */ + public function after(): array + { + return [ + function (Validator $validator): void { + foreach ($this->input('entries', []) as $index => $entry) { + if (! is_array($entry)) { + continue; + } + + $dateIds = $entry['event_date_ids'] ?? []; + + if (! is_array($dateIds)) { + continue; + } + + if (count($dateIds) !== count(array_unique($dateIds))) { + $validator->errors()->add( + "entries.{$index}.event_date_ids", + 'Las fechas de una entrada no pueden repetirse.', + ); + } + } + }, + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php new file mode 100644 index 0000000..41e414f --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php @@ -0,0 +1,26 @@ + */ + public function toArray(Request $request): array + { + $variant = $this->variants->sole(); + + return [ + 'id' => $this->id, + 'title' => $this->nombre, + 'description' => $this->descripcion, + 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), + 'stock' => $variant->inventory->real_stock, + 'price' => $this->precio, + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php new file mode 100644 index 0000000..7f239bf --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php @@ -0,0 +1,143 @@ +> $entries + * @return Collection + */ + public function upsertMany(Tenant $tenant, array $entries): Collection + { + return DB::transaction(function () use ($tenant, $entries): Collection { + $reservedSlugs = []; + + return collect($entries)->map(function (array $entry, int $index) use ($tenant, &$reservedSlugs): CatalogItem { + if (isset($entry['id'])) { + return $this->update($tenant, $entry, $index); + } + + $slug = $this->uniqueSlug($tenant, $entry['title'], $reservedSlugs); + $reservedSlugs[] = $slug; + + return $this->catalogService->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => $slug, + 'nombre' => $entry['title'], + 'descripcion' => $entry['description'] ?? null, + 'precio' => $entry['price'], + 'event_product_type' => EventProductType::Entry->value, + 'has_tickets' => true, + 'inventory_policy' => InventoryPolicy::Tracked->value, + 'attribute_codes' => ['event_date'], + 'multi_select_attribute_codes' => ['event_date'], + 'variants' => [[ + 'real_stock' => $entry['stock'], + 'event_date_ids' => array_values($entry['event_date_ids']), + ]], + ]); + })->values(); + }); + } + + /** @param array $entry */ + private function update(Tenant $tenant, array $entry, int $index): CatalogItem + { + $catalogItem = CatalogItem::query() + ->whereKey($entry['id']) + ->where('tenant_code', $tenant->codigo) + ->where('event_product_type', EventProductType::Entry->value) + ->lockForUpdate() + ->firstOrFail(); + + $variants = Variant::query() + ->where('catalog_item_id', $catalogItem->id) + ->lockForUpdate() + ->get(); + + if ($variants->count() !== 1) { + throw ValidationException::withMessages([ + "entries.{$index}.id" => [ + 'La entrada no posee una única variante editable.', + ], + ]); + } + + $variant = $variants->first(); + $inventory = Inventory::query() + ->whereKey($variant->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + + if ((int) $entry['stock'] < $inventory->reserved_stock) { + throw ValidationException::withMessages([ + "entries.{$index}.stock" => [ + 'El stock no puede ser menor que la cantidad actualmente reservada.', + ], + ]); + } + + $eventDateIds = collect($entry['event_date_ids']) + ->map(fn ($id): int => (int) $id) + ->unique() + ->values(); + + $catalogItem->update([ + 'nombre' => $entry['title'], + 'descripcion' => $entry['description'] ?? null, + 'precio' => $entry['price'], + 'has_tickets' => true, + 'inventory_policy' => InventoryPolicy::Tracked->value, + ]); + $variant->update([ + 'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null, + ]); + $variant->eventDates()->sync($eventDateIds->all()); + $catalogItem->itemAttributes() + ->whereHas('attribute', fn ($query) => $query->where('codigo', 'event_date')) + ->update(['allow_multi_select' => true]); + $inventory->update(['real_stock' => $entry['stock']]); + + return $catalogItem->load([ + 'variants.inventory', + 'variants.eventDate', + 'variants.eventDates', + ]); + } + + /** @param array $reservedSlugs */ + private function uniqueSlug(Tenant $tenant, string $title, array $reservedSlugs): string + { + $baseSlug = Str::slug($title) ?: 'entrada'; + $slug = $baseSlug; + $suffix = 2; + + while ( + in_array($slug, $reservedSlugs, true) + || CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', $slug) + ->exists() + ) { + $slug = "{$baseSlug}-{$suffix}"; + $suffix++; + } + + return $slug; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/routes/api.php b/app/Domains/FiestaFutbolInfantil/routes/api.php new file mode 100644 index 0000000..6b53338 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/routes/api.php @@ -0,0 +1,11 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::post('entries', [EntryController::class, 'store']) + ->name('adminapp.fiesta-futbol-infantil.entries.store'); + }); diff --git a/database/seeders/FiestaFutbolInfantilProductSeeder.php b/database/seeders/FiestaFutbolInfantilProductSeeder.php index b9566c4..4cd1bdc 100644 --- a/database/seeders/FiestaFutbolInfantilProductSeeder.php +++ b/database/seeders/FiestaFutbolInfantilProductSeeder.php @@ -13,7 +13,6 @@ use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Seeder; -use Illuminate\Support\Collection; use RuntimeException; class FiestaFutbolInfantilProductSeeder extends Seeder @@ -93,9 +92,10 @@ class FiestaFutbolInfantilProductSeeder extends Seeder 'has_tickets' => true, 'attribute_codes' => ['event_date'], 'multi_select_attribute_codes' => ['event_date'], - 'variants' => $this->nonEmptySubsets($dateIds) - ->map(fn (array $ids): array => ['real_stock' => 0, 'event_date_ids' => $ids]) - ->all(), + 'variants' => [[ + 'real_stock' => 0, + 'event_date_ids' => $dateIds->all(), + ]], ]); FeaturedGroup::query()->create([ @@ -128,25 +128,4 @@ class FiestaFutbolInfantilProductSeeder extends Seeder ...$data, ]); } - - /** @return Collection> */ - private function nonEmptySubsets(Collection $values): Collection - { - $items = $values->all(); - $subsets = collect(); - - for ($mask = 1; $mask < (1 << count($items)); $mask++) { - $subset = []; - foreach ($items as $index => $item) { - if (($mask & (1 << $index)) !== 0) { - $subset[] = $item; - } - } - $subsets->push($subset); - } - - return $subsets - ->sortBy(fn (array $subset): string => count($subset).':'.implode(',', $subset)) - ->values(); - } } diff --git a/routes/api.php b/routes/api.php index 9339e26..f7154be 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,3 +15,4 @@ require __DIR__.'/../app/Domains/Event/routes/api.php'; require __DIR__.'/../app/Domains/Bootstrap/routes/api.php'; require __DIR__.'/../app/Domains/Forms/routes/api.php'; require __DIR__.'/../app/Domains/Staff/routes/api.php'; +require __DIR__.'/../app/Domains/FiestaFutbolInfantil/routes/api.php'; diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php new file mode 100644 index 0000000..9c9102f --- /dev/null +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -0,0 +1,238 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_authentication_is_required(): void + { + $this->postJson('/api/v1/adminapp/tenant/entries', ['entries' => []]) + ->assertUnauthorized(); + } + + public function test_it_creates_multiple_entries_with_dates_and_tracked_inventory(): void + { + $tenant = $this->createFiestaTenant(); + $this->createEventDateAttribute($tenant); + $firstDate = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $secondDate = $tenant->eventDates()->create([ + 'date' => '2026-10-10', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [ + [ + 'title' => 'Abono', + 'description' => 'Acceso para ambas jornadas', + 'event_date_ids' => [$firstDate->id, $secondDate->id], + 'stock' => 1000, + 'price' => 10000, + ], + [ + 'title' => 'Entrada diaria', + 'description' => null, + 'event_date_ids' => [$firstDate->id], + 'stock' => 250, + 'price' => 5000.50, + ], + ], + ]) + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.title', 'Abono') + ->assertJsonPath('data.0.event_date_ids', [$firstDate->id, $secondDate->id]) + ->assertJsonPath('data.0.stock', 1000) + ->assertJsonPath('data.0.price', '10000.00') + ->assertJsonPath('data.1.title', 'Entrada diaria') + ->assertJsonPath('data.1.price', '5000.50'); + + $this->assertDatabaseCount('catalog_items', 2); + $this->assertDatabaseCount('variantes', 2); + $this->assertDatabaseCount('inventories', 2); + $this->assertDatabaseCount('variant_event_dates', 3); + + CatalogItem::query()->each(function (CatalogItem $entry) use ($tenant): void { + $this->assertSame($tenant->codigo, $entry->tenant_code); + $this->assertSame('entrada', $entry->event_product_type->value); + $this->assertSame('tracked', $entry->inventory_policy->value); + $this->assertTrue($entry->has_tickets); + $this->assertNull($entry->inventory_id); + }); + } + + public function test_it_updates_entries_with_an_id_and_creates_entries_without_one(): void + { + $tenant = $this->createFiestaTenant(); + $this->createEventDateAttribute($tenant); + $firstDate = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $secondDate = $tenant->eventDates()->create([ + 'date' => '2026-10-10', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $createdId = $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'title' => 'Abono original', + 'description' => null, + 'event_date_ids' => [$firstDate->id], + 'stock' => 10, + 'price' => 100, + ]], + ])->assertOk()->json('data.0.id'); + + $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [ + [ + 'id' => $createdId, + 'title' => 'Abono actualizado', + 'description' => 'Ahora incluye ambas fechas', + 'event_date_ids' => [$firstDate->id, $secondDate->id], + 'stock' => 20, + 'price' => 250, + ], + [ + 'title' => 'Entrada nueva', + 'description' => null, + 'event_date_ids' => [$secondDate->id], + 'stock' => 30, + 'price' => 300, + ], + ], + ]) + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $createdId) + ->assertJsonPath('data.0.title', 'Abono actualizado') + ->assertJsonPath('data.0.event_date_ids', [$firstDate->id, $secondDate->id]) + ->assertJsonPath('data.0.stock', 20) + ->assertJsonPath('data.1.title', 'Entrada nueva'); + + $this->assertDatabaseCount('catalog_items', 2); + $this->assertDatabaseHas('catalog_items', [ + 'id' => $createdId, + 'nombre' => 'Abono actualizado', + 'precio' => 250, + ]); + } + + public function test_dates_must_belong_to_the_authenticated_tenant(): void + { + $tenant = $this->createFiestaTenant(); + $otherTenant = $this->createTenant('other'); + $this->createEventDateAttribute($tenant); + $foreignDate = $otherTenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'title' => 'Entrada inválida', + 'description' => null, + 'event_date_ids' => [$foreignDate->id], + 'stock' => 10, + 'price' => 100, + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['entries.0.event_date_ids.0']); + + $this->assertDatabaseCount('catalog_items', 0); + } + + public function test_the_endpoint_is_only_available_for_fiesta_futbol_infantil(): void + { + $tenant = $this->createTenant('other'); + $this->createEventDateAttribute($tenant); + $eventDate = $tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/entries', [ + 'entries' => [[ + 'title' => 'Entrada', + 'description' => null, + 'event_date_ids' => [$eventDate->id], + 'stock' => 10, + 'price' => 100, + ]], + ])->assertNotFound(); + } + + private function createFiestaTenant(): Tenant + { + return $this->createTenant('fiesta_futbol_infantil'); + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + ]); + } + + private function createEventDateAttribute(Tenant $tenant): void + { + Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'event_date', + 'nombre' => 'Fecha', + 'type' => FieldType::EventDate, + 'is_required' => true, + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } +} diff --git a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php index c3c06c7..447a45d 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -54,7 +54,7 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase 'camiseta' => 12, 'alojamiento' => 2, 'comida' => 24, - 'abono' => 15, + 'abono' => 1, ]; foreach ($expectedVariantCounts as $slug => $count) { $item = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', $slug)->sole(); @@ -70,7 +70,7 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase $dateAttribute = $abono->itemAttributes->firstWhere('attribute.codigo', 'event_date'); $this->assertTrue($dateAttribute->allow_multi_select); $this->assertEqualsCanonicalizing( - [1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 4], + [4], $abono->variants->map(fn ($variant): int => $variant->eventDates->count())->all(), );