feat(event): replace variants when dates change
This commit is contained in:
@@ -179,11 +179,27 @@ class CatalogItem extends Model
|
|||||||
{
|
{
|
||||||
return $query->where(function (Builder $query): void {
|
return $query->where(function (Builder $query): void {
|
||||||
$query
|
$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(
|
->orWhereHas(
|
||||||
'variants.inventory',
|
'variants',
|
||||||
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
fn (Builder $variantQuery): Builder => $variantQuery
|
||||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
->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 {
|
->orWhere(function (Builder $directItemQuery): void {
|
||||||
$directItemQuery
|
$directItemQuery
|
||||||
@@ -206,8 +222,10 @@ class CatalogItem extends Model
|
|||||||
{
|
{
|
||||||
return $this->variants
|
return $this->variants
|
||||||
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|
||||||
|| $this->inventory_policy === InventoryPolicy::Unlimited
|
|| ($variant->isSellable() && (
|
||||||
|| ($variant->inventory?->availableStock() ?? 0) > 0)
|
$this->inventory_policy === InventoryPolicy::Unlimited
|
||||||
|
|| ($variant->inventory?->availableStock() ?? 0) > 0
|
||||||
|
)))
|
||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,9 +63,14 @@ class CatalogInventoryService
|
|||||||
|
|
||||||
$selection->loadMissing('variants.inventory');
|
$selection->loadMissing('variants.inventory');
|
||||||
|
|
||||||
return $selection->variants->sum(
|
return $selection->variants
|
||||||
fn (Variant $variant): int => $variant->inventory->availableStock(),
|
->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);
|
$requirements = $this->inventoryRequirements($selection);
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ class CatalogService
|
|||||||
$visibleVariants = $catalogItem->visibleVariants();
|
$visibleVariants = $catalogItem->visibleVariants();
|
||||||
if ($catalogItem->type === CatalogItemType::Standard
|
if ($catalogItem->type === CatalogItemType::Standard
|
||||||
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
|
&& ($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.');
|
throw new NotFoundHttpException('Catalog item is out of stock.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,9 +325,13 @@ class CatalogService
|
|||||||
->findOrFail($variant->catalog_item_id);
|
->findOrFail($variant->catalog_item_id);
|
||||||
$variant->delete();
|
$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);
|
$this->delete($catalogItem);
|
||||||
} elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) {
|
} elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) {
|
||||||
$catalogItem->update(['precio' => $minimumPrice]);
|
$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([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}.variant_id" => [
|
"components.{$index}.variant_id" => [
|
||||||
__('api.catalog.component_variant_invalid'),
|
__('api.catalog.component_variant_invalid'),
|
||||||
|
|||||||
137
app/Domains/Catalog/Services/VariantReplacementService.php
Normal file
137
app/Domains/Catalog/Services/VariantReplacementService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Event\Services;
|
namespace App\Domains\Event\Services;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Catalog\Services\VariantReplacementService;
|
||||||
use App\Domains\Event\Events\EventDateRescheduled;
|
use App\Domains\Event\Events\EventDateRescheduled;
|
||||||
use App\Domains\Event\Events\EventDateSuspended;
|
use App\Domains\Event\Events\EventDateSuspended;
|
||||||
use App\Domains\Event\Models\EventDate;
|
use App\Domains\Event\Models\EventDate;
|
||||||
@@ -23,6 +24,7 @@ class EventService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
|
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
|
||||||
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
|
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
|
||||||
|
private readonly VariantReplacementService $variantReplacementService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function forTenant(Tenant $tenant): Tenant
|
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([
|
throw ValidationException::withMessages([
|
||||||
'date' => ['La fecha de destino no es utilizable.'],
|
'date' => ['La fecha de destino no es utilizable.'],
|
||||||
]);
|
]);
|
||||||
@@ -120,6 +123,7 @@ class EventService
|
|||||||
$this->affectedDateIds($tenant, $source),
|
$this->affectedDateIds($tenant, $source),
|
||||||
);
|
);
|
||||||
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
|
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
|
||||||
|
$this->variantReplacementService->replaceEventDate($source, $effectiveDestination);
|
||||||
|
|
||||||
EventDateRescheduled::dispatch(
|
EventDateRescheduled::dispatch(
|
||||||
$tenant->codigo,
|
$tenant->codigo,
|
||||||
@@ -154,6 +158,7 @@ class EventService
|
|||||||
$this->affectedDateIds($tenant, $date),
|
$this->affectedDateIds($tenant, $date),
|
||||||
);
|
);
|
||||||
$date->update(['suspended_at' => now()]);
|
$date->update(['suspended_at' => now()]);
|
||||||
|
$this->variantReplacementService->disableForSuspension($date);
|
||||||
$this->disableTicketsWithoutUsableDates($tenant, $date);
|
$this->disableTicketsWithoutUsableDates($tenant, $date);
|
||||||
|
|
||||||
EventDateSuspended::dispatch(
|
EventDateSuspended::dispatch(
|
||||||
|
|||||||
Reference in New Issue
Block a user