feat(event): implement EventDateGroupingService to group historical dates and update related resources and tests
This commit is contained in:
@@ -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')
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
101
app/Domains/Event/Services/EventDateGroupingService.php
Normal file
101
app/Domains/Event/Services/EventDateGroupingService.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventDateGroupingService
|
||||
{
|
||||
/**
|
||||
* Groups every historical date under its final active destination.
|
||||
*
|
||||
* @param Collection<int, EventDate> $dates
|
||||
* @return Collection<int, EventDate>
|
||||
*/
|
||||
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<int, EventDate> $rescheduled */
|
||||
$rescheduled = $group['rescheduled'];
|
||||
$destination->setRelation(
|
||||
'adminRescheduledDates',
|
||||
new EloquentCollection($rescheduled->sort($this->dateSorter())->values()->all()),
|
||||
);
|
||||
|
||||
return $destination;
|
||||
})
|
||||
->sort($this->dateSorter())
|
||||
->values();
|
||||
}
|
||||
|
||||
/** @param Collection<int, EventDate> $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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<int, EventDateChange> $changes
|
||||
* @return Collection<int, EventDateChange>
|
||||
*/
|
||||
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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
78
tests/Unit/Event/EventDateGroupingServiceTest.php
Normal file
78
tests/Unit/Event/EventDateGroupingServiceTest.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Event;
|
||||
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Event\Resources\EventResource;
|
||||
use App\Domains\Event\Services\EventDateGroupingService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventDateGroupingServiceTest extends TestCase
|
||||
{
|
||||
public function test_it_groups_chains_under_the_final_destination_and_orders_by_active_date(): void
|
||||
{
|
||||
$source13 = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user