diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 22652d9..2828f65 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -133,6 +133,12 @@ class CatalogItem extends Model /** @return BelongsToMany */ public function attachments(): BelongsToMany + { + return $this->allAttachments()->wherePivot('is_enabled', true); + } + + /** @return BelongsToMany */ + public function allAttachments(): BelongsToMany { return $this->belongsToMany( Attachment::class, diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php index 39b685e..581afc3 100644 --- a/app/Domains/Catalog/Models/Variant.php +++ b/app/Domains/Catalog/Models/Variant.php @@ -84,6 +84,12 @@ class Variant extends Model /** @return BelongsToMany */ public function attachments(): BelongsToMany + { + return $this->allAttachments()->wherePivot('is_enabled', true); + } + + /** @return BelongsToMany */ + public function allAttachments(): BelongsToMany { $relation = $this->belongsToMany( Attachment::class, diff --git a/app/Domains/Desfile/Controllers/EntryController.php b/app/Domains/Desfile/Controllers/EntryController.php new file mode 100644 index 0000000..ec8ca81 --- /dev/null +++ b/app/Domains/Desfile/Controllers/EntryController.php @@ -0,0 +1,57 @@ +entryService->current($request->user()->tenant()->firstOrFail()), + ); + } + + public function update(SyncEntryVariantsRequest $request): EntryResource + { + return new EntryResource($this->entryService->syncVariants( + $request->user()->tenant()->firstOrFail(), + $request->validated('variants'), + )); + } + + public function replaceImage(ReplaceEntryImageRequest $request): JsonResponse + { + return (new EntryResource($this->entryService->replaceImage( + $request->user()->tenant()->firstOrFail(), + $request->file('image'), + $request->boolean('is_enabled', true), + )))->response(); + } + + public function updateImage(UpdateEntryImageRequest $request): EntryResource + { + return new EntryResource($this->entryService->updateImage( + $request->user()->tenant()->firstOrFail(), + $request->boolean('is_enabled'), + )); + } + + public function destroyImage(Request $request): Response + { + $this->entryService->deleteImage($request->user()->tenant()->firstOrFail()); + + return response()->noContent(); + } +} diff --git a/app/Domains/Desfile/Requests/ReplaceEntryImageRequest.php b/app/Domains/Desfile/Requests/ReplaceEntryImageRequest.php new file mode 100644 index 0000000..ddc99f6 --- /dev/null +++ b/app/Domains/Desfile/Requests/ReplaceEntryImageRequest.php @@ -0,0 +1,22 @@ + */ + public function rules(): array + { + return [ + 'image' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:10240'], + 'is_enabled' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Domains/Desfile/Requests/SyncEntryVariantsRequest.php b/app/Domains/Desfile/Requests/SyncEntryVariantsRequest.php new file mode 100644 index 0000000..9424f6c --- /dev/null +++ b/app/Domains/Desfile/Requests/SyncEntryVariantsRequest.php @@ -0,0 +1,56 @@ + */ + 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/Requests/UpdateEntryImageRequest.php b/app/Domains/Desfile/Requests/UpdateEntryImageRequest.php new file mode 100644 index 0000000..c00cbd1 --- /dev/null +++ b/app/Domains/Desfile/Requests/UpdateEntryImageRequest.php @@ -0,0 +1,21 @@ + */ + public function rules(): array + { + return [ + 'is_enabled' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Domains/Desfile/Resources/EntryResource.php b/app/Domains/Desfile/Resources/EntryResource.php new file mode 100644 index 0000000..a6e0997 --- /dev/null +++ b/app/Domains/Desfile/Resources/EntryResource.php @@ -0,0 +1,39 @@ + */ + public function toArray(Request $request): array + { + $image = $this->allAttachments->first(); + + return [ + 'id' => $this->id, + 'variants' => $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(), + 'image' => $image === null ? null : [ + 'key' => $image->key, + 'filename' => $image->filename, + 'url' => $image->getTemporaryUrl(1440), + 'is_enabled' => (bool) $image->pivot->is_enabled, + ], + ]; + } +} diff --git a/app/Domains/Desfile/Services/EntryService.php b/app/Domains/Desfile/Services/EntryService.php new file mode 100644 index 0000000..6e81bc5 --- /dev/null +++ b/app/Domains/Desfile/Services/EntryService.php @@ -0,0 +1,285 @@ + 'tipo', + 'sector' => 'sector', + 'row' => 'fila', + 'seat' => 'asiento', + ]; + + public function __construct(private readonly AttachmentService $attachmentService) {} + + public function current(Tenant $tenant): CatalogItem + { + return $this->entryQuery($tenant) + ->with([ + 'allAttachments', + 'itemAttributes.attribute.options', + 'variants' => fn ($query) => $query->orderBy('id'), + 'variants.inventory', + 'variants.definitions.itemAttribute.attribute', + ]) + ->firstOrFail(); + } + + /** + * @param array> $variants + */ + public function syncVariants(Tenant $tenant, array $variants): CatalogItem + { + DB::transaction(function () use ($tenant, $variants): 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(); + + foreach ($existingVariants->whereNotIn('id', $incomingIds) as $variant) { + $this->assertVariantCanChangeIdentity($variant, 'variants'); + $variant->delete(); + } + + 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.', + ], + ]); + } + + 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(), + ); + } + + $minimumPrice = $entry->variants()->min('precio'); + if ($minimumPrice !== null) { + $entry->update(['precio' => $minimumPrice]); + } + }); + + return $this->current($tenant); + } + + public function replaceImage( + Tenant $tenant, + UploadedFile $image, + bool $isEnabled, + ): CatalogItem { + $attachment = $this->attachmentService->store($image, 'catalog-items'); + $previousAttachments = collect(); + + try { + DB::transaction(function () use ($tenant, $attachment, $isEnabled, &$previousAttachments): void { + $entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail(); + $previousAttachments = $entry->allAttachments()->get(); + $entry->allAttachments()->sync([ + $attachment->id => [ + 'orden' => 0, + 'is_enabled' => $isEnabled, + ], + ]); + }); + } catch (Throwable $throwable) { + $this->deleteAttachmentQuietly($attachment); + throw $throwable; + } + + $previousAttachments->each(fn (Attachment $previous) => $this->deleteIfUnused($previous)); + + return $this->current($tenant); + } + + public function updateImage(Tenant $tenant, bool $isEnabled): CatalogItem + { + DB::transaction(function () use ($tenant, $isEnabled): void { + $entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail(); + $attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail(); + + $entry->allAttachments()->updateExistingPivot($attachment->id, [ + 'is_enabled' => $isEnabled, + ]); + }); + + return $this->current($tenant); + } + + public function deleteImage(Tenant $tenant): void + { + $attachment = DB::transaction(function () use ($tenant): Attachment { + $entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail(); + $attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail(); + $entry->allAttachments()->detach($attachment->id); + + return $attachment; + }); + + $this->deleteIfUnused($attachment); + } + + /** @return Collection */ + private function itemAttributes(CatalogItem $entry): Collection + { + $attributes = $entry->itemAttributes() + ->with('attribute.options') + ->get() + ->filter(fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute !== null) + ->keyBy(fn (ItemAttribute $itemAttribute): string => $itemAttribute->attribute->codigo); + $missing = collect(self::ATTRIBUTE_MAP)->diff($attributes->keys()); + + if ($missing->isNotEmpty()) { + throw ValidationException::withMessages([ + 'variants' => [ + 'Faltan atributos requeridos para las entradas del desfile: '.$missing->implode(', ').'.', + ], + ]); + } + + return $attributes; + } + + /** + * @param Collection $itemAttributes + * @param array $data + * @return array + */ + private function resolveValues(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), + ); + + 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), + ); + } + + private function assertVariantCanChangeIdentity(Variant $variant, string $key): void + { + $inventory = $variant->inventory; + + if (($inventory?->reserved_stock ?? 0) > 0 || ($inventory?->sold_units ?? 0) > 0) { + throw ValidationException::withMessages([ + $key => [ + 'No se puede modificar ni eliminar un asiento con ventas o reservas.', + ], + ]); + } + } + + /** @param array $values */ + private function description(array $values): string + { + return "Sector {$values['sector']} - Fila {$values['fila']} - Asiento {$values['asiento']} - {$values['tipo']}"; + } + + private function normalize(string $value): string + { + return Str::ascii(mb_strtolower(trim($value))); + } + + /** @return Builder */ + private function entryQuery(Tenant $tenant): Builder + { + return CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'entrada'); + } + + private function deleteIfUnused(Attachment $attachment): void + { + if (DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) { + return; + } + + $this->deleteAttachmentQuietly($attachment); + } + + private function deleteAttachmentQuietly(Attachment $attachment): void + { + try { + $this->attachmentService->delete($attachment); + } catch (Throwable $throwable) { + report($throwable); + } + } +} diff --git a/app/Domains/Desfile/routes/api.php b/app/Domains/Desfile/routes/api.php index 8528bd6..7827d3d 100644 --- a/app/Domains/Desfile/routes/api.php +++ b/app/Domains/Desfile/routes/api.php @@ -1,3 +1,19 @@ middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.entradas']) + ->group(function (): void { + Route::get('entries', [EntryController::class, 'show']) + ->name('adminapp.desfile.entries.show'); + Route::put('entries', [EntryController::class, 'update']) + ->name('adminapp.desfile.entries.update'); + Route::post('entries/image', [EntryController::class, 'replaceImage']) + ->name('adminapp.desfile.entries.image.replace'); + Route::patch('entries/image', [EntryController::class, 'updateImage']) + ->name('adminapp.desfile.entries.image.update'); + Route::delete('entries/image', [EntryController::class, 'destroyImage']) + ->name('adminapp.desfile.entries.image.destroy'); + }); diff --git a/tests/Feature/Desfile/EntryControllerTest.php b/tests/Feature/Desfile/EntryControllerTest.php new file mode 100644 index 0000000..99ed234 --- /dev/null +++ b/tests/Feature/Desfile/EntryControllerTest.php @@ -0,0 +1,258 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_it_returns_and_synchronizes_the_single_entry_product_variants(): void + { + [$tenant, $entry] = $this->configuredEntry(); + $existing = $this->createVariant($entry, 'NORMAL', 'A', '1', '1', 100000); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $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'); + + $response = $this->putJson('/api/v1/adminapp/tenant/desfile/entries', [ + 'variants' => [ + [ + 'id' => $existing->id, + 'type' => 'NORMAL', + 'sector' => 'A', + 'row' => '1', + 'seat' => '1', + 'price' => 120000, + ], + [ + 'type' => 'VIP + LUNCH', + 'sector' => 'B', + 'row' => '2', + 'seat' => '3', + 'price' => 250000, + ], + ], + ]); + + $response + ->assertOk() + ->assertJsonCount(2, 'data.variants') + ->assertJsonPath('data.variants.0.price', '120000.00') + ->assertJsonPath('data.variants.1.type', 'VIP + LUNCH'); + + $this->assertDatabaseCount('variantes', 2); + $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, + ]); + } + + public function test_it_replaces_and_toggles_the_entry_image(): void + { + Storage::fake('s3'); + [$tenant, $entry] = $this->configuredEntry(); + $oldImage = Attachment::query()->create([ + 'path' => 'catalog-items/old.png', + 'filename' => 'old.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + 'extension' => 'png', + 'size' => 10, + ]); + Storage::disk('s3')->put($oldImage->path, 'old'); + $entry->allAttachments()->attach($oldImage->id, ['orden' => 0]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->post('/api/v1/adminapp/tenant/desfile/entries/image', [ + 'image' => UploadedFile::fake()->image('plano.png'), + 'is_enabled' => false, + ], ['Accept' => 'application/json']) + ->assertOk() + ->assertJsonPath('data.image.filename', 'plano.png') + ->assertJsonPath('data.image.is_enabled', false); + + $this->assertDatabaseMissing('attachments', ['id' => $oldImage->id]); + $this->assertCount(0, $entry->fresh()->attachments); + $this->assertCount(1, $entry->fresh()->allAttachments); + + $this->patchJson('/api/v1/adminapp/tenant/desfile/entries/image', [ + 'is_enabled' => true, + ]) + ->assertOk() + ->assertJsonPath('data.image.is_enabled', true); + + $this->assertCount(1, $entry->fresh()->attachments); + } + + public function test_it_rejects_duplicate_seats_and_cross_tenant_access(): 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'); + + $payload = [ + 'variants' => [ + $this->variantPayload('NORMAL', 'A', '1', '1', 100), + $this->variantPayload('normal', 'A', '1', '1', 200), + ], + ]; + + $this->putJson('/api/v1/adminapp/tenant/desfile/entries', $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors('variants.1'); + } + + /** @return array{Tenant, CatalogItem} */ + private function configuredEntry(string $tenantCode = 'desfile_pura_tendencia'): array + { + $tenant = Tenant::query()->create([ + 'codigo' => $tenantCode, + 'nombre' => 'Desfile', + 'dominio' => "{$tenantCode}.test", + 'website_type_code' => 'onticket', + ]); + $menu = Menu::query()->firstOrCreate( + ['code' => 'adminapp.desfile.entradas'], + ['label' => 'Entradas', 'route' => '/admin/desfile/entradas'], + ); + $tenant->menues()->attach($menu->code); + $entry = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'entrada', + 'nombre' => 'Entrada', + 'precio' => 0, + 'inventory_policy' => InventoryPolicy::Tracked, + 'inventory_subject' => InventorySubject::Seat, + 'has_tickets' => true, + ]); + + foreach ([ + 'tipo' => ['VIP + LUNCH', 'NORMAL'], + 'sector' => ['A', 'B', 'C', 'D'], + 'fila' => ['1', '2'], + 'asiento' => ['1', '2', '3'], + ] as $code => $options) { + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'type' => FieldType::Select, + 'is_required' => true, + ]); + foreach ($options as $order => $option) { + $attribute->options()->create([ + 'value' => $option, + 'label' => $option, + 'sort_order' => $order + 1, + ]); + } + $entry->itemAttributes()->create([ + 'attribute_id' => $attribute->id, + 'sort_order' => $entry->itemAttributes()->count() + 1, + ]); + } + + return [$tenant, $entry]; + } + + private function createVariant( + CatalogItem $entry, + string $type, + string $sector, + string $row, + string $seat, + int $price, + ): Variant { + $variant = $entry->variants()->create([ + 'inventory_id' => Inventory::query()->create(['real_stock' => 1])->id, + 'precio' => $price, + ]); + $itemAttributes = $entry->itemAttributes()->with('attribute')->get()->keyBy( + fn (ItemAttribute $itemAttribute): string => $itemAttribute->attribute->codigo, + ); + + foreach (compact('type', 'sector', 'row', 'seat') as $input => $value) { + $code = ['type' => 'tipo', 'sector' => 'sector', 'row' => 'fila', 'seat' => 'asiento'][$input]; + $variant->definitions()->create([ + 'item_attribute_id' => $itemAttributes[$code]->id, + 'value' => $value, + ]); + } + + return $variant; + } + + /** @return array */ + private function variantPayload( + string $type, + string $sector, + string $row, + string $seat, + int $price, + ): array { + return compact('type', 'sector', 'row', 'seat', 'price'); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } +}