diff --git a/app/Domains/Catalog/Services/VariantReplacementService.php b/app/Domains/Catalog/Services/VariantReplacementService.php index 0c170aa..e4422d3 100644 --- a/app/Domains/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Catalog/Services/VariantReplacementService.php @@ -56,7 +56,7 @@ class VariantReplacementService public function disableForSuspension(EventDate $eventDate): void { - Variant::query() + $variants = Variant::query() ->whereNull('sales_disabled_at') ->whereNull('replaced_by_variant_id') ->where(function ($query) use ($eventDate): void { @@ -64,7 +64,43 @@ class VariantReplacementService ->orWhereHas('eventDates', fn ($eventDates) => $eventDates ->where('event_dates.id', $eventDate->getKey())); }) - ->update(['sales_disabled_at' => now()]); + ->with(['eventDates', 'eventDate', 'definitions', 'allAttachments']) + ->orderBy('id') + ->lockForUpdate() + ->get(); + + foreach ($variants as $variant) { + $remainingDateIds = $variant->selectedEventDates() + ->filter(fn (EventDate $date): bool => $date->suspended_at === null + && $date->rescheduled_to_event_date_id === null) + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values(); + + if ($remainingDateIds->isEmpty()) { + $variant->update(['sales_disabled_at' => now()]); + + continue; + } + + $replacement = $this->findEquivalent($variant, $remainingDateIds); + if ($replacement === null) { + $replacement = $this->cloneWithDates($variant, $remainingDateIds); + } else { + $this->mergeInventoryInto($variant, $replacement); + } + + $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()]); + } } /** @param Collection $eventDateIds */ @@ -166,6 +202,52 @@ class VariantReplacementService return $replacementInventory; } + private function mergeInventoryInto(Variant $source, Variant $destination): void + { + if ($source->inventory_id === $destination->inventory_id) { + return; + } + + $inventories = Inventory::query() + ->whereKey([$source->inventory_id, $destination->inventory_id]) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); + $sourceInventory = $inventories->get($source->inventory_id); + $destinationInventory = $inventories->get($destination->inventory_id); + if ($sourceInventory === null || $destinationInventory === null) { + throw new \LogicException('No se encontrĂ³ el inventario de una variante.'); + } + + $activeLines = StockReservationLine::query() + ->where('inventory_id', $source->inventory_id) + ->whereHas('reservation', fn ($reservation) => $reservation + ->where('status', StockReservation::STATUS_ACTIVE)) + ->orderBy('id') + ->lockForUpdate() + ->get(); + if ($sourceInventory->reserved_stock !== (int) $activeLines->sum('quantity')) { + throw new \LogicException('El inventario reservado de la variante es inconsistente.'); + } + + $destinationInventory->update([ + 'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock, + 'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock, + 'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units, + ]); + if ($activeLines->isNotEmpty()) { + StockReservationLine::query() + ->whereKey($activeLines->modelKeys()) + ->update(['inventory_id' => $destinationInventory->getKey()]); + } + $sourceInventory->update([ + 'real_stock' => 0, + 'reserved_stock' => 0, + 'sold_units' => 0, + ]); + } + /** @return list */ private function definitionSignature(Variant $variant): array { diff --git a/storage/framework/lsp-1ab91c4294c8e5da.php b/storage/framework/lsp-1ab91c4294c8e5da.php new file mode 100644 index 0000000..f14d849 --- /dev/null +++ b/storage/framework/lsp-1ab91c4294c8e5da.php @@ -0,0 +1,130 @@ +hasDefaultValue()) { +return ['default' => $property->getDefaultValue()]; +} + +if ($parameter?->isDefaultValueAvailable()) { +return ['default' => $parameter->getDefaultValue()]; +} + +return []; +} + +public static function formatDefaultValue(mixed $value): mixed +{ +return match (true) { +is_array($value) => 'array(...)', +$value instanceof UnitEnum => get_class($value) . '::' . $value->name, +$value instanceof Closure => 'Closure', +is_object($value) => get_class($value), +is_string($value) => var_export($value, true), +is_null($value) => 'null', +is_bool($value) => $value ? 'true' : 'false', +default => $value, +}; +} +} + +use Pest\Expectation; +use Pest\TestSuite; + +$pest = new class +{ +public function __construct() +{ +if ($this->isInstalled()) { +$this->boot(); +} +} + +public function isInstalled(): bool +{ +return class_exists(TestSuite::class); +} + +protected function boot(): void +{ +require_once base_path('vendor/pestphp/pest/overrides/Runner/TestSuiteLoader.php'); + +TestSuite::getInstance(base_path(), 'tests'); + +if (file_exists($pestFile = base_path('tests/Pest.php'))) { +require_once $pestFile; +} +} + +public function config(): ?array +{ +if (!$this->isInstalled()) { +return null; +} + +return [ +'uses' => $this->uses(), +'expectations' => $this->expectations(), +]; +} + +protected function uses(): array +{ +if (is_null($instance = TestSuite::getInstance())) { +return []; +} + +$reflection = new ReflectionProperty($instance->tests, 'uses'); +$uses = $reflection->getValue($instance->tests); + +return collect($uses)->map(function (array $use, string $path) { +[$classOrTraits] = $use; + +return [ +'path' => LspHelper::relativePath($path), +'classes' => array_values(array_filter($classOrTraits, fn ($c) => class_exists($c))), +'traits' => array_values(array_filter($classOrTraits, fn ($c) => trait_exists($c))), +]; +})->values()->all(); +} + +protected function expectations(): array +{ +$reflection = new ReflectionProperty(Expectation::class, 'extends'); +$extends = $reflection->getValue(); + +return collect($extends)->map(function (Closure $closure, string $name) { +$parameters = collect((new ReflectionFunction($closure))->getParameters()) +->map(function (ReflectionParameter $param) { +$type = $param->hasType() ? $param->getType() . ' ' : ''; + +$default = $param->isOptional() && $param->isDefaultValueAvailable() +? ' = ' . var_export($param->getDefaultValue(), true) +: ''; + +return $type . '$' . $param->getName() . $default; +}) +->join(', '); + +return compact('name', 'parameters'); +})->values()->all(); +} +}; + +echo json_encode($pest->config()); diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index dc53366..72e27ee 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -474,6 +474,13 @@ class AdminAppEventControllerTest extends TestCase $singleDateVariant = $this->createVariant($tenant, $suspendedDate->id); $multipleDateVariant = $this->createVariant($tenant); $multipleDateVariant->eventDates()->sync([$suspendedDate->id, $otherDate->id]); + $remainingDateVariant = Variant::query()->create([ + 'catalog_item_id' => $multipleDateVariant->catalog_item_id, + 'inventory_id' => Inventory::query()->create()->id, + 'event_date_id' => $otherDate->id, + ]); + $singleDateVariant->inventory->update(['real_stock' => 5]); + $multipleDateVariant->inventory->update(['real_stock' => 5]); $singleDateTicket = $this->createTicket($tenant, $admin, $singleDateVariant); $multipleDateTicket = $this->createTicket($tenant, $admin, $multipleDateVariant); Sanctum::actingAs($admin); @@ -508,6 +515,14 @@ class AdminAppEventControllerTest extends TestCase $this->assertNull($multipleDateTicket->fresh()->disabled_at); $this->assertNotNull($singleDateVariant->fresh()->sales_disabled_at); $this->assertNotNull($multipleDateVariant->fresh()->sales_disabled_at); + $replacement = $multipleDateVariant->fresh()->replacement; + $this->assertNotNull($replacement); + $this->assertSame($remainingDateVariant->id, $replacement->id); + $this->assertTrue($replacement->isSellable()); + $this->assertSame([$otherDate->id], $replacement->selectedEventDates()->pluck('id')->all()); + $this->assertSame(5, $replacement->inventory->fresh()->availableStock()); + $this->assertTrue(CatalogItem::query()->whereKey($multipleDateVariant->catalog_item_id)->whereAvailable()->exists()); + $this->assertFalse(CatalogItem::query()->whereKey($singleDateVariant->catalog_item_id)->whereAvailable()->exists()); $this->assertSame('10 de Octubre 2027', $tenant->fresh()->event_date_text); $this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F') ->assertOk() @@ -525,6 +540,10 @@ class AdminAppEventControllerTest extends TestCase '2027-10-10 09:00:00', $multipleDateTicket->fresh()->resolvedValidity()->effectiveStartsAt()?->format('Y-m-d H:i:s'), ); + + $this->postJson("/api/v1/adminapp/tenant/event-dates/{$otherDate->id}/suspend") + ->assertOk(); + $this->assertFalse(CatalogItem::query()->whereKey($multipleDateVariant->catalog_item_id)->whereAvailable()->exists()); } public function test_suspending_a_reschedule_destination_disables_tickets_from_predecessor_dates(): void