feat(event): add effective date resolution for rescheduled events and update tests

This commit is contained in:
2026-09-16 14:27:35 -03:00
parent 9aa0c1f3b9
commit f8a3296821
7 changed files with 96 additions and 25 deletions

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Enums\EventDateStatus; use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Services\EffectiveEventDateResolver;
use App\Domains\Event\Services\EventDateTextFormatter; use App\Domains\Event\Services\EventDateTextFormatter;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Enums\ValidityTimeType;
@@ -80,6 +81,11 @@ class EventDate extends Model
return $this->belongsTo(self::class, 'rescheduled_to_event_date_id'); return $this->belongsTo(self::class, 'rescheduled_to_event_date_id');
} }
public function effectiveDate(): ?self
{
return app(EffectiveEventDateResolver::class)->resolve($this);
}
/** @return HasMany<EventDate, $this> */ /** @return HasMany<EventDate, $this> */
public function rescheduledFrom(): HasMany public function rescheduledFrom(): HasMany
{ {

View File

@@ -297,7 +297,7 @@ class NotificationMailService
->forTenant($tenantCode) ->forTenant($tenantCode)
->send( ->send(
$recipient, $recipient,
"Tu evento fue reprogramado - Compra #{$purchase->getKey()}", "Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}",
view('mail.notifications.event-date-rescheduled', compact( view('mail.notifications.event-date-rescheduled', compact(
'purchase', 'previousDate', 'newDate', 'tickets' 'purchase', 'previousDate', 'newDate', 'tickets'
))->render(), ))->render(),

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Ticket\Services; namespace App\Domains\Ticket\Services;
use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -11,12 +12,12 @@ class AdminAppTicketRowService
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
private const CATEGORY_PRESENTATIONS = [ private const CATEGORY_PRESENTATIONS = [
'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], 'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null],
'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null], 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null],
'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'date' => null, 'size' => null], 'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'size' => null],
'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], 'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null],
'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null], 'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null],
'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'date' => null, 'size' => 'talle'], 'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'size' => 'talle'],
]; ];
/** @return array<string, mixed> */ /** @return array<string, mixed> */
@@ -111,13 +112,14 @@ class AdminAppTicketRowService
private function presentation(Ticket $ticket, array $details): array private function presentation(Ticket $ticket, array $details): array
{ {
$sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-'; $sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-';
$effectiveDates = $this->effectiveEventDateLabels($ticket) ?: '-';
if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) { if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) {
return [ return [
'category' => $sourceCategory, 'category' => $sourceCategory,
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
'type' => $this->allPropertyLabels($details) ?: '-', 'type' => $this->allPropertyLabels($details) ?: '-',
'date' => '-', 'date' => $effectiveDates,
'size' => '-', 'size' => '-',
]; ];
} }
@@ -128,7 +130,7 @@ class AdminAppTicketRowService
'category' => $sourceCategory, 'category' => $sourceCategory,
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
'type' => $this->allPropertyLabels($details) ?: '-', 'type' => $this->allPropertyLabels($details) ?: '-',
'date' => '-', 'date' => $effectiveDates,
'size' => '-', 'size' => '-',
]; ];
} }
@@ -141,29 +143,30 @@ class AdminAppTicketRowService
'type' => $configuration['type'] === null 'type' => $configuration['type'] === null
? '-' ? '-'
: ($this->propertyLabels($details, $configuration['type']) ?: '-'), : ($this->propertyLabels($details, $configuration['type']) ?: '-'),
'date' => $configuration['date'] === null 'date' => $effectiveDates,
? '-'
: ($this->propertyLabels($details, $configuration['date']) ?: '-'),
'size' => $configuration['size'] === null 'size' => $configuration['size'] === null
? '-' ? '-'
: ($this->propertyLabels($details, $configuration['size']) ?: '-'), : ($this->propertyLabels($details, $configuration['size']) ?: '-'),
]; ];
} }
private function effectiveEventDateLabels(Ticket $ticket): string
{
return $ticket->sourceVariant?->selectedEventDates()
->map(fn (EventDate $date): ?EventDate => $date->effectiveDate())
->filter()
->unique(fn (EventDate $date): int => $date->getKey())
->sortBy(fn (EventDate $date): string => $date->date->format('Y-m-d'))
->map(fn (EventDate $date): string => $date->date->format('d/m'))
->implode(', ') ?? '';
}
/** @param array<string, mixed> $details */ /** @param array<string, mixed> $details */
private function propertyLabels(array $details, string $code): string private function propertyLabels(array $details, string $code): string
{ {
$property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code); $property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code);
$labels = collect($property['values'] ?? [])->pluck('label')->filter(); $labels = collect($property['values'] ?? [])->pluck('label')->filter();
if ($code === 'event_date') {
$labels = $labels->map(function (string $label): string {
[$day, $month] = array_pad(explode('/', $label), 2, null);
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
});
}
return $labels->implode(', '); return $labels->implode(', ');
} }

View File

@@ -8,7 +8,7 @@
<p><strong>Tickets afectados</strong></p> <p><strong>Tickets afectados</strong></p>
<ul> <ul>
@foreach ($tickets as $ticket) @foreach ($tickets as $ticket)
<li>{{ $ticket->name }} · #{{ $ticket->id }}</li> <li>{{ $ticket->name }} · de Ticket #{{ $ticket->id }}</li>
@endforeach @endforeach
</ul> </ul>
<p style="color: #64748b; font-size: 13px;">Compra #{{ $purchase->id }}</p> <p style="color: #64748b; font-size: 13px;"> de Orden #{{ $purchase->id }}</p>

View File

@@ -352,11 +352,12 @@ class NotificationMailServiceTest extends TestCase
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $firstTicket, $secondTicket): bool { Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $firstTicket, $secondTicket): bool {
$mail->assertTo('checkout@example.com'); $mail->assertTo('checkout@example.com');
return $mail->subject === "Tu evento fue reprogramado - Compra #{$purchase->id}" return $mail->subject === "Tu evento fue reprogramado - N° de Orden #{$purchase->id}"
&& str_contains($mail->render(), '09/10/2027') && str_contains($mail->render(), '09/10/2027')
&& str_contains($mail->render(), '20/10/2027') && str_contains($mail->render(), '20/10/2027')
&& str_contains($mail->render(), '#'.$firstTicket->id) && str_contains($mail->render(), 'N° de Ticket #'.$firstTicket->id)
&& str_contains($mail->render(), '#'.$secondTicket->id); && str_contains($mail->render(), 'N° de Ticket #'.$secondTicket->id)
&& str_contains($mail->render(), 'N° de Orden #'.$purchase->id);
}); });
$this->assertDatabaseHas('email_deliveries', [ $this->assertDatabaseHas('email_deliveries', [
'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}", 'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}",

View File

@@ -835,6 +835,50 @@ class AdminAppTicketControllerTest extends TestCase
->assertJsonPath('total_tickets', 1); ->assertJsonPath('total_tickets', 1);
} }
public function test_it_shows_the_effective_date_after_multiple_reschedules_and_suspension(): void
{
$tenant = $this->createTenant('fiesta_futbol_infantil');
$admin = $this->createAdminAppUser($tenant);
$this->grantTicketsMenu($tenant);
$this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]);
Sanctum::actingAs($admin);
$entry = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', 'abono')->firstOrFail();
$variant = $entry->variants()->whereHas('eventDates')->firstOrFail();
$original = $variant->selectedEventDates()->firstOrFail();
$middle = $tenant->eventDates()->create([
'date' => '2026-11-01', 'time_start' => '09:00', 'time_end' => '18:00',
]);
$latest = $tenant->eventDates()->create([
'date' => '2026-11-02', 'time_start' => '09:00', 'time_end' => '18:00',
]);
$original->update(['rescheduled_to_event_date_id' => $middle->id]);
$middle->update(['rescheduled_to_event_date_id' => $latest->id]);
$ticket = $this->createTicket($tenant, $admin, [
'source_catalog_item_id' => $entry->id,
'source_variant_id' => $variant->id,
]);
$this->getJson('/api/v1/adminapp/tenant/tickets')
->assertOk()
->assertJsonPath('data.0.id', $ticket->id)
->assertJsonPath('data.0.values.date', '10/10, 11/10, 12/10, 02/11');
$latest->update(['suspended_at' => now()]);
$this->getJson('/api/v1/adminapp/tenant/tickets')
->assertOk()
->assertJsonPath('data.0.values.date', '10/10, 11/10, 12/10');
foreach ($variant->selectedEventDates()->skip(1) as $date) {
$date->update(['suspended_at' => now()]);
}
$this->getJson('/api/v1/adminapp/tenant/tickets')
->assertOk()
->assertJsonPath('data.0.values.date', '-');
}
public function test_it_exposes_and_filters_merchandise_color_and_size(): void public function test_it_exposes_and_filters_merchandise_color_and_size(): void
{ {
$tenant = $this->createTenant('fiesta_futbol_infantil'); $tenant = $this->createTenant('fiesta_futbol_infantil');

View File

@@ -89,4 +89,21 @@ class EventModelsTest extends TestCase
$this->assertInstanceOf(EventDate::class, $tenant->eventDates()->getRelated()); $this->assertInstanceOf(EventDate::class, $tenant->eventDates()->getRelated());
} }
public function test_effective_date_follows_all_replacements_and_returns_null_when_suspended(): void
{
$original = new EventDate;
$original->setRawAttributes(['id' => 1, 'rescheduled_to_event_date_id' => 2]);
$middle = new EventDate;
$middle->setRawAttributes(['id' => 2, 'rescheduled_to_event_date_id' => 3]);
$latest = new EventDate;
$latest->setRawAttributes(['id' => 3, 'date' => '2026-11-02']);
$original->setRelation('rescheduledTo', $middle);
$middle->setRelation('rescheduledTo', $latest);
$this->assertSame($latest, $original->effectiveDate());
$latest->suspended_at = '2026-11-01 12:00:00';
$this->assertNull($original->effectiveDate());
}
} }