diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index affbb55..db3fdb5 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -104,6 +104,8 @@ class CartService 'items.variant.attachments', 'items.variant.inventory', 'items.variant.definitions.itemAttribute.attribute', + 'items.variant.eventDates', + 'items.variant.eventDate', ]); } diff --git a/app/Domains/Catalog/Models/ItemAttribute.php b/app/Domains/Catalog/Models/ItemAttribute.php index 89e4b31..46d9b09 100644 --- a/app/Domains/Catalog/Models/ItemAttribute.php +++ b/app/Domains/Catalog/Models/ItemAttribute.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'catalog_item_id', 'attribute_id', + 'allow_multi_select', ])] class ItemAttribute extends Model { @@ -18,6 +19,15 @@ class ItemAttribute extends Model protected $table = 'item_attributes'; + protected function casts(): array + { + return [ + 'catalog_item_id' => 'integer', + 'attribute_id' => 'integer', + 'allow_multi_select' => 'boolean', + ]; + } + /** @return BelongsTo */ public function catalogItem(): BelongsTo { diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php index bddd47c..71016ca 100644 --- a/app/Domains/Catalog/Models/Variant.php +++ b/app/Domains/Catalog/Models/Variant.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Collection; #[Fillable([ 'catalog_item_id', @@ -46,6 +47,17 @@ class Variant extends Model return $this->belongsTo(EventDate::class); } + /** @return BelongsToMany */ + public function eventDates(): BelongsToMany + { + return $this->belongsToMany( + EventDate::class, + 'variant_event_dates', + 'variant_id', + 'event_date_id', + )->orderBy('date')->orderBy('time_start'); + } + /** @return HasMany */ public function sourceTickets(): HasMany { @@ -92,4 +104,39 @@ class Variant extends Model { return $this->catalogItem->nombre; } + + /** @return Collection> */ + public function selectionValues(): Collection + { + $values = $this->definitions + ->mapWithKeys(fn ($definition) => [ + $definition->itemAttribute?->attribute?->codigo => $definition->value, + ]) + ->filter(fn ($value, $key): bool => $key !== null); + + $eventDateIds = $this->selectedEventDates() + ->pluck('id') + ->map(fn ($id): string => (string) $id) + ->values(); + + if ($eventDateIds->count() === 1) { + $values->put('event_date', $eventDateIds->first()); + } elseif ($eventDateIds->isNotEmpty()) { + $values->put('event_date', $eventDateIds->all()); + } + + return $values; + } + + /** @return Collection */ + public function selectedEventDates(): Collection + { + $eventDates = $this->eventDates; + + if ($eventDates->isEmpty() && $this->event_date_id !== null && $this->eventDate !== null) { + return collect([$this->eventDate]); + } + + return $eventDates; + } } diff --git a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php index 304c746..222292c 100644 --- a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php +++ b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php @@ -75,6 +75,15 @@ class StoreCatalogItemRequest extends FormRequest fn ($query) => $query->where('tenant_codigo', $tenantCode) ), ], + 'multi_select_attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'], + 'multi_select_attribute_codes.*' => [ + 'required', + 'string', + 'distinct', + Rule::exists('attribute', 'codigo')->where( + fn ($query) => $query->where('tenant_codigo', $tenantCode) + ), + ], 'images' => ['sometimes', 'array'], 'images.*' => ['required', new ImageOrBase64Rule], 'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'], @@ -87,6 +96,15 @@ class StoreCatalogItemRequest extends FormRequest fn ($query) => $query->where('tenant_code', $tenantCode) ), ], + 'variants.*.event_date_ids' => ['sometimes', 'array', 'min:1'], + 'variants.*.event_date_ids.*' => [ + 'required', + 'integer', + 'distinct', + Rule::exists('event_dates', 'id')->where( + fn ($query) => $query->where('tenant_code', $tenantCode) + ), + ], 'variants.*.inventory_id' => ['prohibited'], 'variants.*.reserved_stock' => ['prohibited'], 'variants.*.sold_units' => ['prohibited'], diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index a080998..cfe33a3 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -39,14 +39,12 @@ class CatalogFeaturedItemResource extends JsonResource 'id' => $variant->id, 'event_date_id' => $variant->event_date_id, '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(), 'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory->availableStock(), - 'values' => $variant->definitions - ->mapWithKeys(fn ($definition) => [ - $definition->itemAttribute?->attribute?->codigo => $definition->value, - ]) - ->filter(fn ($value, $key): bool => $key !== null), + 'values' => $variant->selectionValues(), ]) ->values(), ]; diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index 357fce4..e45dc81 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -88,6 +88,7 @@ class CatalogItemDetailResource extends JsonResource 'codigo' => $attribute->codigo, 'nombre' => $attribute->nombre, 'is_required' => $attribute->is_required, + 'allow_multi_select' => $itemAttribute->allow_multi_select, 'metadata_schema' => $attribute->metadata_schema, 'type' => $attribute->type->value, 'options' => $attribute->type === FieldType::EventDate @@ -155,20 +156,15 @@ class CatalogItemDetailResource extends JsonResource /** @return array */ private function variantData(Variant $variant): array { - $values = $variant->definitions - ->mapWithKeys(fn ($definition) => [ - $definition->itemAttribute?->attribute?->codigo => $definition->value, - ]) - ->filter(fn ($value, $key): bool => $key !== null); - - if ($variant->event_date_id !== null) { - $values->put('event_date', (string) $variant->event_date_id); - } + $values = $variant->selectionValues(); + $eventDates = $variant->selectedEventDates(); return [ 'id' => $variant->id, 'event_date_id' => $variant->event_date_id, '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(), 'stock_tecnico' => $this->variantStock($variant), 'values' => $values, ]; diff --git a/app/Domains/Catalog/Resources/CatalogItemResource.php b/app/Domains/Catalog/Resources/CatalogItemResource.php index f26ce08..7c4d0ca 100644 --- a/app/Domains/Catalog/Resources/CatalogItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemResource.php @@ -40,12 +40,10 @@ class CatalogItemResource extends JsonResource 'id' => $variant->id, 'event_date_id' => $variant->event_date_id, '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(), 'real_stock' => $variant->inventory?->real_stock, - 'values' => $variant->definitions - ->mapWithKeys(fn ($definition) => [ - $definition->itemAttribute?->attribute?->codigo => $definition->value, - ]) - ->filter(fn ($value, $key) => $key !== null), + 'values' => $variant->selectionValues(), 'images' => $variant->attachments ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) ->values(), diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index a11ebbf..9c39afd 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -35,14 +35,12 @@ class CatalogSearchItemResource extends JsonResource 'id' => $variant->id, 'event_date_id' => $variant->event_date_id, '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(), 'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory?->availableStock(), - 'values' => $variant->definitions - ->mapWithKeys(fn ($definition) => [ - $definition->itemAttribute?->attribute?->codigo => $definition->value, - ]) - ->filter(fn ($value, $key): bool => $key !== null), + 'values' => $variant->selectionValues(), ]) ->values(), ]; diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index 944c48f..8fcc7ea 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -37,12 +37,14 @@ class CatalogService $variants = $data['variants'] ?? []; $images = $data['images'] ?? []; $attributeCodes = $data['attribute_codes'] ?? []; + $multiSelectAttributeCodes = $data['multi_select_attribute_codes'] ?? []; $components = $data['components'] ?? []; $hasDirectStock = array_key_exists('real_stock', $data); $realStock = (int) ($data['real_stock'] ?? 0); $hasEventDateVariants = $variants !== [] && collect($variants)->every( fn (array $variant): bool => ! empty($variant['event_date_id']) + || ! empty($variant['event_date_ids']) ); if ($hasEventDateVariants && ! in_array('event_date', $attributeCodes, true)) { @@ -55,6 +57,14 @@ class CatalogService $hasVariants = $attributeCodes !== [] || $hasEventDateVariants; + if (array_diff($multiSelectAttributeCodes, $attributeCodes) !== []) { + throw ValidationException::withMessages([ + 'multi_select_attribute_codes' => [ + 'Multi-select attributes must also be present in attribute_codes.', + ], + ]); + } + $this->validateEventProductType($data); $this->validateUniqueVariantCombinations($variants, $attributeCodes); @@ -79,6 +89,7 @@ class CatalogService $data['variants'], $data['images'], $data['attribute_codes'], + $data['multi_select_attribute_codes'], $data['components'], $data['real_stock'], $data['reserved_stock'], @@ -100,7 +111,7 @@ class CatalogService $catalogItem = CatalogItem::query()->create($data); $itemAttributes = $type === CatalogItemType::Standard - ? $this->createItemAttributes($catalogItem, $attributeCodes) + ? $this->createItemAttributes($catalogItem, $attributeCodes, $multiSelectAttributeCodes) : []; if ($type === CatalogItemType::Bundle) { @@ -141,6 +152,7 @@ class CatalogService 'variants.inventory', 'variants.attachments', 'variants.eventDate', + 'variants.eventDates', 'variants.definitions.itemAttribute.attribute', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', @@ -162,6 +174,7 @@ class CatalogService 'variants.inventory', 'variants.attachments', 'variants.eventDate', + 'variants.eventDates', 'variants.definitions' => fn ($query) => $query->orderBy('id'), 'variants.definitions.itemAttribute.attribute', 'bundleComponents.catalogItem.inventory', @@ -217,6 +230,7 @@ class CatalogService 'variants.inventory', 'variants.attachments', 'variants.eventDate', + 'variants.eventDates', 'variants.definitions.itemAttribute.attribute', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', @@ -250,6 +264,7 @@ class CatalogService 'variants.inventory', 'variants.attachments', 'variants.eventDate', + 'variants.eventDates', 'variants.definitions.itemAttribute.attribute', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', @@ -435,11 +450,13 @@ class CatalogService /** * @param array $attributeCodes + * @param array $multiSelectAttributeCodes * @return array */ private function createItemAttributes( CatalogItem $catalogItem, array $attributeCodes, + array $multiSelectAttributeCodes = [], ): array { $itemAttributes = []; $attributeCodes = array_values(array_unique($attributeCodes)); @@ -462,6 +479,7 @@ class CatalogService $itemAttribute = $catalogItem->itemAttributes()->create([ 'attribute_id' => $attribute->id, + 'allow_multi_select' => in_array($attributeCode, $multiSelectAttributeCodes, true), ]); $itemAttributes[$attributeCode] = $itemAttribute; @@ -495,28 +513,49 @@ class CatalogService } $inventory = $this->createInventory((int) ($data['real_stock'] ?? 0)); - $eventDateId = $data['event_date_id'] ?? null; - - if ( - $eventDateId !== null - && ( - ! EventDate::query() - ->whereKey($eventDateId) - ->where('tenant_code', $catalogItem->tenant_code) - ->exists() + $eventDateIds = collect($data['event_date_ids'] ?? []) + ->when( + isset($data['event_date_id']), + fn ($ids) => $ids->push($data['event_date_id']), ) - ) { + ->filter(fn ($id): bool => $id !== null) + ->map(fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values(); + + $eventDateItemAttribute = $itemAttributes['event_date'] ?? null; + if ($eventDateItemAttribute !== null && ( + $eventDateIds->isEmpty() + || (! $eventDateItemAttribute->allow_multi_select && $eventDateIds->count() !== 1) + )) { throw ValidationException::withMessages([ - "variants.{$index}.event_date_id" => [ - 'The event date must belong to the catalog item tenant.', + "variants.{$index}.event_date_ids" => [ + $eventDateItemAttribute->allow_multi_select + ? 'At least one event date must be selected.' + : 'Exactly one event date must be selected.', + ], + ]); + } + + $validEventDateCount = EventDate::query() + ->whereKey($eventDateIds) + ->where('tenant_code', $catalogItem->tenant_code) + ->count(); + + if ($validEventDateCount !== $eventDateIds->count()) { + throw ValidationException::withMessages([ + "variants.{$index}.event_date_ids" => [ + 'Every event date must belong to the catalog item tenant.', ], ]); } $variant = $catalogItem->variants()->create([ 'inventory_id' => $inventory->id, - 'event_date_id' => $eventDateId, + 'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null, ]); + $variant->eventDates()->sync($eventDateIds->all()); $variant->setRelation('catalogItem', $catalogItem); foreach ($data['values'] ?? [] as $attributeCode => $value) { @@ -566,7 +605,17 @@ class CatalogService sort($attributeCodes); foreach (array_values($variants) as $index => $variant) { - $combination = [(string) ($variant['event_date_id'] ?? '')]; + $eventDateIds = collect($variant['event_date_ids'] ?? []) + ->when( + isset($variant['event_date_id']), + fn ($ids) => $ids->push($variant['event_date_id']), + ) + ->map(fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values() + ->implode(','); + $combination = [$eventDateIds]; foreach ($attributeCodes as $attributeCode) { $value = trim((string) ($variant['values'][$attributeCode] ?? '')); diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index 2e61579..6d11d84 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -44,6 +44,7 @@ class FeaturedGroupService 'variants.inventory', 'variants.attachments', 'variants.eventDate', + 'variants.eventDates', 'variants.definitions.itemAttribute.attribute', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', diff --git a/app/Domains/Event/Models/EventDate.php b/app/Domains/Event/Models/EventDate.php index 9fb05ae..c89bb72 100644 --- a/app/Domains/Event/Models/EventDate.php +++ b/app/Domains/Event/Models/EventDate.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Carbon; @@ -43,6 +44,17 @@ class EventDate extends Model return $this->hasMany(Variant::class); } + /** @return BelongsToMany */ + public function selectedByVariants(): BelongsToMany + { + return $this->belongsToMany( + Variant::class, + 'variant_event_dates', + 'event_date_id', + 'variant_id', + ); + } + public function startsAt(): CarbonInterface { return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start); diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index 5712cbf..aca7d9d 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -98,14 +98,28 @@ class PurchaseItemResource extends JsonResource return []; } - return $variant->definitions + $attributes = $variant->definitions ->map(fn ($definition): array => [ 'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''), 'value' => $definition->value, ]) ->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null) - ->values() - ->all(); + ->values(); + + $eventDates = $variant->relationLoaded('eventDates') + ? $variant->selectedEventDates() + : collect(); + if ($eventDates->isNotEmpty()) { + $attributes->prepend([ + 'name' => 'Fecha', + 'value' => $eventDates + ->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d')) + ->values() + ->all(), + ]); + } + + return $attributes->all(); } private function formatMoney(float|int|string|null $amount): string diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php index 28662aa..f0974cf 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php @@ -53,13 +53,25 @@ class PurchaseItemSnapshotFactory /** @return array */ private function snapshotAttributes(Variant $variant): array { - return $variant->definitions + $attributes = $variant->definitions ->map(fn ($definition): array => [ 'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''), 'value' => $definition->value, ]) ->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null) - ->values() - ->all(); + ->values(); + + $eventDates = $variant->selectedEventDates(); + if ($eventDates->isNotEmpty()) { + $attributes->prepend([ + 'name' => 'Fecha', + 'value' => $eventDates + ->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d')) + ->values() + ->all(), + ]); + } + + return $attributes->all(); } } diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index fc772f8..b5ade20 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -278,6 +278,8 @@ class StartCheckoutService 'variant.attachments', 'variant.catalogItem', 'variant.definitions.itemAttribute.attribute', + 'variant.eventDates', + 'variant.eventDate', ]); } diff --git a/app/Domains/Ticket/Services/TicketGeneratorService.php b/app/Domains/Ticket/Services/TicketGeneratorService.php index 5a9a9b9..bdd82e6 100644 --- a/app/Domains/Ticket/Services/TicketGeneratorService.php +++ b/app/Domains/Ticket/Services/TicketGeneratorService.php @@ -5,6 +5,7 @@ namespace App\Domains\Ticket\Services; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Models\EventDate; use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Models\Ticket; @@ -44,9 +45,11 @@ class TicketGeneratorService ): Ticket { $item = $target['catalog_item']; $variant = $target['variant']; + $eventDate = $target['event_date']; $validityTime = $this->resolveValidityTime($item, $variant); $validityTime = $this->materializeEventDateValidityTime( $variant, + $eventDate, $validityTime, $fixedValidityTimes, ); @@ -68,7 +71,7 @@ class TicketGeneratorService } /** - * @return Collection + * @return Collection */ private function resolveTargets( CatalogItem $catalogItem, @@ -79,10 +82,7 @@ class TicketGeneratorService $variant = $this->resolveVariant($catalogItem, $sourceVariantId); $this->validateTarget($catalogItem, $variant); - return Collection::times($quantity, fn (): array => [ - 'catalog_item' => $catalogItem, - 'variant' => $variant, - ]); + return $this->targetsForVariant($catalogItem, $variant, $quantity); } $catalogItem->loadMissing([ @@ -100,17 +100,46 @@ class TicketGeneratorService $variant = $component->variant; $this->validateTarget($componentItem, $variant); - return Collection::times( + return $this->targetsForVariant( + $componentItem, + $variant, $quantity * $component->quantity, - fn (): array => [ - 'catalog_item' => $componentItem, - 'variant' => $variant, - ], ); }) ->values(); } + /** + * @return Collection + */ + private function targetsForVariant( + CatalogItem $catalogItem, + ?Variant $variant, + int $quantity, + ): Collection { + if ($variant === null) { + return Collection::times($quantity, fn (): array => [ + 'catalog_item' => $catalogItem, + 'variant' => null, + 'event_date' => null, + ]); + } + + $variant->loadMissing(['eventDates', 'eventDate']); + $selectedEventDates = $variant->selectedEventDates(); + $eventDates = $selectedEventDates->isEmpty() + ? collect([null]) + : $selectedEventDates; + + return Collection::times($quantity) + ->flatMap(fn () => $eventDates->map(fn (?EventDate $eventDate): array => [ + 'catalog_item' => $catalogItem, + 'variant' => $variant, + 'event_date' => $eventDate, + ])) + ->values(); + } + private function resolveVariant( CatalogItem $catalogItem, ?int $sourceVariantId, @@ -185,32 +214,29 @@ class TicketGeneratorService */ private function materializeEventDateValidityTime( ?Variant $variant, + ?EventDate $eventDate, ?ValidityTime $validityTime, array &$fixedValidityTimes, ): ?ValidityTime { if ( $variant === null - || $validityTime === null - || $validityTime->type !== ValidityTimeType::TimeWindow + || $eventDate === null ) { return $validityTime; } - $variant->loadMissing('eventDate'); - $eventDate = $variant->eventDate; - - if ($eventDate === null) { + if ($validityTime !== null && $validityTime->type !== ValidityTimeType::TimeWindow) { return $validityTime; } - $cacheKey = $eventDate->getKey().':'.$validityTime->getKey(); + $cacheKey = $eventDate->getKey().':'.($validityTime?->getKey() ?? 'event'); if (isset($fixedValidityTimes[$cacheKey])) { return $fixedValidityTimes[$cacheKey]; } - $startsAt = $validityTime->startsAt($eventDate->date); - $expiresAt = $validityTime->expiresAt($eventDate->date); + $startsAt = $validityTime?->startsAt($eventDate->date) ?? $eventDate->startsAt(); + $expiresAt = $validityTime?->expiresAt($eventDate->date) ?? $eventDate->endsAt(); if ( $startsAt !== null diff --git a/database/migrations/2026_08_07_000300_add_multi_value_event_dates_to_variants.php b/database/migrations/2026_08_07_000300_add_multi_value_event_dates_to_variants.php new file mode 100644 index 0000000..e60d409 --- /dev/null +++ b/database/migrations/2026_08_07_000300_add_multi_value_event_dates_to_variants.php @@ -0,0 +1,42 @@ +boolean('allow_multi_select')->default(false)->after('attribute_id'); + }); + + Schema::create('variant_event_dates', function (Blueprint $table): void { + $table->foreignId('variant_id')->constrained('variantes')->cascadeOnDelete(); + $table->foreignId('event_date_id')->constrained('event_dates')->cascadeOnDelete(); + $table->primary(['variant_id', 'event_date_id']); + $table->index(['event_date_id', 'variant_id']); + }); + + DB::table('variantes') + ->whereNotNull('event_date_id') + ->orderBy('id') + ->each(function (object $variant): void { + DB::table('variant_event_dates')->insertOrIgnore([ + 'variant_id' => $variant->id, + 'event_date_id' => $variant->event_date_id, + ]); + }); + } + + public function down(): void + { + Schema::dropIfExists('variant_event_dates'); + + Schema::table('item_attributes', function (Blueprint $table): void { + $table->dropColumn('allow_multi_select'); + }); + } +}; diff --git a/database/migrations/2026_08_07_000400_allow_multiple_variant_values_per_attribute.php b/database/migrations/2026_08_07_000400_allow_multiple_variant_values_per_attribute.php new file mode 100644 index 0000000..0ff277d --- /dev/null +++ b/database/migrations/2026_08_07_000400_allow_multiple_variant_values_per_attribute.php @@ -0,0 +1,22 @@ +dropUnique('variant_values_variant_id_item_attribute_id_unique'); + }); + } + + public function down(): void + { + Schema::table('variant_values', function (Blueprint $table): void { + $table->unique(['variant_id', 'item_attribute_id']); + }); + } +}; diff --git a/database/seeders/AttributeSeeder.php b/database/seeders/AttributeSeeder.php index 417fbba..001d26a 100644 --- a/database/seeders/AttributeSeeder.php +++ b/database/seeders/AttributeSeeder.php @@ -104,7 +104,14 @@ class AttributeSeeder extends Seeder { Attribute::query() ->where('tenant_codigo', $tenant->codigo) - ->whereIn('codigo', ['talle_numerico', 'fecha']) + ->whereNotIn('codigo', [ + 'event_date', + 'servicio', + 'color', + 'horario', + 'talle', + 'tipo_alojamiento', + ]) ->delete(); $this->seedAttribute($tenant, [ @@ -118,6 +125,17 @@ class AttributeSeeder extends Seeder $lunchValidityTime = $this->timeWindow('12:00:00', '15:00:00'); $dinnerValidityTime = $this->timeWindow('20:00:00', '24:00:00'); + $this->seedAttribute($tenant, [ + 'codigo' => 'tipo_alojamiento', + 'nombre' => 'TipoAlojamiento', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'options' => [ + ['value' => 'Carpa', 'label' => 'Carpa', 'sort_order' => 1], + ['value' => 'Motorhome', 'label' => 'Motorhome', 'sort_order' => 2], + ], + ]); + $this->seedAttribute($tenant, [ 'codigo' => 'servicio', 'nombre' => 'Servicio', diff --git a/database/seeders/FiestaFutbolInfantilProductSeeder.php b/database/seeders/FiestaFutbolInfantilProductSeeder.php index b9ff008..b9566c4 100644 --- a/database/seeders/FiestaFutbolInfantilProductSeeder.php +++ b/database/seeders/FiestaFutbolInfantilProductSeeder.php @@ -2,7 +2,6 @@ namespace Database\Seeders; -use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Enums\EventProductType; use App\Domains\Catalog\Enums\FeaturedGroupSource; use App\Domains\Catalog\Enums\GroupLayout; @@ -13,9 +12,8 @@ use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Enums\ValidityTimeType; -use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Database\Seeder; +use Illuminate\Support\Collection; use RuntimeException; class FiestaFutbolInfantilProductSeeder extends Seeder @@ -32,141 +30,82 @@ class FiestaFutbolInfantilProductSeeder extends Seeder $this->deleteExistingCatalog($tenant); FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete(); - Category::query() - ->where('tenant_code', $tenant->codigo) - ->update(['categoria_id' => null]); + Category::query()->where('tenant_code', $tenant->codigo)->update(['categoria_id' => null]); Category::query()->where('tenant_code', $tenant->codigo)->delete(); $tenant->update([ 'event_title' => 'Fiesta Nacional del Fútbol Infantil', 'event_location' => 'Sunchales, Santa Fe', ]); + $tenant->eventDates()->delete(); - $eventDates = collect(['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12']) - ->mapWithKeys(function (string $date) use ($tenant): array { - $eventDate = $tenant->eventDates()->create([ - 'date' => $date, - 'time_start' => '00:00:00', - 'time_end' => '23:59:59', - ]); + ->map(fn (string $date) => $tenant->eventDates()->create([ + 'date' => $date, + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', + ])); + $dateIds = $eventDates->pluck('id')->map(fn ($id): int => (int) $id)->values(); - return [$date => $eventDate]; - }); - - $ticketCategory = Category::query()->firstOrCreate([ - 'nombre' => 'Entradas', - 'tenant_code' => $tenant->codigo, - ]); - $accommodationCategory = Category::query()->firstOrCreate([ - 'nombre' => 'Alojamientos', - 'tenant_code' => $tenant->codigo, - ]); - $merchandisingCategory = Category::query()->firstOrCreate([ - 'nombre' => 'Merchandising', - 'tenant_code' => $tenant->codigo, - ]); - $foodCategory = Category::query()->firstOrCreate([ - 'nombre' => 'Comida', - 'tenant_code' => $tenant->codigo, - ]); - - $dates = $eventDates->keys()->all(); - $minimumUseDate = $dates[0].' 00:00:00'; - $maximumUseDate = $dates[array_key_last($dates)].' 23:59:59'; - $eventValidityTime = ValidityTime::query()->firstOrCreate([ - 'type' => ValidityTimeType::FixedWindow, - 'fixed_starts_at' => $minimumUseDate, - 'fixed_expires_at' => $maximumUseDate, - ]); - - $generalAdmission = $this->catalogService->create([ - 'tenant_code' => $tenant->codigo, - 'event_product_type' => EventProductType::Entry->value, - 'category_id' => $ticketCategory->id, - 'slug' => 'entrada-general', - 'nombre' => 'Entrada General', - 'descripcion' => 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.', - 'precio' => 10000, - 'inventory_policy' => InventoryPolicy::Unlimited->value, - 'has_tickets' => true, - 'validity_time_id' => $eventValidityTime->id, - 'attribute_codes' => ['event_date'], - 'variants' => array_map( - fn (string $date): array => [ + $this->createProduct($tenant, [ + 'slug' => 'camiseta', + 'nombre' => 'Camiseta', + 'precio' => 18000, + 'attribute_codes' => ['color', 'talle'], + 'variants' => collect(['Verde', 'Blanco']) + ->crossJoin(['14', 'S', 'M', 'L', 'XL', 'XXL']) + ->map(fn (array $values): array => [ 'real_stock' => 0, - 'event_date_id' => $eventDates->get($date)->id, - ], - $dates, - ), + 'values' => ['color' => $values[0], 'talle' => $values[1]], + ])->all(), ]); - $items = [ - ['slug' => 'alojamiento-por-noche', 'nombre' => 'Alojamiento por noche', 'precio' => 35000, 'category_id' => $accommodationCategory->id], - ['slug' => 'alojamiento-fin-de-semana', 'nombre' => 'Alojamiento fin de semana', 'precio' => 90000, 'category_id' => $accommodationCategory->id], - ['slug' => 'remera-oficial', 'nombre' => 'Remera oficial', 'precio' => 18000, 'category_id' => $merchandisingCategory->id], - ['slug' => 'gorra-oficial', 'nombre' => 'Gorra oficial', 'precio' => 12000, 'category_id' => $merchandisingCategory->id], - ['slug' => 'hamburguesa-papa-frita', 'nombre' => 'Hamburguesa con papa frita', 'precio' => 8000, 'category_id' => $foodCategory->id], - ['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'category_id' => $foodCategory->id], - ]; - - $createdItems = []; - foreach ($items as $item) { - $createdItems[$item['slug']] = $this->catalogService->create([ - 'tenant_code' => $tenant->codigo, - 'event_product_type' => EventProductType::Product->value, - 'descripcion' => $item['descripcion'] ?? $item['nombre'], - 'inventory_policy' => InventoryPolicy::Unlimited->value, + $this->createProduct($tenant, [ + 'slug' => 'alojamiento', + 'nombre' => 'Alojamiento', + 'precio' => 35000, + 'attribute_codes' => ['tipo_alojamiento'], + 'variants' => collect(['Carpa', 'Motorhome'])->map(fn (string $type): array => [ 'real_stock' => 0, - 'validity_time_id' => $eventValidityTime->id, - ...$item, - ]); - } + 'values' => ['tipo_alojamiento' => $type], + ])->all(), + ]); - $this->catalogService->create([ - 'tenant_code' => $tenant->codigo, - 'event_product_type' => EventProductType::Entry->value, - 'type' => CatalogItemType::Bundle->value, - 'slug' => 'entrada-general-todos-los-dias', - 'nombre' => 'Entrada General - Todos los días', - 'descripcion' => 'Incluye una entrada para cada día de la Fiesta Nacional del Fútbol Infantil.', + $this->createProduct($tenant, [ + 'slug' => 'comida', + 'nombre' => 'Comida', + 'precio' => 8000, + 'attribute_codes' => ['event_date', 'horario', 'servicio'], + 'variants' => $dateIds + ->crossJoin(['Desayuno', 'Almuerzo', 'Cena'], ['Comedor', 'Vianda']) + ->map(fn (array $values): array => [ + 'real_stock' => 0, + 'event_date_ids' => [(int) $values[0]], + 'values' => ['horario' => $values[1], 'servicio' => $values[2]], + ])->all(), + ]); + + $this->createProduct($tenant, [ + 'slug' => 'abono', + 'nombre' => 'Abono', 'precio' => 40000, - 'category_id' => $ticketCategory->id, - 'components' => $generalAdmission->variants - ->map(fn ($variant): array => [ - 'catalog_item_id' => $generalAdmission->id, - 'variant_id' => $variant->id, - 'quantity' => 1, - ]) + 'event_product_type' => EventProductType::Entry->value, + '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(), ]); - $this->catalogService->create([ + FeaturedGroup::query()->create([ 'tenant_code' => $tenant->codigo, - 'event_product_type' => EventProductType::Product->value, - 'type' => CatalogItemType::Bundle->value, - 'slug' => 'combo-2-panchos-2-hamburguesas', - 'nombre' => 'Combo 2 Panchos + 2 Hamburguesas', - 'descripcion' => 'Incluye 2 panchos y 2 hamburguesas con papa frita.', - 'precio' => 24000, - 'category_id' => $foodCategory->id, - 'components' => [ - [ - 'catalog_item_id' => $createdItems['pancho']->id, - 'quantity' => 2, - ], - [ - 'catalog_item_id' => $createdItems['hamburguesa-papa-frita']->id, - 'quantity' => 2, - ], - ], - ]); - - $this->seedFeaturedGroups($tenant, [ - 'Entradas' => $ticketCategory, - 'Alojamientos' => $accommodationCategory, - 'Merchandising' => $merchandisingCategory, - 'Comida' => $foodCategory, + 'source_type' => FeaturedGroupSource::All, + 'category_id' => null, + 'product_layout' => ProductLayout::ColumnWithCart, + 'group_layout' => GroupLayout::Simple, + 'group_name' => 'Productos', + 'group_order' => 0, ]); } @@ -174,49 +113,40 @@ class FiestaFutbolInfantilProductSeeder extends Seeder { CatalogItem::query() ->where('tenant_code', $tenant->codigo) - ->where('type', CatalogItemType::Bundle->value) - ->each(fn (CatalogItem $item) => $this->catalogService->delete($item)); - - CatalogItem::query() - ->where('tenant_code', $tenant->codigo) - ->where('type', CatalogItemType::Standard->value) + ->orderByRaw("CASE WHEN type = 'bundle' THEN 0 ELSE 1 END") ->each(fn (CatalogItem $item) => $this->catalogService->delete($item)); } - /** @param array $categories */ - private function seedFeaturedGroups(Tenant $tenant, array $categories): void + /** @param array $data */ + private function createProduct(Tenant $tenant, array $data): CatalogItem { - $groups = [ - 'Entradas' => [ - 'product_layout' => ProductLayout::Row, - 'group_layout' => GroupLayout::SimpleVertical, - ], - 'Alojamientos' => [ - 'product_layout' => ProductLayout::ColumnWithCart, - 'group_layout' => GroupLayout::Simple, - ], - 'Merchandising' => [ - 'product_layout' => ProductLayout::ColumnWithCart, - 'group_layout' => GroupLayout::Simple, - ], - 'Comida' => [ - 'product_layout' => ProductLayout::ColumnWithCart, - 'group_layout' => GroupLayout::Simple, - ], - ]; + return $this->catalogService->create([ + 'tenant_code' => $tenant->codigo, + 'event_product_type' => EventProductType::Product->value, + 'descripcion' => $data['nombre'], + 'inventory_policy' => InventoryPolicy::Unlimited->value, + ...$data, + ]); + } - $groupOrder = 0; - foreach ($groups as $groupName => $config) { - FeaturedGroup::query()->create([ - 'tenant_code' => $tenant->codigo, - 'source_type' => FeaturedGroupSource::Category, - 'category_id' => $categories[$groupName]->id, - 'product_layout' => $config['product_layout'], - 'group_layout' => $config['group_layout'], - 'group_name' => $groupName, - 'group_order' => $groupOrder++, - ]); + /** @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/tests/Feature/Catalog/CatalogServiceTest.php b/tests/Feature/Catalog/CatalogServiceTest.php index 3297699..6e79f22 100644 --- a/tests/Feature/Catalog/CatalogServiceTest.php +++ b/tests/Feature/Catalog/CatalogServiceTest.php @@ -136,6 +136,42 @@ class CatalogServiceTest extends TestCase ); } + public function test_it_creates_a_variant_with_multiple_event_dates(): void + { + $eventDateAttribute = $this->createAttribute('event_date', FieldType::EventDate); + $firstDate = $this->tenant->eventDates()->create([ + 'date' => '2026-10-09', + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', + ]); + $secondDate = $this->tenant->eventDates()->create([ + 'date' => '2026-10-10', + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', + ]); + + $item = $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'multi-date-pass', + 'nombre' => 'Multi-date pass', + 'precio' => 100, + 'attribute_codes' => [$eventDateAttribute->codigo], + 'multi_select_attribute_codes' => ['event_date'], + 'variants' => [[ + 'real_stock' => 5, + 'event_date_ids' => [$secondDate->id, $firstDate->id], + ]], + ]); + + $variant = $item->variants->sole(); + $this->assertNull($variant->event_date_id); + $this->assertSame([$firstDate->id, $secondDate->id], $variant->eventDates->pluck('id')->all()); + $this->assertSame( + [(string) $firstDate->id, (string) $secondDate->id], + $variant->selectionValues()->get('event_date'), + ); + } + public function test_it_rejects_duplicate_variant_combinations(): void { $sector = $this->createAttribute('sector'); diff --git a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php index 0f767f1..c3c06c7 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -4,20 +4,12 @@ namespace Tests\Feature\Seeders; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; -use App\Domains\Catalog\Enums\CatalogItemType; -use App\Domains\Catalog\Enums\EventProductType; use App\Domains\Catalog\Enums\FeaturedGroupSource; -use App\Domains\Catalog\Enums\GroupLayout; -use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; -use App\Domains\Catalog\Models\Inventory; -use App\Domains\Event\Models\EventDate; -use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Ticket\Enums\ValidityTimeType; use Database\Seeders\AttributeSeeder; use Database\Seeders\FiestaFutbolInfantilProductSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -27,21 +19,10 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase { use RefreshDatabase; - public function test_it_seeds_event_items_for_the_new_catalog(): void + public function test_it_seeds_the_category_free_configurable_catalog(): void { - $headerLogo = Attachment::query()->create([ - 'path' => 'tests/header.png', - 'filename' => 'header.png', - 'type' => AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - $footerLogo = Attachment::query()->create([ - 'path' => 'tests/footer.png', - 'filename' => 'footer.png', - 'type' => AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - + $headerLogo = $this->attachment('header'); + $footerLogo = $this->attachment('footer'); $tenant = Tenant::query()->create([ 'codigo' => 'fiesta_futbol_infantil', 'nombre' => 'Fiesta Fútbol Infantil', @@ -56,189 +37,55 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase 'footer_logo_id' => $footerLogo->id, ]); - $this->seed([ - AttributeSeeder::class, - FiestaFutbolInfantilProductSeeder::class, - ]); - $this->seed([ - AttributeSeeder::class, - FiestaFutbolInfantilProductSeeder::class, - ]); - - $attributes = Attribute::query() - ->where('tenant_codigo', $tenant->codigo) - ->with('options.validityTime') - ->orderBy('id') - ->get(); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); $this->assertSame( - ['event_date', 'servicio', 'color', 'horario', 'talle'], - $attributes->pluck('codigo')->all(), + ['color', 'event_date', 'horario', 'servicio', 'talle', 'tipo_alojamiento'], + Attribute::query()->where('tenant_codigo', $tenant->codigo)->orderBy('codigo')->pluck('codigo')->all(), ); - $this->assertSame(FieldType::EventDate, $attributes[0]->type); - $this->assertEmpty($attributes[0]->options); - $this->assertSame(['Comedor', 'Vianda'], $attributes[1]->options->pluck('value')->all()); - $this->assertSame(['Verde', 'Blanco'], $attributes[2]->options->pluck('value')->all()); - $this->assertSame(['Desayuno', 'Almuerzo', 'Cena'], $attributes[3]->options->pluck('value')->all()); - $this->assertSame(['14', 'S', 'M', 'L', 'XL', 'XXL'], $attributes[4]->options->pluck('value')->all()); - + $this->assertSame(0, Category::query()->where('tenant_code', $tenant->codigo)->count()); $this->assertSame( - [ - ['Desayuno', ValidityTimeType::TimeWindow, '07:00:00', '12:00:00'], - ['Almuerzo', ValidityTimeType::TimeWindow, '12:00:00', '15:00:00'], - ['Cena', ValidityTimeType::TimeWindow, '20:00:00', '24:00:00'], - ], - $attributes[3]->options - ->map(fn ($option): array => [ - $option->value, - $option->validityTime->type, - $option->validityTime->start_time, - $option->validityTime->end_time, - ]) - ->all(), + ['abono', 'alojamiento', 'camiseta', 'comida'], + CatalogItem::query()->where('tenant_code', $tenant->codigo)->orderBy('slug')->pluck('slug')->all(), ); - $tenant->refresh()->load('eventDates'); - $this->assertSame('Fiesta Nacional del Fútbol Infantil', $tenant->event_title); - $this->assertSame('Sunchales, Santa Fe', $tenant->event_location); - $this->assertCount(4, $tenant->eventDates); - $this->assertSame(4, EventDate::query()->where('tenant_code', $tenant->codigo)->count()); - - $generalAdmission = CatalogItem::query() - ->where('tenant_code', $tenant->codigo) - ->where('slug', 'entrada-general') - ->with('variants.eventDate', 'itemAttributes') - ->sole(); - - $this->assertNull($generalAdmission->inventory_id); - $this->assertSame(EventProductType::Entry, $generalAdmission->event_product_type); - $this->assertCount(1, $generalAdmission->itemAttributes); - $this->assertCount(4, $generalAdmission->variants); - $this->assertEqualsCanonicalizing( - ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'], - $generalAdmission->variants - ->map(fn ($variant) => $variant->eventDate->date->format('Y-m-d')) - ->all() - ); - $this->assertSame('2026-10-09 00:00:00', $generalAdmission->validityTime->fixed_starts_at->format('Y-m-d H:i:s')); - $this->assertSame('2026-10-12 23:59:59', $generalAdmission->validityTime->fixed_expires_at->format('Y-m-d H:i:s')); - - $standardItems = CatalogItem::query() - ->where('tenant_code', $tenant->codigo) - ->where('type', CatalogItemType::Standard->value) - ->get(); - - $this->assertCount(7, $standardItems); - foreach ($standardItems as $standardItem) { - $this->assertSame( - '2026-10-09 00:00:00', - $standardItem->validityTime->fixed_starts_at->format('Y-m-d H:i:s'), - ); - $this->assertSame( - '2026-10-12 23:59:59', - $standardItem->validityTime->fixed_expires_at->format('Y-m-d H:i:s'), - ); + $expectedVariantCounts = [ + 'camiseta' => 12, + 'alojamiento' => 2, + 'comida' => 24, + 'abono' => 15, + ]; + foreach ($expectedVariantCounts as $slug => $count) { + $item = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', $slug)->sole(); + $this->assertCount($count, $item->variants); + $this->assertNull($item->category_id); } - $allDaysItem = CatalogItem::query() + $abono = CatalogItem::query() ->where('tenant_code', $tenant->codigo) - ->where('nombre', 'Entrada General - Todos los días') - ->with('bundleComponents.variant.eventDate') + ->where('slug', 'abono') + ->with('itemAttributes.attribute', 'variants.eventDates') ->sole(); - - $this->assertSame(CatalogItemType::Bundle, $allDaysItem->type); - $this->assertSame('40000.00', $allDaysItem->precio); - $this->assertNull($allDaysItem->inventory_id); - $this->assertFalse($allDaysItem->has_tickets); - $this->assertSame(EventProductType::Entry, $allDaysItem->event_product_type); - $this->assertCount(4, $allDaysItem->bundleComponents); + $dateAttribute = $abono->itemAttributes->firstWhere('attribute.codigo', 'event_date'); + $this->assertTrue($dateAttribute->allow_multi_select); $this->assertEqualsCanonicalizing( - ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'], - $allDaysItem->bundleComponents - ->map(fn ($component) => $component->variant->eventDate->date->format('Y-m-d')) - ->all() + [1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 4], + $abono->variants->map(fn ($variant): int => $variant->eventDates->count())->all(), ); - $foodCombo = CatalogItem::query() - ->where('tenant_code', $tenant->codigo) - ->where('nombre', 'Combo 2 Panchos + 2 Hamburguesas') - ->with('bundleComponents.catalogItem') - ->sole(); + $featuredGroup = FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->sole(); + $this->assertSame(FeaturedGroupSource::All, $featuredGroup->source_type); + $this->assertNull($featuredGroup->category_id); + } - $this->assertSame(CatalogItemType::Bundle, $foodCombo->type); - $this->assertSame('24000.00', $foodCombo->precio); - $this->assertNull($foodCombo->inventory_id); - $this->assertSame(EventProductType::Product, $foodCombo->event_product_type); - $this->assertSame( - [ - 'hamburguesa-papa-frita' => 2, - 'pancho' => 2, - ], - $foodCombo->bundleComponents - ->mapWithKeys(fn ($component): array => [ - $component->catalogItem->slug => $component->quantity, - ]) - ->sortKeys() - ->all() - ); - $this->assertSame(9, CatalogItem::query()->where('tenant_code', $tenant->codigo)->count()); - $this->assertSame(10, Inventory::query()->count()); - $this->assertSame( - ['Alojamientos', 'Comida', 'Entradas', 'Merchandising'], - Category::query() - ->where('tenant_code', $tenant->codigo) - ->orderBy('nombre') - ->pluck('nombre') - ->all(), - ); - $this->assertSame( - 'Merchandising', - CatalogItem::query() - ->where('tenant_code', $tenant->codigo) - ->where('slug', 'remera-oficial') - ->with('category') - ->sole() - ->category - ->nombre, - ); - - $featuredGroups = FeaturedGroup::query() - ->where('tenant_code', $tenant->codigo) - ->with('category.catalogItems') - ->orderBy('group_order') - ->get(); - - $this->assertSame( - [ - 'Entradas' => ['entrada-general', 'entrada-general-todos-los-dias'], - 'Alojamientos' => ['alojamiento-por-noche', 'alojamiento-fin-de-semana'], - 'Merchandising' => ['remera-oficial', 'gorra-oficial'], - 'Comida' => ['hamburguesa-papa-frita', 'pancho', 'combo-2-panchos-2-hamburguesas'], - ], - $featuredGroups - ->mapWithKeys(fn (FeaturedGroup $group): array => [ - $group->group_name => $group->category->catalogItems->pluck('slug')->all(), - ]) - ->all() - ); - - $this->assertSame( - [ - ['Entradas', FeaturedGroupSource::Category, 'Entradas', ProductLayout::Row, GroupLayout::SimpleVertical, 0], - ['Alojamientos', FeaturedGroupSource::Category, 'Alojamientos', ProductLayout::ColumnWithCart, GroupLayout::Simple, 1], - ['Merchandising', FeaturedGroupSource::Category, 'Merchandising', ProductLayout::ColumnWithCart, GroupLayout::Simple, 2], - ['Comida', FeaturedGroupSource::Category, 'Comida', ProductLayout::ColumnWithCart, GroupLayout::Simple, 3], - ], - $featuredGroups - ->map(fn (FeaturedGroup $group): array => [ - $group->group_name, - $group->source_type, - $group->category->nombre, - $group->product_layout, - $group->group_layout, - $group->group_order, - ]) - ->all() - ); + private function attachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "tests/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); } } diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index 4deb0c1..6621615 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -235,6 +235,31 @@ class TicketGeneratorServiceTest extends TestCase $this->assertSame('2026-08-21 02:00:00', $fixedWindow->fixed_expires_at->format('Y-m-d H:i:s')); } + public function test_a_multi_date_variant_generates_one_ticket_for_each_selected_date(): void + { + $item = $this->createTicketableItem('multi-date-pass'); + $dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'date' => $date, + 'time_start' => '00:00:00', + 'time_end' => '23:59:59', + ])); + $variant = $item->variants()->create([ + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->eventDates()->sync($dates->pluck('id')); + + $tickets = $this->service->generate($item, $this->user, 1, $variant->id); + $tickets->each->loadMissing('validityTime'); + + $this->assertCount(2, $tickets); + $this->assertSame( + ['2026-08-20 00:00:00', '2026-08-21 00:00:00'], + $tickets->map(fn ($ticket): string => $ticket->validityTime->fixed_starts_at->format('Y-m-d H:i:s'))->all(), + ); + $this->assertSame([$variant->id], $tickets->pluck('source_variant_id')->unique()->values()->all()); + } + public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void { Event::fake([TicketsAvailable::class]);