diff --git a/app/Domains/Event/Resources/EventDateResource.php b/app/Domains/Event/Resources/EventDateResource.php index 0c15cca..6ebba0b 100644 --- a/app/Domains/Event/Resources/EventDateResource.php +++ b/app/Domains/Event/Resources/EventDateResource.php @@ -23,6 +23,9 @@ class EventDateResource extends JsonResource 'status' => $this->status->value, 'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id, 'suspended_at' => $this->suspended_at?->toISOString(), + 'rescheduled_dates' => EventDateResource::collection( + $this->whenLoaded('adminRescheduledDates') + ), ]; } } diff --git a/app/Domains/Event/Resources/EventResource.php b/app/Domains/Event/Resources/EventResource.php index 207cf42..150dcc7 100644 --- a/app/Domains/Event/Resources/EventResource.php +++ b/app/Domains/Event/Resources/EventResource.php @@ -2,6 +2,7 @@ namespace App\Domains\Event\Resources; +use App\Domains\Event\Services\EventDateGroupingService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -22,7 +23,9 @@ class EventResource extends JsonResource 'allow_ticket_total_refund' => $this->allow_ticket_total_refund, 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund, 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage, - 'dates' => EventDateResource::collection($this->eventDates), + 'dates' => EventDateResource::collection( + app(EventDateGroupingService::class)->group($this->eventDates) + ), 'social_media' => $this->socialMedia->map(fn ($item): array => [ 'code' => $item->code, 'url' => $item->pivot->url, diff --git a/app/Domains/Event/Services/EventDateGroupingService.php b/app/Domains/Event/Services/EventDateGroupingService.php new file mode 100644 index 0000000..83dff7c --- /dev/null +++ b/app/Domains/Event/Services/EventDateGroupingService.php @@ -0,0 +1,101 @@ + $dates + * @return Collection + */ + public function group(Collection $dates): Collection + { + $byId = $dates->keyBy(fn (EventDate $date): int => (int) $date->getKey()); + $groups = collect(); + + foreach ($dates as $date) { + $destination = $this->finalDestination($date, $byId); + $key = (int) $destination->getKey(); + + if (! $groups->has($key)) { + $groups->put($key, [ + 'destination' => $destination, + 'rescheduled' => collect(), + ]); + } + + if (! $date->is($destination)) { + $group = $groups->get($key); + $historicalDate = clone $date; + $historicalDate->setAttribute( + 'rescheduled_to_event_date_id', + $destination->getKey(), + ); + $group['rescheduled']->push($historicalDate); + $groups->put($key, $group); + } + } + + return $groups + ->map(function (array $group): EventDate { + /** @var EventDate $destination */ + $destination = clone $group['destination']; + /** @var Collection $rescheduled */ + $rescheduled = $group['rescheduled']; + $destination->setRelation( + 'adminRescheduledDates', + new EloquentCollection($rescheduled->sort($this->dateSorter())->values()->all()), + ); + + return $destination; + }) + ->sort($this->dateSorter()) + ->values(); + } + + /** @param Collection $byId */ + private function finalDestination(EventDate $date, Collection $byId): EventDate + { + $current = $date; + $visited = collect(); + + while ($current->rescheduled_to_event_date_id !== null) { + $currentId = (int) $current->getKey(); + + if ($visited->contains($currentId)) { + break; + } + + $visited->push($currentId); + $destination = $byId->get((int) $current->rescheduled_to_event_date_id); + + if (! $destination instanceof EventDate) { + break; + } + + $current = $destination; + } + + return $current; + } + + /** @return callable(EventDate, EventDate): int */ + private function dateSorter(): callable + { + return fn (EventDate $left, EventDate $right): int => [ + $left->date->format('Y-m-d'), + $left->time_start, + $left->getKey(), + ] <=> [ + $right->date->format('Y-m-d'), + $right->time_start, + $right->getKey(), + ]; + } +} diff --git a/app/Domains/Event/Services/EventDateInfoFormatter.php b/app/Domains/Event/Services/EventDateInfoFormatter.php index 28b3d2a..9442d14 100644 --- a/app/Domains/Event/Services/EventDateInfoFormatter.php +++ b/app/Domains/Event/Services/EventDateInfoFormatter.php @@ -18,9 +18,7 @@ class EventDateInfoFormatter $messages->push('Esta fecha fue cancelada.'); } - $sourceDates = $changes - ->where('change_type', EventDateChangeType::Rescheduled) - ->where('destination_event_date_id', $eventDate->getKey()) + $sourceDates = $this->reschedulesEndingAt($eventDate, $changes) ->pluck('previous_date') ->filter() ->map(fn ($date): string => $date->format('d/m/Y')) @@ -34,4 +32,45 @@ class EventDateInfoFormatter return $messages->isEmpty() ? null : $messages->join(' '); } + + /** + * Includes direct and intermediate reschedules that ultimately end at the + * displayed event date, while preserving the original change order. + * + * @param Collection $changes + * @return Collection + */ + private function reschedulesEndingAt(EventDate $eventDate, Collection $changes): Collection + { + $eventDateId = $eventDate->getKey(); + $reschedules = $changes->where('change_type', EventDateChangeType::Rescheduled); + + if ($eventDateId === null) { + return $reschedules->where('destination_event_date_id', null); + } + + $destinationIds = [(int) $eventDateId => true]; + + do { + $foundAncestor = false; + + foreach ($reschedules as $change) { + $destinationId = $change->destination_event_date_id; + $sourceId = $change->source_event_date_id; + + if ($destinationId === null || $sourceId === null) { + continue; + } + + if (isset($destinationIds[(int) $destinationId]) && ! isset($destinationIds[(int) $sourceId])) { + $destinationIds[(int) $sourceId] = true; + $foundAncestor = true; + } + } + } while ($foundAncestor); + + return $reschedules + ->filter(fn (EventDateChange $change): bool => $change->destination_event_date_id !== null + && isset($destinationIds[(int) $change->destination_event_date_id])); + } } diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 5ffa50d..e5431e9 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -150,6 +150,52 @@ class AdminAppEventControllerTest extends TestCase ->assertJsonMissing(['title' => 'Other Event']); } + public function test_dates_are_grouped_by_their_final_destination_and_ordered_by_active_date(): void + { + $tenant = $this->createActiveEvent($this->createTenant('acme'), 'Acme Event'); + $source13 = $tenant->eventDates()->create([ + 'date' => '2026-10-13', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $source22 = $tenant->eventDates()->create([ + 'date' => '2026-10-22', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $middle24 = $tenant->eventDates()->create([ + 'date' => '2026-10-24', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $active25 = $tenant->eventDates()->create([ + 'date' => '2026-10-25', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $destination30 = $tenant->eventDates()->create([ + 'date' => '2026-10-30', 'time_start' => '00:00', 'time_end' => '23:59', + ]); + $source13->update(['rescheduled_to_event_date_id' => $destination30->id]); + $source22->update(['rescheduled_to_event_date_id' => $middle24->id]); + $middle24->update(['rescheduled_to_event_date_id' => $destination30->id]); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/event') + ->assertOk() + ->assertJsonCount(2, 'data.dates') + ->assertJsonPath('data.dates.0.id', $active25->id) + ->assertJsonCount(0, 'data.dates.0.rescheduled_dates') + ->assertJsonPath('data.dates.1.id', $destination30->id) + ->assertJsonPath('data.dates.1.rescheduled_dates.0.id', $source13->id) + ->assertJsonPath( + 'data.dates.1.rescheduled_dates.0.rescheduled_to_event_date_id', + $destination30->id, + ) + ->assertJsonPath('data.dates.1.rescheduled_dates.1.id', $source22->id) + ->assertJsonPath( + 'data.dates.1.rescheduled_dates.1.rescheduled_to_event_date_id', + $destination30->id, + ) + ->assertJsonPath('data.dates.1.rescheduled_dates.2.id', $middle24->id) + ->assertJsonPath( + 'data.dates.1.rescheduled_dates.2.rescheduled_to_event_date_id', + $destination30->id, + ); + } + public function test_reading_a_tenant_without_event_configuration_returns_empty_values(): void { $tenant = $this->createTenant('acme'); diff --git a/tests/Unit/Event/EventDateGroupingServiceTest.php b/tests/Unit/Event/EventDateGroupingServiceTest.php new file mode 100644 index 0000000..2662b4c --- /dev/null +++ b/tests/Unit/Event/EventDateGroupingServiceTest.php @@ -0,0 +1,78 @@ +date(1, '2026-10-13', 5); + $source22 = $this->date(2, '2026-10-22', 3); + $middle24 = $this->date(3, '2026-10-24', 5); + $active25 = $this->date(4, '2026-10-25'); + $destination30 = $this->date(5, '2026-10-30'); + + $grouped = (new EventDateGroupingService)->group(new Collection([ + $source13, + $source22, + $middle24, + $active25, + $destination30, + ])); + + $this->assertSame([4, 5], $grouped->map->getKey()->all()); + $this->assertSame( + [1, 2, 3], + $grouped->last()->getRelation('adminRescheduledDates')->modelKeys(), + ); + $this->assertSame( + [5, 5, 5], + $grouped->last()->getRelation('adminRescheduledDates') + ->pluck('rescheduled_to_event_date_id') + ->all(), + ); + + $tenant = new Tenant(['codigo' => 'acme']); + $tenant->id = 1; + $tenant->setRelation('eventDates', new Collection([ + $source13, + $source22, + $middle24, + $active25, + $destination30, + ])); + $tenant->setRelation('socialMedia', new Collection); + $payload = EventResource::make($tenant)->response()->getData(true)['data']; + + $this->assertCount(2, $payload['dates']); + $this->assertSame(4, $payload['dates'][0]['id']); + $this->assertSame([1, 2, 3], array_column($payload['dates'][1]['rescheduled_dates'], 'id')); + $this->assertSame( + [5, 5, 5], + array_column( + $payload['dates'][1]['rescheduled_dates'], + 'rescheduled_to_event_date_id', + ), + ); + } + + private function date(int $id, string $date, ?int $destinationId = null): EventDate + { + $eventDate = new EventDate([ + 'date' => $date, + 'time_start' => '00:00:00', + 'time_end' => '23:59:00', + 'rescheduled_to_event_date_id' => $destinationId, + ]); + $eventDate->id = $id; + + return $eventDate; + } +} diff --git a/tests/Unit/Event/EventDateInfoFormatterTest.php b/tests/Unit/Event/EventDateInfoFormatterTest.php index 40eb789..0742a73 100644 --- a/tests/Unit/Event/EventDateInfoFormatterTest.php +++ b/tests/Unit/Event/EventDateInfoFormatterTest.php @@ -36,6 +36,25 @@ class EventDateInfoFormatterTest extends TestCase ); } + public function test_it_includes_sources_that_reach_the_destination_through_intermediate_dates(): void + { + $date = new EventDate; + $date->id = 30; + + $changes = collect([ + $this->reschedule(13, 24, '2026-10-13'), + $this->reschedule(22, 24, '2026-10-22'), + $this->reschedule(24, 30, '2026-10-24'), + $this->reschedule(29, 30, '2026-10-29'), + $this->reschedule(10, 11, '2026-10-10'), + ]); + + $this->assertSame( + '13/10/2026, 22/10/2026, 24/10/2026 y 29/10/2026 se reprogramaron para este día.', + (new EventDateInfoFormatter)->format($date, $changes), + ); + } + private function reschedule(int $sourceId, int $destinationId, string $previousDate): EventDateChange { $change = new EventDateChange;