diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 9c5ec7a..7b2b11b 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -180,6 +180,11 @@ class CatalogItem extends Model return $this->nombre; } + public function getDescription(): ?string + { + return $this->descripcion; + } + public function isBundle(): bool { return $this->type === CatalogItemType::Bundle; diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php index 147c449..388d87b 100644 --- a/app/Domains/Catalog/Models/Variant.php +++ b/app/Domains/Catalog/Models/Variant.php @@ -17,6 +17,8 @@ use Illuminate\Support\Collection; 'catalog_item_id', 'event_date_id', 'inventory_id', + 'descripcion', + 'precio', ])] class Variant extends Model { @@ -32,6 +34,7 @@ class Variant extends Model 'catalog_item_id' => 'integer', 'event_date_id' => 'integer', 'inventory_id' => 'integer', + 'precio' => 'decimal:2', ]; } @@ -97,7 +100,12 @@ class Variant extends Model public function getPrice(): float { - return $this->catalogItem->getPrice(); + return (float) ($this->precio ?? $this->catalogItem->precio); + } + + public function getDescription(): ?string + { + return $this->descripcion ?? $this->catalogItem->descripcion; } public function getName(): string diff --git a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php index e5d9cfc..105b072 100644 --- a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php +++ b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php @@ -88,6 +88,8 @@ class StoreCatalogItemRequest extends FormRequest 'images.*' => ['required', new ImageOrBase64Rule], 'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'], 'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'], + 'variants.*.descripcion' => ['sometimes', 'nullable', 'string'], + 'variants.*.precio' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:99999999.99'], 'variants.*.event_date_id' => [ 'sometimes', 'nullable', diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index cfe33a3..42da6e7 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -41,6 +41,8 @@ class CatalogFeaturedItemResource extends JsonResource 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), + 'descripcion' => $variant->getDescription(), + 'precio' => number_format($variant->getPrice(), 2, '.', ''), 'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory->availableStock(), diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index e45dc81..8736767 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -165,6 +165,8 @@ class CatalogItemDetailResource extends JsonResource 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), 'event_date_ids' => $eventDates->pluck('id')->values(), 'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), + 'descripcion' => $variant->getDescription(), + 'precio' => number_format($variant->getPrice(), 2, '.', ''), 'stock_tecnico' => $this->variantStock($variant), 'values' => $values, ]; diff --git a/app/Domains/Catalog/Resources/CatalogItemResource.php b/app/Domains/Catalog/Resources/CatalogItemResource.php index 7c4d0ca..1de5671 100644 --- a/app/Domains/Catalog/Resources/CatalogItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemResource.php @@ -42,6 +42,8 @@ class CatalogItemResource extends JsonResource 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), + 'descripcion' => $variant->getDescription(), + 'precio' => number_format($variant->getPrice(), 2, '.', ''), 'real_stock' => $variant->inventory?->real_stock, 'values' => $variant->selectionValues(), 'images' => $variant->attachments diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index 9c39afd..1a80d4c 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -37,6 +37,8 @@ class CatalogSearchItemResource extends JsonResource 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), + 'descripcion' => $variant->getDescription(), + 'precio' => number_format($variant->getPrice(), 2, '.', ''), 'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory?->availableStock(), diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index da1e1e8..6d9aaa7 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -554,6 +554,8 @@ class CatalogService $variant = $catalogItem->variants()->create([ 'inventory_id' => $inventory->id, 'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null, + 'descripcion' => $data['descripcion'] ?? null, + 'precio' => $data['precio'] ?? null, ]); $variant->eventDates()->sync($eventDateIds->all()); $variant->setRelation('catalogItem', $catalogItem); diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php new file mode 100644 index 0000000..6ced376 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php @@ -0,0 +1,27 @@ +user()->tenant()->firstOrFail(); + + abort_unless($tenant->codigo === 'fiesta_futbol_infantil', 404); + + return FoodResource::make( + $this->foodService->upsertMany( + $tenant, + $request->validated('variants'), + ) + ); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php new file mode 100644 index 0000000..8e6b918 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpsertFoodVariantsRequest.php @@ -0,0 +1,70 @@ + */ + public function rules(): array + { + $tenantCode = $this->user()?->tenant_codigo; + + return [ + 'variants' => ['required', 'array', 'min:1', 'max:500'], + 'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,price'], + 'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'], + 'variants.*.event_date_id' => [ + 'required', + 'integer', + Rule::exists('event_dates', 'id')->where( + fn ($query) => $query->where('tenant_code', $tenantCode) + ), + ], + 'variants.*.schedule' => ['required', 'string', 'max:255'], + 'variants.*.service' => ['required', 'string', 'max:255'], + 'variants.*.description' => ['sometimes', 'nullable', 'string'], + 'variants.*.stock' => ['required', 'integer', 'min:0'], + 'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], + ]; + } + + /** @return array */ + public function after(): array + { + return [ + function (Validator $validator): void { + $seen = []; + + foreach ($this->input('variants', []) as $index => $variant) { + if (! is_array($variant)) { + continue; + } + + $key = implode('|', [ + $variant['event_date_id'] ?? '', + mb_strtolower(trim((string) ($variant['schedule'] ?? ''))), + mb_strtolower(trim((string) ($variant['service'] ?? ''))), + ]); + + if (isset($seen[$key])) { + $validator->errors()->add( + "variants.{$index}", + 'La combinación de fecha, horario y servicio no puede repetirse.', + ); + } + + $seen[$key] = true; + } + }, + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php b/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php new file mode 100644 index 0000000..fbd61e9 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php @@ -0,0 +1,35 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->nombre, + 'variants' => $this->variants->map(function ($variant): array { + $values = $variant->selectionValues(); + $eventDate = $variant->selectedEventDates()->first(); + + return [ + 'id' => $variant->id, + 'event_date_id' => $eventDate?->id, + 'event_date' => $eventDate?->date?->format('Y-m-d'), + 'schedule' => $values->get('horario'), + 'service' => $values->get('servicio'), + 'description' => $variant->descripcion, + 'stock' => $variant->inventory->real_stock, + 'price' => number_format($variant->getPrice(), 2, '.', ''), + ]; + })->values(), + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php new file mode 100644 index 0000000..1515fee --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php @@ -0,0 +1,285 @@ +> $variants + */ + public function upsertMany(Tenant $tenant, array $variants): CatalogItem + { + return DB::transaction(function () use ($tenant, $variants): CatalogItem { + $attributes = $this->attributes($tenant); + $food = $this->food($tenant, $variants); + $itemAttributes = $this->itemAttributes($food, $attributes); + + $food->variants()->whereNull('precio')->update(['precio' => $food->precio]); + $existingVariants = $food->variants() + ->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute']) + ->lockForUpdate() + ->get(); + $resolvedVariants = $this->resolveVariants($variants, $attributes); + + $this->validateCombinations($resolvedVariants, $existingVariants); + + foreach ($resolvedVariants as $index => $data) { + $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 Comida.'], + ]); + } + + if ($variant === null) { + $this->createVariant($food, $itemAttributes, $data); + } else { + $this->updateVariant($variant, $itemAttributes, $data, $index); + } + } + + $minimumPrice = $food->variants()->min('precio'); + if ($minimumPrice !== null) { + $food->update(['precio' => $minimumPrice]); + } + + return $food->fresh()->load([ + 'variants.catalogItem', + 'variants.inventory', + 'variants.eventDate', + 'variants.eventDates', + 'variants.definitions.itemAttribute.attribute', + ]); + }); + } + + /** @return Collection */ + private function attributes(Tenant $tenant): Collection + { + $attributes = Attribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->whereIn('codigo', self::ATTRIBUTE_CODES) + ->with('options') + ->get() + ->keyBy('codigo'); + + $missingCodes = collect(self::ATTRIBUTE_CODES)->diff($attributes->keys()); + if ($missingCodes->isNotEmpty()) { + throw ValidationException::withMessages([ + 'variants' => [ + 'Faltan atributos requeridos para Comida: '.$missingCodes->implode(', ').'.', + ], + ]); + } + + return $attributes; + } + + /** @param array> $variants */ + private function food(Tenant $tenant, array $variants): CatalogItem + { + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->lockForUpdate() + ->first(); + + if ($food !== null) { + $food->update([ + 'event_product_type' => EventProductType::Product->value, + 'inventory_policy' => InventoryPolicy::Tracked->value, + 'has_tickets' => false, + ]); + + return $food; + } + + return CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'comida', + 'nombre' => 'Comida', + 'descripcion' => 'Comida', + 'precio' => collect($variants)->min('price') ?? 0, + 'event_product_type' => EventProductType::Product->value, + 'inventory_policy' => InventoryPolicy::Tracked->value, + 'has_tickets' => false, + 'inventory_id' => null, + ]); + } + + /** + * @param Collection $attributes + * @return Collection + */ + private function itemAttributes(CatalogItem $food, Collection $attributes): Collection + { + return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($food): array { + $itemAttribute = $food->itemAttributes()->firstOrCreate( + ['attribute_id' => $attribute->id], + ['allow_multi_select' => false], + ); + + return [$code => $itemAttribute]; + }); + } + + /** + * @param array> $variants + * @param Collection $attributes + * @return array> + */ + private function resolveVariants(array $variants, Collection $attributes): array + { + return collect($variants)->map(function (array $variant, int $index) use ($attributes): array { + $schedule = $this->option($attributes['horario'], $variant['schedule'], "variants.{$index}.schedule"); + $service = $this->option($attributes['servicio'], $variant['service'], "variants.{$index}.service"); + + return [ + ...$variant, + 'event_date_id' => (int) $variant['event_date_id'], + 'schedule' => $schedule->value, + 'service' => $service->value, + 'description' => (string) ($variant['description'] ?? ''), + 'stock' => (int) $variant['stock'], + ]; + })->all(); + } + + private function option(Attribute $attribute, string $value, string $validationKey): AttributeOption + { + $option = $attribute->options->first( + fn (AttributeOption $option): bool => mb_strtolower(trim($option->value)) === mb_strtolower(trim($value)) + ); + + if ($option === null) { + throw ValidationException::withMessages([ + $validationKey => ["El valor seleccionado no es válido para {$attribute->nombre}."], + ]); + } + + return $option; + } + + /** + * @param array> $incoming + * @param Collection $existing + */ + private function validateCombinations(array $incoming, Collection $existing): void + { + $incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id); + $seen = []; + + foreach ($existing->whereNotIn('id', $incomingIds) as $variant) { + $values = $variant->selectionValues(); + $seen[$this->combinationKey( + (int) $variant->selectedEventDates()->first()?->id, + (string) $values->get('horario'), + (string) $values->get('servicio'), + )] = true; + } + + foreach ($incoming as $index => $variant) { + $key = $this->combinationKey( + $variant['event_date_id'], + $variant['schedule'], + $variant['service'], + ); + + if (isset($seen[$key])) { + throw ValidationException::withMessages([ + "variants.{$index}" => ['La combinación de fecha, horario y servicio ya existe.'], + ]); + } + + $seen[$key] = true; + } + } + + /** @param Collection $itemAttributes */ + private function createVariant(CatalogItem $food, Collection $itemAttributes, array $data): void + { + $inventory = Inventory::query()->create(['real_stock' => $data['stock']]); + $variant = $food->variants()->create([ + 'event_date_id' => $data['event_date_id'], + 'inventory_id' => $inventory->id, + 'descripcion' => $data['description'], + 'precio' => $data['price'], + ]); + $variant->eventDates()->sync([$data['event_date_id']]); + $this->syncDefinitions($variant, $itemAttributes, $data); + } + + /** @param Collection $itemAttributes */ + private function updateVariant( + Variant $variant, + Collection $itemAttributes, + array $data, + int $index, + ): void { + $inventory = Inventory::query() + ->whereKey($variant->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + + if ($data['stock'] < $inventory->reserved_stock) { + throw ValidationException::withMessages([ + "variants.{$index}.stock" => [ + 'El stock no puede ser menor que la cantidad actualmente reservada.', + ], + ]); + } + + $variant->update([ + 'event_date_id' => $data['event_date_id'], + 'descripcion' => $data['description'], + 'precio' => $data['price'], + ]); + $variant->eventDates()->sync([$data['event_date_id']]); + $inventory->update(['real_stock' => $data['stock']]); + $this->syncDefinitions($variant, $itemAttributes, $data); + } + + /** @param Collection $itemAttributes */ + private function syncDefinitions(Variant $variant, Collection $itemAttributes, array $data): void + { + $definitionAttributes = $itemAttributes->only(['horario', 'servicio']); + $variant->definitions()->whereIn('item_attribute_id', $definitionAttributes->pluck('id'))->delete(); + $variant->definitions()->createMany([ + [ + 'item_attribute_id' => $definitionAttributes['horario']->id, + 'value' => $data['schedule'], + ], + [ + 'item_attribute_id' => $definitionAttributes['servicio']->id, + 'value' => $data['service'], + ], + ]); + } + + private function combinationKey(int $eventDateId, string $schedule, string $service): string + { + return implode('|', [ + $eventDateId, + mb_strtolower(trim($schedule)), + mb_strtolower(trim($service)), + ]); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/routes/api.php b/app/Domains/FiestaFutbolInfantil/routes/api.php index 6b53338..5c5cb01 100644 --- a/app/Domains/FiestaFutbolInfantil/routes/api.php +++ b/app/Domains/FiestaFutbolInfantil/routes/api.php @@ -1,6 +1,7 @@ group(function (): void { Route::post('entries', [EntryController::class, 'store']) ->name('adminapp.fiesta-futbol-infantil.entries.store'); + Route::post('foods', [FoodController::class, 'store']) + ->name('adminapp.fiesta-futbol-infantil.foods.store'); }); diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index 06938ab..0b09562 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -64,7 +64,7 @@ class PurchaseItemResource extends JsonResource ], 'item_details' => $selectedItem === null ? null : [ 'nombre' => $selectedItem->getName(), - 'descripcion' => $catalogItem?->descripcion, + 'descripcion' => $selectedItem->getDescription(), 'imagen' => $imageUrl, 'attributes' => $variant === null ? [] : $this->resolveAttributes($variant), ], diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php index fbfbcff..e1711a2 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php @@ -27,7 +27,7 @@ class PurchaseItemSnapshotFactory 'source_variant_id' => $item->variant_id, 'image_attachment_id' => $this->firstImageAttachment($item)?->id, 'nombre' => $item->catalogItem->nombre, - 'descripcion' => $item->catalogItem->descripcion, + 'descripcion' => $selectedItem?->getDescription(), 'slug' => $item->catalogItem->slug, 'item_nombre' => $selectedItem->getName(), 'variant_attributes' => $item->variant === null diff --git a/database/migrations/2026_08_07_000500_add_commercial_overrides_to_variants.php b/database/migrations/2026_08_07_000500_add_commercial_overrides_to_variants.php new file mode 100644 index 0000000..86035a3 --- /dev/null +++ b/database/migrations/2026_08_07_000500_add_commercial_overrides_to_variants.php @@ -0,0 +1,34 @@ +text('descripcion')->nullable()->after('inventory_id'); + $table->decimal('precio', 10, 2)->nullable()->after('descripcion'); + }); + + DB::table('variantes')->orderBy('id')->each(function (object $variant): void { + $price = DB::table('catalog_items') + ->where('id', $variant->catalog_item_id) + ->value('precio'); + + DB::table('variantes')->where('id', $variant->id)->update([ + 'precio' => $price, + ]); + }); + } + + public function down(): void + { + Schema::table('variantes', function (Blueprint $table): void { + $table->dropColumn(['descripcion', 'precio']); + }); + } +}; diff --git a/database/seeders/FiestaFutbolInfantilProductSeeder.php b/database/seeders/FiestaFutbolInfantilProductSeeder.php index 4cd1bdc..dfdc6b8 100644 --- a/database/seeders/FiestaFutbolInfantilProductSeeder.php +++ b/database/seeders/FiestaFutbolInfantilProductSeeder.php @@ -73,13 +73,20 @@ class FiestaFutbolInfantilProductSeeder extends Seeder $this->createProduct($tenant, [ 'slug' => 'comida', 'nombre' => 'Comida', - 'precio' => 8000, + 'precio' => 4000, 'attribute_codes' => ['event_date', 'horario', 'servicio'], - 'variants' => $dateIds + 'variants' => $eventDates ->crossJoin(['Desayuno', 'Almuerzo', 'Cena'], ['Comedor', 'Vianda']) ->map(fn (array $values): array => [ 'real_stock' => 0, - 'event_date_ids' => [(int) $values[0]], + 'event_date_ids' => [(int) $values[0]->id], + 'descripcion' => sprintf( + '%s del %s - %s', + $values[1], + $values[0]->date->format('d/m/Y'), + $values[2], + ), + 'precio' => $this->foodPrice($values[1]), 'values' => ['horario' => $values[1], 'servicio' => $values[2]], ])->all(), ]); @@ -128,4 +135,14 @@ class FiestaFutbolInfantilProductSeeder extends Seeder ...$data, ]); } + + private function foodPrice(string $schedule): int + { + return match ($schedule) { + 'Desayuno' => 4000, + 'Almuerzo' => 10000, + 'Cena' => 8000, + default => throw new RuntimeException("Horario de comida desconocido: {$schedule}"), + }; + } } diff --git a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php new file mode 100644 index 0000000..fed13eb --- /dev/null +++ b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php @@ -0,0 +1,208 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + ]); + } + + public function test_authentication_is_required(): void + { + $this->postJson('/api/v1/adminapp/tenant/foods', ['variants' => []]) + ->assertUnauthorized(); + } + + public function test_it_creates_one_food_item_with_multiple_variants(): void + { + [$tenant, $firstDate, $secondDate] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 1700, 10000), + $this->variantPayload($secondDate->id, 'Cena', 'Vianda', 900, 8000), + ], + ]) + ->assertOk() + ->assertJsonPath('data.name', 'Comida') + ->assertJsonCount(2, 'data.variants') + ->assertJsonPath('data.variants.0.schedule', 'Almuerzo') + ->assertJsonPath('data.variants.0.service', 'Comedor') + ->assertJsonPath('data.variants.0.stock', 1700) + ->assertJsonPath('data.variants.0.price', '10000.00') + ->assertJsonPath('data.variants.1.price', '8000.00'); + + $this->assertDatabaseCount('catalog_items', 1); + $this->assertDatabaseCount('variantes', 2); + $this->assertDatabaseCount('inventories', 2); + $this->assertDatabaseCount('variant_event_dates', 2); + $this->assertDatabaseCount('variant_values', 4); + $this->assertDatabaseHas('catalog_items', [ + 'tenant_code' => $tenant->codigo, + 'slug' => 'comida', + 'nombre' => 'Comida', + 'precio' => 8000, + ]); + } + + public function test_it_updates_variants_with_an_id_and_creates_variants_without_one(): void + { + [$tenant, $firstDate, $secondDate] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $created = $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 100, 10000), + ], + ])->assertOk(); + $variantId = $created->json('data.variants.0.id'); + + $updated = $this->variantPayload($secondDate->id, 'Cena', 'Vianda', 80, 8500); + $updated['id'] = $variantId; + $updated['description'] = 'Cena para llevar'; + + $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $updated, + $this->variantPayload($firstDate->id, 'Almuerzo', 'Vianda', 50, 9000), + ], + ]) + ->assertOk() + ->assertJsonCount(2, 'data.variants') + ->assertJsonPath('data.variants.0.id', $variantId) + ->assertJsonPath('data.variants.0.event_date_id', $secondDate->id) + ->assertJsonPath('data.variants.0.schedule', 'Cena') + ->assertJsonPath('data.variants.0.service', 'Vianda') + ->assertJsonPath('data.variants.0.description', 'Cena para llevar') + ->assertJsonPath('data.variants.0.stock', 80) + ->assertJsonPath('data.variants.0.price', '8500.00'); + + $this->assertDatabaseCount('catalog_items', 1); + $this->assertDatabaseCount('variantes', 2); + $this->assertDatabaseHas('variantes', [ + 'id' => $variantId, + 'event_date_id' => $secondDate->id, + 'descripcion' => 'Cena para llevar', + 'precio' => 8500, + ]); + } + + public function test_it_rejects_duplicate_combinations(): void + { + [$tenant, $firstDate] = $this->configuredTenant(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 100, 10000), + $this->variantPayload($firstDate->id, ' almuerzo ', ' comedor ', 100, 10000), + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['variants.1']); + + $this->assertDatabaseCount('catalog_items', 0); + } + + /** @return array{Tenant, mixed, mixed} */ + private function configuredTenant(): array + { + $tenant = Tenant::query()->create([ + 'codigo' => 'fiesta_futbol_infantil', + 'nombre' => 'Fiesta Fútbol Infantil', + 'dominio' => 'fiesta.test', + 'website_type_code' => 'onticket', + ]); + $eventDateAttribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'event_date', + 'nombre' => 'Fecha', + 'type' => FieldType::EventDate, + 'is_required' => true, + ]); + $scheduleAttribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'horario', + 'nombre' => 'Horario', + 'type' => FieldType::Select, + 'is_required' => true, + ]); + $scheduleAttribute->options()->createMany([ + ['value' => 'Almuerzo', 'label' => 'Almuerzo', 'sort_order' => 1], + ['value' => 'Cena', 'label' => 'Cena', 'sort_order' => 2], + ]); + $serviceAttribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'servicio', + 'nombre' => 'Servicio', + 'type' => FieldType::Select, + 'is_required' => true, + ]); + $serviceAttribute->options()->createMany([ + ['value' => 'Comedor', 'label' => 'Comedor', 'sort_order' => 1], + ['value' => 'Vianda', 'label' => 'Vianda', 'sort_order' => 2], + ]); + $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', + ]); + + $this->assertNotNull($eventDateAttribute); + + return [$tenant, $firstDate, $secondDate]; + } + + /** @return array */ + private function variantPayload( + int $eventDateId, + string $schedule, + string $service, + int $stock, + float $price, + ): array { + return [ + 'event_date_id' => $eventDateId, + 'schedule' => $schedule, + 'service' => $service, + 'description' => null, + 'stock' => $stock, + 'price' => $price, + ]; + } + + 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 447a45d..678add2 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -74,6 +74,21 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase $abono->variants->map(fn ($variant): int => $variant->eventDates->count())->all(), ); + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->with('variants') + ->sole(); + $this->assertSame('4000.00', $food->precio); + $this->assertCount(24, $food->variants->pluck('descripcion')->unique()); + $this->assertEqualsCanonicalizing( + ['4000.00', '8000.00', '10000.00'], + $food->variants->pluck('precio')->unique()->values()->all(), + ); + $this->assertTrue($food->variants->every( + fn ($variant): bool => $variant->descripcion !== null && $variant->precio !== null + )); + $featuredGroup = FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->sole(); $this->assertSame(FeaturedGroupSource::All, $featuredGroup->source_type); $this->assertNull($featuredGroup->category_id);