From de8da72354bdace5c285f523b1060c0b37b85484 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 14 Aug 2026 13:32:02 -0300 Subject: [PATCH] feat(desfile): refactor entry management to use row-based configuration and expand seat options --- .../Desfile/Controllers/EntryController.php | 8 +- .../Desfile/Requests/SyncEntryRowsRequest.php | 55 ++++++ .../Requests/SyncEntryVariantsRequest.php | 56 ------ .../Desfile/Resources/EntryResource.php | 39 +++-- app/Domains/Desfile/Services/EntryService.php | 165 ++++++++++-------- ..._14_040000_expand_desfile_seat_options.php | 50 ++++++ tests/Feature/Desfile/EntryControllerTest.php | 106 ++++++----- .../CreateDesfilePuraTendenciaTenantTest.php | 7 +- 8 files changed, 298 insertions(+), 188 deletions(-) create mode 100644 app/Domains/Desfile/Requests/SyncEntryRowsRequest.php delete mode 100644 app/Domains/Desfile/Requests/SyncEntryVariantsRequest.php create mode 100644 database/migrations/2026_08_14_040000_expand_desfile_seat_options.php diff --git a/app/Domains/Desfile/Controllers/EntryController.php b/app/Domains/Desfile/Controllers/EntryController.php index ec8ca81..51e09db 100644 --- a/app/Domains/Desfile/Controllers/EntryController.php +++ b/app/Domains/Desfile/Controllers/EntryController.php @@ -3,7 +3,7 @@ namespace App\Domains\Desfile\Controllers; use App\Domains\Desfile\Requests\ReplaceEntryImageRequest; -use App\Domains\Desfile\Requests\SyncEntryVariantsRequest; +use App\Domains\Desfile\Requests\SyncEntryRowsRequest; use App\Domains\Desfile\Requests\UpdateEntryImageRequest; use App\Domains\Desfile\Resources\EntryResource; use App\Domains\Desfile\Services\EntryService; @@ -23,11 +23,11 @@ class EntryController extends Controller ); } - public function update(SyncEntryVariantsRequest $request): EntryResource + public function update(SyncEntryRowsRequest $request): EntryResource { - return new EntryResource($this->entryService->syncVariants( + return new EntryResource($this->entryService->syncRows( $request->user()->tenant()->firstOrFail(), - $request->validated('variants'), + $request->validated('rows'), )); } diff --git a/app/Domains/Desfile/Requests/SyncEntryRowsRequest.php b/app/Domains/Desfile/Requests/SyncEntryRowsRequest.php new file mode 100644 index 0000000..4282fa6 --- /dev/null +++ b/app/Domains/Desfile/Requests/SyncEntryRowsRequest.php @@ -0,0 +1,55 @@ + */ + public function rules(): array + { + return [ + 'rows' => ['required', 'array', 'min:1', 'max:1000'], + 'rows.*' => ['required', 'array:type,sector,row,max_seat,price'], + 'rows.*.type' => ['required', 'string', 'max:100'], + 'rows.*.sector' => ['required', 'string', 'max:100'], + 'rows.*.row' => ['required', 'integer', 'between:1,5'], + 'rows.*.max_seat' => ['required', 'integer', 'between:1,100'], + 'rows.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], + ]; + } + + /** @return array */ + public function after(): array + { + return [function (Validator $validator): void { + $combinations = []; + + foreach ($this->input('rows', []) as $index => $row) { + if (! is_array($row)) { + continue; + } + + $combination = collect(['type', 'sector', 'row']) + ->map(fn (string $field): string => mb_strtolower(trim((string) ($row[$field] ?? '')))) + ->implode('|'); + + if (isset($combinations[$combination])) { + $validator->errors()->add( + "rows.{$index}", + 'La combinación de tipo, sector y fila no puede repetirse.', + ); + } + + $combinations[$combination] = true; + } + }]; + } +} diff --git a/app/Domains/Desfile/Requests/SyncEntryVariantsRequest.php b/app/Domains/Desfile/Requests/SyncEntryVariantsRequest.php deleted file mode 100644 index 9424f6c..0000000 --- a/app/Domains/Desfile/Requests/SyncEntryVariantsRequest.php +++ /dev/null @@ -1,56 +0,0 @@ - */ - public function rules(): array - { - return [ - 'variants' => ['required', 'array', 'min:1', 'max:1000'], - 'variants.*' => ['required', 'array:id,type,sector,row,seat,price'], - 'variants.*.id' => ['sometimes', 'integer', 'distinct'], - 'variants.*.type' => ['required', 'string', 'max:100'], - 'variants.*.sector' => ['required', 'string', 'max:100'], - 'variants.*.row' => ['required', 'string', 'max:100'], - 'variants.*.seat' => ['required', 'string', 'max:100'], - 'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], - ]; - } - - /** @return array */ - public function after(): array - { - return [function (Validator $validator): void { - $combinations = []; - - foreach ($this->input('variants', []) as $index => $variant) { - if (! is_array($variant)) { - continue; - } - - $combination = collect(['type', 'sector', 'row', 'seat']) - ->map(fn (string $field): string => mb_strtolower(trim((string) ($variant[$field] ?? '')))) - ->implode('|'); - - if (isset($combinations[$combination])) { - $validator->errors()->add( - "variants.{$index}", - 'La combinación de tipo, sector, fila y asiento no puede repetirse.', - ); - } - - $combinations[$combination] = true; - } - }]; - } -} diff --git a/app/Domains/Desfile/Resources/EntryResource.php b/app/Domains/Desfile/Resources/EntryResource.php index a6e0997..497a725 100644 --- a/app/Domains/Desfile/Resources/EntryResource.php +++ b/app/Domains/Desfile/Resources/EntryResource.php @@ -16,18 +16,35 @@ class EntryResource extends JsonResource return [ 'id' => $this->id, - 'variants' => $this->variants->map(function ($variant): array { - $values = $variant->selectionValues(); + 'rows' => $this->variants + ->map(function ($variant): array { + $values = $variant->selectionValues(); - return [ - 'id' => $variant->id, - 'type' => $values->get('tipo'), - 'sector' => $values->get('sector'), - 'row' => $values->get('fila'), - 'seat' => $values->get('asiento'), - 'price' => number_format($variant->getPrice(), 2, '.', ''), - ]; - })->values(), + return [ + 'type' => $values->get('tipo'), + 'sector' => $values->get('sector'), + 'row' => $values->get('fila'), + 'seat' => (int) $values->get('asiento'), + 'price' => $variant->getPrice(), + ]; + }) + ->groupBy(fn (array $variant): string => implode('|', [ + mb_strtolower(trim((string) $variant['type'])), + mb_strtolower(trim((string) $variant['sector'])), + mb_strtolower(trim((string) $variant['row'])), + ])) + ->map(function ($variants): array { + $first = $variants->first(); + + return [ + 'type' => $first['type'], + 'sector' => $first['sector'], + 'row' => $first['row'], + 'max_seat' => $variants->max('seat'), + 'price' => number_format($first['price'], 2, '.', ''), + ]; + }) + ->values(), 'image' => $image === null ? null : [ 'key' => $image->key, 'filename' => $image->filename, diff --git a/app/Domains/Desfile/Services/EntryService.php b/app/Domains/Desfile/Services/EntryService.php index 6e81bc5..0ddd1d1 100644 --- a/app/Domains/Desfile/Services/EntryService.php +++ b/app/Domains/Desfile/Services/EntryService.php @@ -26,6 +26,12 @@ class EntryService 'seat' => 'asiento', ]; + private const ROW_ATTRIBUTE_MAP = [ + 'type' => 'tipo', + 'sector' => 'sector', + 'row' => 'fila', + ]; + public function __construct(private readonly AttachmentService $attachmentService) {} public function current(Tenant $tenant): CatalogItem @@ -42,68 +48,70 @@ class EntryService } /** - * @param array> $variants + * @param array> $rows */ - public function syncVariants(Tenant $tenant, array $variants): CatalogItem + public function syncRows(Tenant $tenant, array $rows): CatalogItem { - DB::transaction(function () use ($tenant, $variants): void { + DB::transaction(function () use ($tenant, $rows): void { $entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail(); $itemAttributes = $this->itemAttributes($entry); $existingVariants = $entry->variants() ->with(['inventory', 'definitions.itemAttribute.attribute']) ->lockForUpdate() ->get(); - $incomingIds = collect($variants) - ->pluck('id') - ->filter() - ->map(fn ($id): int => (int) $id) - ->values(); + $existingBySelection = $existingVariants->keyBy( + fn (Variant $variant): string => $this->selectionKey($variant->selectionValues()->all()), + ); + $desiredSelections = collect(); - foreach ($existingVariants->whereNotIn('id', $incomingIds) as $variant) { - $this->assertVariantCanChangeIdentity($variant, 'variants'); - $variant->delete(); + foreach (array_values($rows) as $index => $data) { + $rowValues = $this->resolveRowValues($itemAttributes, $data, $index); + + foreach (range(1, (int) $data['max_seat']) as $seat) { + $values = [ + ...$rowValues, + 'asiento' => $this->resolveAttributeValue( + $itemAttributes, + 'asiento', + (string) $seat, + "rows.{$index}.max_seat", + ), + ]; + $selectionKey = $this->selectionKey($values); + $desiredSelections->put($selectionKey, true); + $variant = $existingBySelection->get($selectionKey); + + if ($variant === null) { + $variant = $entry->variants()->create([ + 'inventory_id' => Inventory::query()->create(['real_stock' => 1])->id, + 'descripcion' => $this->description($values), + 'precio' => $data['price'], + ]); + $variant->definitions()->createMany( + collect($values)->map( + fn (string $value, string $code): array => [ + 'item_attribute_id' => $itemAttributes[$code]->id, + 'value' => $value, + ], + )->values()->all(), + ); + } else { + $variant->update([ + 'descripcion' => $this->description($values), + 'precio' => $data['price'], + ]); + } + } } - foreach (array_values($variants) as $index => $data) { - $values = $this->resolveValues($itemAttributes, $data, $index); - $variant = isset($data['id']) - ? $existingVariants->firstWhere('id', (int) $data['id']) - : null; - - if (isset($data['id']) && $variant === null) { - throw ValidationException::withMessages([ - "variants.{$index}.id" => [ - 'La variante no pertenece al producto Entrada del desfile.', - ], - ]); + foreach ($existingVariants as $variant) { + $selectionKey = $this->selectionKey($variant->selectionValues()->all()); + if ($desiredSelections->has($selectionKey)) { + continue; } - if ($variant === null) { - $variant = $entry->variants()->create([ - 'inventory_id' => Inventory::query()->create(['real_stock' => 1])->id, - 'descripcion' => $this->description($values), - 'precio' => $data['price'], - ]); - } else { - if ($this->identityChanged($variant, $values)) { - $this->assertVariantCanChangeIdentity($variant, "variants.{$index}"); - } - - $variant->update([ - 'descripcion' => $this->description($values), - 'precio' => $data['price'], - ]); - $variant->definitions()->delete(); - } - - $variant->definitions()->createMany( - collect($values)->map( - fn (string $value, string $code): array => [ - 'item_attribute_id' => $itemAttributes[$code]->id, - 'value' => $value, - ], - )->values()->all(), - ); + $this->assertVariantCanChangeIdentity($variant, 'rows'); + $variant->delete(); } $minimumPrice = $entry->variants()->min('precio'); @@ -183,7 +191,7 @@ class EntryService if ($missing->isNotEmpty()) { throw ValidationException::withMessages([ - 'variants' => [ + 'rows' => [ 'Faltan atributos requeridos para las entradas del desfile: '.$missing->implode(', ').'.', ], ]); @@ -197,40 +205,49 @@ class EntryService * @param array $data * @return array */ - private function resolveValues(Collection $itemAttributes, array $data, int $index): array + private function resolveRowValues(Collection $itemAttributes, array $data, int $index): array { $values = []; - foreach (self::ATTRIBUTE_MAP as $input => $code) { - $requestedValue = trim((string) $data[$input]); - $option = $itemAttributes[$code]->attribute->options->first( - fn ($candidate): bool => $this->normalize($candidate->value) === $this->normalize($requestedValue), + foreach (self::ROW_ATTRIBUTE_MAP as $input => $code) { + $values[$code] = $this->resolveAttributeValue( + $itemAttributes, + $code, + (string) $data[$input], + "rows.{$index}.{$input}", ); - - if ($option === null) { - throw ValidationException::withMessages([ - "variants.{$index}.{$input}" => ['La opción seleccionada no es válida.'], - ]); - } - - $values[$code] = $option->value; } return $values; } - /** @param array $values */ - private function identityChanged(Variant $variant, array $values): bool - { - $currentValues = $variant->definitions - ->mapWithKeys(fn ($definition): array => [ - $definition->itemAttribute?->attribute?->codigo => $definition->value, - ]); - - return collect($values)->contains( - fn (string $value, string $code): bool => $this->normalize((string) $currentValues->get($code)) - !== $this->normalize($value), + /** @param Collection $itemAttributes */ + private function resolveAttributeValue( + Collection $itemAttributes, + string $code, + string $requestedValue, + string $errorKey, + ): string { + $requestedValue = trim($requestedValue); + $option = $itemAttributes[$code]->attribute->options->first( + fn ($candidate): bool => $this->normalize($candidate->value) === $this->normalize($requestedValue), ); + + if ($option === null) { + throw ValidationException::withMessages([ + $errorKey => ['La opción seleccionada no es válida.'], + ]); + } + + return $option->value; + } + + /** @param array $values */ + private function selectionKey(array $values): string + { + return collect(self::ATTRIBUTE_MAP) + ->map(fn (string $code): string => $this->normalize((string) ($values[$code] ?? ''))) + ->implode('|'); } private function assertVariantCanChangeIdentity(Variant $variant, string $key): void diff --git a/database/migrations/2026_08_14_040000_expand_desfile_seat_options.php b/database/migrations/2026_08_14_040000_expand_desfile_seat_options.php new file mode 100644 index 0000000..ab30edb --- /dev/null +++ b/database/migrations/2026_08_14_040000_expand_desfile_seat_options.php @@ -0,0 +1,50 @@ +replaceSeatOptions(100); + } + + public function down(): void + { + $this->replaceSeatOptions(17); + } + + private function replaceSeatOptions(int $maximum): void + { + $attributeId = DB::table('attribute') + ->where('tenant_codigo', self::TENANT_CODE) + ->where('codigo', 'asiento') + ->value('id'); + + if ($attributeId === null) { + return; + } + + DB::transaction(function () use ($attributeId, $maximum): void { + DB::table('attribute_options')->where('attribute_id', $attributeId)->delete(); + + $now = now(); + DB::table('attribute_options')->insert(array_map( + fn (int $seat): array => [ + 'attribute_id' => $attributeId, + 'validity_time_id' => null, + 'value' => (string) $seat, + 'label' => (string) $seat, + 'sort_order' => $seat, + 'metadata' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + range(1, $maximum), + )); + }); + } +}; diff --git a/tests/Feature/Desfile/EntryControllerTest.php b/tests/Feature/Desfile/EntryControllerTest.php index 99ed234..0ddb972 100644 --- a/tests/Feature/Desfile/EntryControllerTest.php +++ b/tests/Feature/Desfile/EntryControllerTest.php @@ -39,7 +39,7 @@ class EntryControllerTest extends TestCase ]); } - public function test_it_returns_and_synchronizes_the_single_entry_product_variants(): void + public function test_it_returns_row_configs_and_expands_them_into_seat_variants(): void { [$tenant, $entry] = $this->configuredEntry(); $existing = $this->createVariant($entry, 'NORMAL', 'A', '1', '1', 100000); @@ -48,26 +48,25 @@ class EntryControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/desfile/entries') ->assertOk() ->assertJsonPath('data.id', $entry->id) - ->assertJsonPath('data.variants.0.id', $existing->id) - ->assertJsonPath('data.variants.0.type', 'NORMAL') - ->assertJsonPath('data.variants.0.seat', '1') - ->assertJsonPath('data.variants.0.price', '100000.00'); + ->assertJsonPath('data.rows.0.type', 'NORMAL') + ->assertJsonPath('data.rows.0.row', '1') + ->assertJsonPath('data.rows.0.max_seat', 1) + ->assertJsonPath('data.rows.0.price', '100000.00'); $response = $this->putJson('/api/v1/adminapp/tenant/desfile/entries', [ - 'variants' => [ + 'rows' => [ [ - 'id' => $existing->id, 'type' => 'NORMAL', 'sector' => 'A', 'row' => '1', - 'seat' => '1', + 'max_seat' => 2, 'price' => 120000, ], [ 'type' => 'VIP + LUNCH', 'sector' => 'B', 'row' => '2', - 'seat' => '3', + 'max_seat' => 3, 'price' => 250000, ], ], @@ -75,21 +74,21 @@ class EntryControllerTest extends TestCase $response ->assertOk() - ->assertJsonCount(2, 'data.variants') - ->assertJsonPath('data.variants.0.price', '120000.00') - ->assertJsonPath('data.variants.1.type', 'VIP + LUNCH'); + ->assertJsonCount(2, 'data.rows') + ->assertJsonPath('data.rows.0.max_seat', 2) + ->assertJsonPath('data.rows.0.price', '120000.00') + ->assertJsonPath('data.rows.1.max_seat', 3) + ->assertJsonPath('data.rows.1.type', 'VIP + LUNCH'); - $this->assertDatabaseCount('variantes', 2); + $this->assertDatabaseCount('variantes', 5); $this->assertDatabaseHas('catalog_items', [ 'id' => $entry->id, 'precio' => 120000, ]); - $this->assertDatabaseHas('inventories', [ - 'id' => $response->json('data.variants.1.id') === null - ? 0 - : Variant::query()->findOrFail($response->json('data.variants.1.id'))->inventory_id, - 'real_stock' => 1, - ]); + $this->assertSame('120000.00', $existing->fresh()->precio); + $this->assertSame(2, $entry->variants()->where('precio', 120000)->count()); + $this->assertSame(3, $entry->variants()->where('precio', 250000)->count()); + $this->assertSame(5, Inventory::query()->where('real_stock', 1)->count()); } public function test_it_replaces_and_toggles_the_entry_image(): void @@ -129,32 +128,52 @@ class EntryControllerTest extends TestCase $this->assertCount(1, $entry->fresh()->attachments); } - public function test_it_rejects_duplicate_seats_and_cross_tenant_access(): void + public function test_it_rejects_duplicate_rows(): void { - [, $entry] = $this->configuredEntry(); - $foreignVariant = $this->createVariant($entry, 'NORMAL', 'A', '1', '1', 100); - [$otherTenant] = $this->configuredEntry('other-desfile'); - Sanctum::actingAs($this->createAdminAppUser($otherTenant)); - - $this->putJson('/api/v1/adminapp/tenant/desfile/entries', [ - 'variants' => [[ - 'id' => $foreignVariant->id, - ...$this->variantPayload('NORMAL', 'A', '1', '1', 100), - ]], - ]) - ->assertUnprocessable() - ->assertJsonValidationErrors('variants.0.id'); + [$tenant] = $this->configuredEntry(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); $payload = [ - 'variants' => [ - $this->variantPayload('NORMAL', 'A', '1', '1', 100), - $this->variantPayload('normal', 'A', '1', '1', 200), + 'rows' => [ + $this->rowPayload('NORMAL', 'A', '1', 10, 100), + $this->rowPayload('normal', 'A', '1', 20, 200), ], ]; $this->putJson('/api/v1/adminapp/tenant/desfile/entries', $payload) ->assertUnprocessable() - ->assertJsonValidationErrors('variants.1'); + ->assertJsonValidationErrors('rows.1'); + } + + public function test_it_rejects_rows_and_seat_quantities_outside_the_configured_ranges(): void + { + [$tenant] = $this->configuredEntry(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->putJson('/api/v1/adminapp/tenant/desfile/entries', [ + 'rows' => [ + $this->rowPayload('NORMAL', 'A', '6', 1, 100), + $this->rowPayload('NORMAL', 'A', '1', 101, 100), + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['rows.0.row', 'rows.1.max_seat']); + } + + public function test_it_cannot_reduce_the_maximum_below_a_reserved_seat(): void + { + [$tenant, $entry] = $this->configuredEntry(); + $reserved = $this->createVariant($entry, 'NORMAL', 'A', '1', '3', 100); + $reserved->inventory()->update(['reserved_stock' => 1]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->putJson('/api/v1/adminapp/tenant/desfile/entries', [ + 'rows' => [$this->rowPayload('NORMAL', 'A', '1', 2, 100)], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('rows'); + + $this->assertDatabaseHas('variantes', ['id' => $reserved->id, 'deleted_at' => null]); } /** @return array{Tenant, CatalogItem} */ @@ -184,8 +203,8 @@ class EntryControllerTest extends TestCase foreach ([ 'tipo' => ['VIP + LUNCH', 'NORMAL'], 'sector' => ['A', 'B', 'C', 'D'], - 'fila' => ['1', '2'], - 'asiento' => ['1', '2', '3'], + 'fila' => array_map('strval', range(1, 5)), + 'asiento' => array_map('strval', range(1, 100)), ] as $code => $options) { $attribute = Attribute::query()->create([ 'tenant_codigo' => $tenant->codigo, @@ -238,14 +257,17 @@ class EntryControllerTest extends TestCase } /** @return array */ - private function variantPayload( + private function rowPayload( string $type, string $sector, string $row, - string $seat, + int $maxSeat, int $price, ): array { - return compact('type', 'sector', 'row', 'seat', 'price'); + return [ + ...compact('type', 'sector', 'row', 'price'), + 'max_seat' => $maxSeat, + ]; } private function createAdminAppUser(Tenant $tenant): User diff --git a/tests/Feature/Migrations/CreateDesfilePuraTendenciaTenantTest.php b/tests/Feature/Migrations/CreateDesfilePuraTendenciaTenantTest.php index 28134e1..85db67b 100644 --- a/tests/Feature/Migrations/CreateDesfilePuraTendenciaTenantTest.php +++ b/tests/Feature/Migrations/CreateDesfilePuraTendenciaTenantTest.php @@ -36,6 +36,11 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase ); $rowAndSeatMigration->up(); + $seatOptionsMigration = require database_path( + 'migrations/2026_08_14_040000_expand_desfile_seat_options.php' + ); + $seatOptionsMigration->up(); + $footerBackgroundMigration = require database_path( 'migrations/2026_08_12_060000_set_desfile_footer_background_image.php' ); @@ -192,7 +197,7 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase 'tipo' => ['VIP + LUNCH', 'NORMAL'], 'sector' => ['A', 'B', 'C', 'D'], 'fila' => array_map('strval', range(1, 5)), - 'asiento' => array_map('strval', range(1, 17)), + 'asiento' => array_map('strval', range(1, 100)), ], $attributes->map(fn (object $attribute): array => DB::table('attribute_options') ->where('attribute_id', $attribute->id) ->orderBy('sort_order')