feat(entry): implement EntryController, EntryService, and EntryResource for managing entries

This commit is contained in:
2026-08-07 12:34:38 -03:00
parent ed4502425a
commit 6a6d3690cc
9 changed files with 531 additions and 27 deletions

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Requests\UpsertEntriesRequest;
use App\Domains\FiestaFutbolInfantil\Resources\EntryResource;
use App\Domains\FiestaFutbolInfantil\Services\EntryService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class EntryController extends Controller
{
public function __construct(private readonly EntryService $entryService) {}
public function store(UpsertEntriesRequest $request): JsonResponse
{
$tenant = $request->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);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpsertEntriesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
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<int, callable> */
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.',
);
}
}
},
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class EntryResource extends JsonResource
{
/** @return array<string, mixed> */
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,
];
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class EntryService
{
public function __construct(private readonly CatalogService $catalogService) {}
/**
* @param array<int, array<string, mixed>> $entries
* @return Collection<int, CatalogItem>
*/
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<string, mixed> $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<int, string> $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;
}
}

View File

@@ -0,0 +1,11 @@
<?php
use App\Domains\FiestaFutbolInfantil\Controllers\EntryController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::post('entries', [EntryController::class, 'store'])
->name('adminapp.fiesta-futbol-infantil.entries.store');
});

View File

@@ -13,7 +13,6 @@ use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use RuntimeException; use RuntimeException;
class FiestaFutbolInfantilProductSeeder extends Seeder class FiestaFutbolInfantilProductSeeder extends Seeder
@@ -93,9 +92,10 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'has_tickets' => true, 'has_tickets' => true,
'attribute_codes' => ['event_date'], 'attribute_codes' => ['event_date'],
'multi_select_attribute_codes' => ['event_date'], 'multi_select_attribute_codes' => ['event_date'],
'variants' => $this->nonEmptySubsets($dateIds) 'variants' => [[
->map(fn (array $ids): array => ['real_stock' => 0, 'event_date_ids' => $ids]) 'real_stock' => 0,
->all(), 'event_date_ids' => $dateIds->all(),
]],
]); ]);
FeaturedGroup::query()->create([ FeaturedGroup::query()->create([
@@ -128,25 +128,4 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
...$data, ...$data,
]); ]);
} }
/** @return Collection<int, array<int, int>> */
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();
}
} }

View File

@@ -15,3 +15,4 @@ require __DIR__.'/../app/Domains/Event/routes/api.php';
require __DIR__.'/../app/Domains/Bootstrap/routes/api.php'; require __DIR__.'/../app/Domains/Bootstrap/routes/api.php';
require __DIR__.'/../app/Domains/Forms/routes/api.php'; require __DIR__.'/../app/Domains/Forms/routes/api.php';
require __DIR__.'/../app/Domains/Staff/routes/api.php'; require __DIR__.'/../app/Domains/Staff/routes/api.php';
require __DIR__.'/../app/Domains/FiestaFutbolInfantil/routes/api.php';

View File

@@ -0,0 +1,238 @@
<?php
namespace Tests\Feature\FiestaFutbolInfantil;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class EntryControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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,
]);
}
}

View File

@@ -54,7 +54,7 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
'camiseta' => 12, 'camiseta' => 12,
'alojamiento' => 2, 'alojamiento' => 2,
'comida' => 24, 'comida' => 24,
'abono' => 15, 'abono' => 1,
]; ];
foreach ($expectedVariantCounts as $slug => $count) { foreach ($expectedVariantCounts as $slug => $count) {
$item = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', $slug)->sole(); $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'); $dateAttribute = $abono->itemAttributes->firstWhere('attribute.codigo', 'event_date');
$this->assertTrue($dateAttribute->allow_multi_select); $this->assertTrue($dateAttribute->allow_multi_select);
$this->assertEqualsCanonicalizing( $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(), $abono->variants->map(fn ($variant): int => $variant->eventDates->count())->all(),
); );