From 3a4f6b64d210d4022ba15cab7a5e67868e4d4b77 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 16 Sep 2026 09:05:21 -0300 Subject: [PATCH] feat(variants): implement restoration of previously suspended variants with usable dates --- .../Services/VariantReplacementService.php | 88 ++++++++++++++----- .../Resources/EntryResource.php | 7 +- .../Services/EntryService.php | 2 + ...re_variants_with_remaining_event_dates.php | 17 ++++ .../Event/AdminAppEventControllerTest.php | 31 +++++++ 5 files changed, 124 insertions(+), 21 deletions(-) create mode 100644 database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php diff --git a/app/Domains/Catalog/Services/VariantReplacementService.php b/app/Domains/Catalog/Services/VariantReplacementService.php index e4422d3..3070614 100644 --- a/app/Domains/Catalog/Services/VariantReplacementService.php +++ b/app/Domains/Catalog/Services/VariantReplacementService.php @@ -8,7 +8,9 @@ use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Models\Variant; use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Services\EffectiveEventDateResolver; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; class VariantReplacementService { @@ -70,14 +72,7 @@ class VariantReplacementService ->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(); + $remainingDateIds = $this->usableDateIds($variant); if ($remainingDateIds->isEmpty()) { $variant->update(['sales_disabled_at' => now()]); @@ -85,22 +80,75 @@ class VariantReplacementService continue; } - $replacement = $this->findEquivalent($variant, $remainingDateIds); - if ($replacement === null) { - $replacement = $this->cloneWithDates($variant, $remainingDateIds); - } else { - $this->mergeInventoryInto($variant, $replacement); + $this->replaceWithUsableDates($variant, $remainingDateIds); + } + } + + public function restorePreviouslySuspendedVariants(): int + { + return DB::transaction(function (): int { + $variants = Variant::query() + ->whereNotNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['eventDates', 'eventDate', 'definitions', 'allAttachments']) + ->orderBy('id') + ->lockForUpdate() + ->get(); + $restored = 0; + + foreach ($variants as $variant) { + if (! $variant->selectedEventDates()->contains( + fn (EventDate $date): bool => $date->suspended_at !== null, + )) { + continue; + } + + $usableDateIds = $this->usableDateIds($variant); + if ($usableDateIds->isEmpty()) { + continue; + } + + $this->replaceWithUsableDates($variant, $usableDateIds); + $restored++; } - $variant->update([ - 'replaced_by_variant_id' => $replacement->getKey(), - 'sales_disabled_at' => now(), - ]); + return $restored; + }); + } - BundleComponent::query() - ->where('component_variant_id', $variant->getKey()) - ->update(['component_variant_id' => $replacement->getKey()]); + /** @return Collection */ + private function usableDateIds(Variant $variant): Collection + { + $resolver = app(EffectiveEventDateResolver::class); + + return $variant->selectedEventDates() + ->map(fn (EventDate $date): ?EventDate => $resolver->resolve($date)) + ->filter() + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values(); + } + + /** @param Collection $dateIds */ + private function replaceWithUsableDates(Variant $variant, Collection $dateIds): void + { + $replacement = $this->findEquivalent($variant, $dateIds); + if ($replacement === null) { + $replacement = $this->cloneWithDates($variant, $dateIds); + } else { + $this->mergeInventoryInto($variant, $replacement); } + + $variant->update([ + 'replaced_by_variant_id' => $replacement->getKey(), + 'sales_disabled_at' => $variant->sales_disabled_at ?? now(), + ]); + + BundleComponent::query() + ->where('component_variant_id', $variant->getKey()) + ->update(['component_variant_id' => $replacement->getKey()]); } /** @param Collection $eventDateIds */ diff --git a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php index 41e414f..4442bd3 100644 --- a/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php +++ b/app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php @@ -12,7 +12,12 @@ class EntryResource extends JsonResource /** @return array */ public function toArray(Request $request): array { - $variant = $this->variants->sole(); + $variant = $this->variants + ->filter(fn ($candidate): bool => $candidate->sales_disabled_at === null + && $candidate->replaced_by_variant_id === null) + ->sortByDesc('id') + ->first() + ?? $this->variants->sortByDesc('id')->firstOrFail(); return [ 'id' => $this->id, diff --git a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php index 0c44363..97e6999 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/EntryService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/EntryService.php @@ -97,6 +97,8 @@ class EntryService $variants = Variant::query() ->where('catalog_item_id', $catalogItem->id) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') ->lockForUpdate() ->get(); diff --git a/database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php b/database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php new file mode 100644 index 0000000..86b903d --- /dev/null +++ b/database/migrations/2026_09_16_000000_restore_variants_with_remaining_event_dates.php @@ -0,0 +1,17 @@ +restorePreviouslySuspendedVariants(); + } + + public function down(): void + { + // Historical variants and their purchases must remain intact. + } +}; diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 72e27ee..ef5b469 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -11,6 +11,7 @@ use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservationLine; 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\Purchase\Services\Checkout\CatalogSelectionResolver; @@ -575,6 +576,36 @@ class AdminAppEventControllerTest extends TestCase $this->assertFalse($ticket->fresh()->resolvedValidity()->isResolvable); } + public function test_previous_suspensions_restore_variants_with_usable_dates(): void + { + $tenant = $this->createTenant('acme'); + $suspendedDate = $tenant->eventDates()->create([ + 'date' => '2027-10-09', + 'time_start' => '09:00', + 'time_end' => '18:30', + 'suspended_at' => now(), + ]); + $usableDate = $tenant->eventDates()->create([ + 'date' => '2027-10-10', + 'time_start' => '09:00', + 'time_end' => '18:30', + ]); + $variant = $this->createVariant($tenant); + $variant->eventDates()->sync([$suspendedDate->id, $usableDate->id]); + $variant->inventory->update(['real_stock' => 5]); + $variant->update(['sales_disabled_at' => now()]); + + $service = app(VariantReplacementService::class); + $this->assertSame(1, $service->restorePreviouslySuspendedVariants()); + $this->assertSame(0, $service->restorePreviouslySuspendedVariants()); + + $replacement = $variant->fresh()->replacement; + $this->assertNotNull($replacement); + $this->assertSame([$usableDate->id], $replacement->selectedEventDates()->pluck('id')->all()); + $this->assertSame(5, $replacement->inventory->availableStock()); + $this->assertTrue(CatalogItem::query()->whereKey($variant->catalog_item_id)->whereAvailable()->exists()); + } + public function test_update_and_date_creation_validate_their_own_payloads(): void { $tenant = $this->createTenant('acme');