feat(event): replace variants when dates change

This commit is contained in:
2026-09-14 14:09:42 -03:00
parent aa14129fe7
commit e42bc545a5
5 changed files with 188 additions and 14 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Models\BundleComponent;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use Illuminate\Support\Collection;
class VariantReplacementService
{
/** @return Collection<int, Variant> */
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<int, int> $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<int, int> $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<string> */
private function definitionSignature(Variant $variant): array
{
return $variant->definitions
->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value)
->sort()
->values()
->all();
}
}