feat: add support for multiple event dates in variants

- Updated the FeaturedGroupService to include 'variants.eventDates' in the items query.
- Introduced a BelongsToMany relationship in EventDate for selected variants.
- Modified PurchaseItemResource and PurchaseItemSnapshotFactory to handle multiple event dates for variants.
- Enhanced StartCheckoutService to load event dates for variants.
- Updated TicketGeneratorService to accommodate event dates in ticket generation.
- Created migrations to support multi-value event dates and allow multiple variant values per attribute.
- Adjusted seeders to reflect new event date handling and added new attributes.
- Added tests to ensure correct functionality for multi-date variants and their integration with ticket generation.
This commit is contained in:
2026-08-07 11:57:38 -03:00
parent 91af233941
commit 21b70777f2
22 changed files with 516 additions and 413 deletions

View File

@@ -104,6 +104,8 @@ class CartService
'items.variant.attachments',
'items.variant.inventory',
'items.variant.definitions.itemAttribute.attribute',
'items.variant.eventDates',
'items.variant.eventDate',
]);
}

View File

@@ -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<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{

View File

@@ -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<EventDate, $this> */
public function eventDates(): BelongsToMany
{
return $this->belongsToMany(
EventDate::class,
'variant_event_dates',
'variant_id',
'event_date_id',
)->orderBy('date')->orderBy('time_start');
}
/** @return HasMany<Ticket, $this> */
public function sourceTickets(): HasMany
{
@@ -92,4 +104,39 @@ class Variant extends Model
{
return $this->catalogItem->nombre;
}
/** @return Collection<string, string|array<int, string>> */
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<int, EventDate> */
public function selectedEventDates(): Collection
{
$eventDates = $this->eventDates;
if ($eventDates->isEmpty() && $this->event_date_id !== null && $this->eventDate !== null) {
return collect([$this->eventDate]);
}
return $eventDates;
}
}

View File

@@ -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'],

View File

@@ -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(),
];

View File

@@ -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<string, mixed> */
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,
];

View File

@@ -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(),

View File

@@ -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(),
];

View File

@@ -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<int, string> $attributeCodes
* @param array<int, string> $multiSelectAttributeCodes
* @return array<string, ItemAttribute>
*/
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] ?? ''));

View File

@@ -44,6 +44,7 @@ class FeaturedGroupService
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',

View File

@@ -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<Variant, $this> */
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);

View File

@@ -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

View File

@@ -53,13 +53,25 @@ class PurchaseItemSnapshotFactory
/** @return array<int, array{name: string, value: mixed}> */
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();
}
}

View File

@@ -278,6 +278,8 @@ class StartCheckoutService
'variant.attachments',
'variant.catalogItem',
'variant.definitions.itemAttribute.attribute',
'variant.eventDates',
'variant.eventDate',
]);
}

View File

@@ -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<int, array{catalog_item: CatalogItem, variant: Variant|null}>
* @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null, event_date: EventDate|null}>
*/
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<int, array{catalog_item: CatalogItem, variant: Variant|null, event_date: EventDate|null}>
*/
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