From e42bc545a5614589b2a36d04c5b42e9f89c0294e Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 14:09:42 -0300 Subject: [PATCH] feat(event): replace variants when dates change --- app/Domains/Catalog/Models/CatalogItem.php | 30 +++- .../Services/CatalogInventoryService.php | 11 +- .../Catalog/Services/CatalogService.php | 17 ++- .../Services/VariantReplacementService.php | 137 ++++++++++++++++++ app/Domains/Event/Services/EventService.php | 7 +- 5 files changed, 188 insertions(+), 14 deletions(-) create mode 100644 app/Domains/Catalog/Services/VariantReplacementService.php diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 401d670..74e3b7b 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -179,11 +179,27 @@ class CatalogItem extends Model { return $query->where(function (Builder $query): void { $query - ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) + ->where(function (Builder $unlimitedQuery): void { + $unlimitedQuery + ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) + ->where(function (Builder $selectionQuery): void { + $selectionQuery + ->whereDoesntHave('variants') + ->orWhereHas('variants', fn (Builder $variantQuery): Builder => $variantQuery + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id')); + }); + }) ->orWhereHas( - 'variants.inventory', - fn (Builder $inventoryQuery): Builder => $inventoryQuery - ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') + 'variants', + fn (Builder $variantQuery): Builder => $variantQuery + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->whereHas( + 'inventory', + fn (Builder $inventoryQuery): Builder => $inventoryQuery + ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') + ) ) ->orWhere(function (Builder $directItemQuery): void { $directItemQuery @@ -206,8 +222,10 @@ class CatalogItem extends Model { return $this->variants ->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId) - || $this->inventory_policy === InventoryPolicy::Unlimited - || ($variant->inventory?->availableStock() ?? 0) > 0) + || ($variant->isSellable() && ( + $this->inventory_policy === InventoryPolicy::Unlimited + || ($variant->inventory?->availableStock() ?? 0) > 0 + ))) ->values(); } diff --git a/app/Domains/Catalog/Services/CatalogInventoryService.php b/app/Domains/Catalog/Services/CatalogInventoryService.php index 30e433a..34de52f 100644 --- a/app/Domains/Catalog/Services/CatalogInventoryService.php +++ b/app/Domains/Catalog/Services/CatalogInventoryService.php @@ -63,9 +63,14 @@ class CatalogInventoryService $selection->loadMissing('variants.inventory'); - return $selection->variants->sum( - fn (Variant $variant): int => $variant->inventory->availableStock(), - ); + return $selection->variants + ->filter(fn (Variant $variant): bool => $variant->isSellable()) + ->unique(fn (Variant $variant): string => $variant->inventory_id === null + ? 'object:'.spl_object_id($variant->inventory) + : 'id:'.$variant->inventory_id) + ->sum( + fn (Variant $variant): int => $variant->inventory->availableStock(), + ); } $requirements = $this->inventoryRequirements($selection); diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index 2b46377..4cc8989 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -207,7 +207,8 @@ class CatalogService $visibleVariants = $catalogItem->visibleVariants(); if ($catalogItem->type === CatalogItemType::Standard && ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty()) - && ! $catalogItem->isAvailable()) { + && (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty()) + || ! $catalogItem->isAvailable())) { throw new NotFoundHttpException('Catalog item is out of stock.'); } @@ -324,9 +325,13 @@ class CatalogService ->findOrFail($variant->catalog_item_id); $variant->delete(); - if (! $catalogItem->variants()->exists()) { + $sellableVariants = $catalogItem->variants() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id'); + + if (! (clone $sellableVariants)->exists()) { $this->delete($catalogItem); - } elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) { + } elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) { $catalogItem->update(['precio' => $minimumPrice]); } @@ -399,7 +404,11 @@ class CatalogService ]); } - if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) { + if ($variantId !== null && ! $componentItem->variants() + ->whereKey($variantId) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->exists()) { throw ValidationException::withMessages([ "components.{$index}.variant_id" => [ __('api.catalog.component_variant_invalid'), diff --git a/app/Domains/Catalog/Services/VariantReplacementService.php b/app/Domains/Catalog/Services/VariantReplacementService.php new file mode 100644 index 0000000..b6702ce --- /dev/null +++ b/app/Domains/Catalog/Services/VariantReplacementService.php @@ -0,0 +1,137 @@ + */ + public function replaceEventDate(EventDate $source, EventDate $destination): Collection + { + $variants = Variant::query() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->where(function ($query) use ($source): void { + $query->where('event_date_id', $source->getKey()) + ->orWhereHas('eventDates', fn ($eventDates) => $eventDates + ->where('event_dates.id', $source->getKey())); + }) + ->with(['eventDates', 'eventDate', 'definitions', 'allAttachments']) + ->orderBy('id') + ->lockForUpdate() + ->get(); + + return $variants->map(function (Variant $variant) use ($source, $destination): Variant { + $destinationDateIds = $variant->selectedEventDates() + ->pluck('id') + ->map(fn ($id): int => (int) $id === (int) $source->getKey() + ? (int) $destination->getKey() + : (int) $id) + ->unique() + ->sort() + ->values(); + + $replacement = $this->findEquivalent($variant, $destinationDateIds) + ?? $this->cloneWithDates($variant, $destinationDateIds); + + $variant->update([ + 'replaced_by_variant_id' => $replacement->getKey(), + 'sales_disabled_at' => now(), + ]); + + BundleComponent::query() + ->where('component_variant_id', $variant->getKey()) + ->update(['component_variant_id' => $replacement->getKey()]); + + return $replacement; + })->values(); + } + + public function disableForSuspension(EventDate $eventDate): void + { + Variant::query() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->where(function ($query) use ($eventDate): void { + $query->where('event_date_id', $eventDate->getKey()) + ->orWhereHas('eventDates', fn ($eventDates) => $eventDates + ->where('event_dates.id', $eventDate->getKey())); + }) + ->update(['sales_disabled_at' => now()]); + } + + /** @param Collection $eventDateIds */ + private function findEquivalent(Variant $source, Collection $eventDateIds): ?Variant + { + $definitionSignature = $this->definitionSignature($source); + $dateSignature = $eventDateIds->map(fn ($id): int => (int) $id)->sort()->values()->all(); + + return Variant::query() + ->where('catalog_item_id', $source->catalog_item_id) + ->whereKeyNot($source->getKey()) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['eventDates', 'eventDate', 'definitions']) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->first(fn (Variant $candidate): bool => $this->definitionSignature($candidate) === $definitionSignature + && $candidate->selectedEventDates() + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->sort() + ->values() + ->all() === $dateSignature + ); + } + + /** @param Collection $eventDateIds */ + private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant + { + $replacement = $source->replicate([ + 'event_date_id', + 'replaced_by_variant_id', + 'sales_disabled_at', + ]); + $replacement->event_date_id = $eventDateIds->count() === 1 + ? $eventDateIds->first() + : null; + $replacement->save(); + $replacement->eventDates()->sync($eventDateIds->all()); + + $replacement->definitions()->createMany( + $source->definitions + ->map(fn ($definition): array => [ + 'item_attribute_id' => $definition->item_attribute_id, + 'value' => $definition->value, + ]) + ->all(), + ); + + $attachments = $source->allAttachments + ->mapWithKeys(fn ($attachment): array => [ + $attachment->getKey() => [ + 'orden' => $attachment->pivot->orden, + 'is_enabled' => $attachment->pivot->is_enabled, + ], + ]) + ->all(); + $replacement->allAttachments()->sync($attachments); + + return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']); + } + + /** @return list */ + private function definitionSignature(Variant $variant): array + { + return $variant->definitions + ->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value) + ->sort() + ->values() + ->all(); + } +} diff --git a/app/Domains/Event/Services/EventService.php b/app/Domains/Event/Services/EventService.php index 1cafb91..0119e15 100644 --- a/app/Domains/Event/Services/EventService.php +++ b/app/Domains/Event/Services/EventService.php @@ -3,6 +3,7 @@ namespace App\Domains\Event\Services; use App\Domains\Catalog\Models\Variant; +use App\Domains\Catalog\Services\VariantReplacementService; use App\Domains\Event\Events\EventDateRescheduled; use App\Domains\Event\Events\EventDateSuspended; use App\Domains\Event\Models\EventDate; @@ -23,6 +24,7 @@ class EventService public function __construct( private readonly EffectiveEventDateResolver $effectiveEventDateResolver, private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver, + private readonly VariantReplacementService $variantReplacementService, ) {} public function forTenant(Tenant $tenant): Tenant @@ -109,7 +111,8 @@ class EventService ]); } - if ($this->effectiveEventDateResolver->resolve($destination) === null) { + $effectiveDestination = $this->effectiveEventDateResolver->resolve($destination); + if ($effectiveDestination === null) { throw ValidationException::withMessages([ 'date' => ['La fecha de destino no es utilizable.'], ]); @@ -120,6 +123,7 @@ class EventService $this->affectedDateIds($tenant, $source), ); $source->update(['rescheduled_to_event_date_id' => $destination->getKey()]); + $this->variantReplacementService->replaceEventDate($source, $effectiveDestination); EventDateRescheduled::dispatch( $tenant->codigo, @@ -154,6 +158,7 @@ class EventService $this->affectedDateIds($tenant, $date), ); $date->update(['suspended_at' => now()]); + $this->variantReplacementService->disableForSuspension($date); $this->disableTicketsWithoutUsableDates($tenant, $date); EventDateSuspended::dispatch(