refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use App\Domains\Ticket\Services\TicketValidityResolver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AffectedEventDatePurchaseResolver
|
||||
{
|
||||
/**
|
||||
* Finds active tickets belonging to paid purchases before an event-date mutation.
|
||||
*
|
||||
* @param Collection<int, int>|list<int> $eventDateIds
|
||||
* @return list<array{purchase_id: int, ticket_ids: list<int>}>
|
||||
*/
|
||||
public function resolve(Tenant $tenant, Collection|array $eventDateIds): array
|
||||
{
|
||||
$eventDateIds = collect($eventDateIds)->map(fn (mixed $id): int => (int) $id)->unique()->values();
|
||||
|
||||
if ($eventDateIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var Collection<int, Ticket> $tickets */
|
||||
$tickets = Ticket::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereHas('sourcePurchaseItem.purchase', fn (Builder $query) => $query
|
||||
->where('status', Purchase::STATUS_PAID))
|
||||
->whereHas('sourceVariant', function (Builder $query) use ($eventDateIds): void {
|
||||
$query->whereIn('event_date_id', $eventDateIds)
|
||||
->orWhereHas('eventDates', fn (Builder $eventDates) => $eventDates
|
||||
->whereIn('event_dates.id', $eventDateIds));
|
||||
})
|
||||
->with([
|
||||
...TicketValidityResolver::RELATIONS,
|
||||
'sourcePurchaseItem.purchase',
|
||||
])
|
||||
->get()
|
||||
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
|
||||
->values();
|
||||
|
||||
return $tickets
|
||||
->groupBy(fn (Ticket $ticket): int => (int) $ticket->sourcePurchaseItem->purchase->getKey())
|
||||
->map(function (Collection $purchaseTickets): array {
|
||||
return [
|
||||
'purchase_id' => (int) $purchaseTickets->first()->sourcePurchaseItem->purchase->getKey(),
|
||||
'ticket_ids' => $purchaseTickets->modelKeys(),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
|
||||
class EffectiveEventDateResolver
|
||||
{
|
||||
public function resolve(EventDate $eventDate): ?EventDate
|
||||
{
|
||||
$date = $this->resolveLatest($eventDate);
|
||||
|
||||
return $date !== null && $date->suspended_at === null ? $date : null;
|
||||
}
|
||||
|
||||
/** Sigue las reprogramaciones para presentación, incluso si el destino está suspendido. */
|
||||
public function resolveLatest(EventDate $eventDate): ?EventDate
|
||||
{
|
||||
$current = $eventDate;
|
||||
$visited = [];
|
||||
|
||||
while (true) {
|
||||
$identity = $current->getKey() === null
|
||||
? 'object:'.spl_object_id($current)
|
||||
: 'key:'.$current->getKey();
|
||||
|
||||
if (isset($visited[$identity])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$visited[$identity] = true;
|
||||
|
||||
if ($current->rescheduled_to_event_date_id === null) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
$current->loadMissing('rescheduledTo');
|
||||
$current = $current->rescheduledTo;
|
||||
|
||||
if ($current === null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Event\Enums\EventDateChangeType;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Event\Models\EventDateChange;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventDateInfoFormatter
|
||||
{
|
||||
/** @param Collection<int, EventDateChange> $changes */
|
||||
public function format(EventDate $eventDate, Collection $changes): ?string
|
||||
{
|
||||
$messages = collect();
|
||||
|
||||
if ($eventDate->suspended_at !== null) {
|
||||
$messages->push('Esta fecha fue cancelada.');
|
||||
}
|
||||
|
||||
$sourceDates = $this->reschedulesEndingAt($eventDate, $changes)
|
||||
->pluck('previous_date')
|
||||
->filter()
|
||||
->map(fn ($date): string => $date->format('d/m/Y'))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($sourceDates->isNotEmpty()) {
|
||||
$verb = $sourceDates->count() === 1 ? 'se reprogramó' : 'se reprogramaron';
|
||||
$messages->push("{$sourceDates->join(', ', ' y ')} {$verb} para este día.");
|
||||
}
|
||||
|
||||
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]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Event\Enums\EventDateChangeType;
|
||||
use App\Domains\Event\Models\EventDateChange;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventDateNoticeFormatter
|
||||
{
|
||||
public function __construct(private readonly EventDateTextFormatter $dateTextFormatter) {}
|
||||
|
||||
/**
|
||||
* @param Collection<int, EventDateChange> $changes
|
||||
* @return list<array{
|
||||
* type: string,
|
||||
* change_ids: list<int>,
|
||||
* title: string,
|
||||
* message: list<array{text: string, bold: bool}>
|
||||
* }>
|
||||
*/
|
||||
public function format(Collection $changes): array
|
||||
{
|
||||
return collect([
|
||||
$this->suspensionNotice(
|
||||
$changes->where('change_type', EventDateChangeType::Suspended)
|
||||
),
|
||||
$this->rescheduleNotice(
|
||||
$changes->where('change_type', EventDateChangeType::Rescheduled)
|
||||
),
|
||||
])->filter()->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, EventDateChange> $changes
|
||||
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
|
||||
*/
|
||||
private function suspensionNotice(Collection $changes): ?array
|
||||
{
|
||||
$dates = $this->formatDates($changes, 'previous_date');
|
||||
|
||||
if ($dates === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plural = $changes->count() > 1;
|
||||
|
||||
return [
|
||||
'type' => EventDateChangeType::Suspended->value,
|
||||
'change_ids' => $this->changeIds($changes),
|
||||
'title' => $plural ? 'FECHAS CANCELADAS!' : 'FECHA CANCELADA!',
|
||||
'message' => [
|
||||
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
|
||||
['text' => $dates, 'bold' => true],
|
||||
['text' => $plural ? ' han sido canceladas.' : ' ha sido cancelada.', 'bold' => false],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, EventDateChange> $changes
|
||||
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
|
||||
*/
|
||||
private function rescheduleNotice(Collection $changes): ?array
|
||||
{
|
||||
$changes = $changes->whereNotNull('new_date');
|
||||
$sourceDates = $this->formatDates($changes, 'previous_date');
|
||||
$destinationDates = $this->formatDates($changes, 'new_date');
|
||||
|
||||
if ($sourceDates === null || $destinationDates === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plural = $changes->count() > 1;
|
||||
$message = [
|
||||
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
|
||||
['text' => $sourceDates, 'bold' => true],
|
||||
[
|
||||
'text' => $plural ? ' han sido reprogramadas para el ' : ' ha sido reprogramada para el ',
|
||||
'bold' => false,
|
||||
],
|
||||
['text' => $destinationDates, 'bold' => true],
|
||||
];
|
||||
|
||||
if ($plural) {
|
||||
$message[] = ['text' => ', ', 'bold' => false];
|
||||
$message[] = ['text' => 'respectivamente', 'bold' => true];
|
||||
}
|
||||
|
||||
$message[] = ['text' => '.', 'bold' => false];
|
||||
|
||||
return [
|
||||
'type' => EventDateChangeType::Rescheduled->value,
|
||||
'change_ids' => $this->changeIds($changes),
|
||||
'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!',
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, EventDateChange> $changes
|
||||
*/
|
||||
private function formatDates(Collection $changes, string $attribute): ?string
|
||||
{
|
||||
return $this->dateTextFormatter->formatForSentence(
|
||||
$changes
|
||||
->pluck($attribute)
|
||||
->filter()
|
||||
->map(fn ($date): string => $date->format('Y-m-d'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, EventDateChange> $changes
|
||||
* @return list<int>
|
||||
*/
|
||||
private function changeIds(Collection $changes): array
|
||||
{
|
||||
return $changes
|
||||
->pluck('id')
|
||||
->filter(fn ($id): bool => $id !== null)
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Event\Models\EventDateChange;
|
||||
use App\Domains\Event\Models\EventDateChangeView;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EventDateNoticeService
|
||||
{
|
||||
public const MAX_DISPLAYS = 3;
|
||||
|
||||
public function __construct(private readonly EventDateNoticeFormatter $formatter) {}
|
||||
|
||||
/**
|
||||
* Claims one display of every pending change and returns them grouped by type.
|
||||
*
|
||||
* @return list<array{
|
||||
* type: string,
|
||||
* change_ids: list<int>,
|
||||
* title: string,
|
||||
* message: list<array{text: string, bold: bool}>
|
||||
* }>
|
||||
*/
|
||||
public function claimFor(User $user, Tenant $tenant): array
|
||||
{
|
||||
return DB::transaction(function () use ($user, $tenant): array {
|
||||
$lockedUser = User::query()->whereKey($user->getKey())->lockForUpdate()->firstOrFail();
|
||||
|
||||
$changes = EventDateChange::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereDoesntHave('views', fn ($query) => $query
|
||||
->where('user_id', $lockedUser->getKey())
|
||||
->where('display_count', '>=', self::MAX_DISPLAYS))
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$notices = $this->formatter->format($changes);
|
||||
$claimedChangeIds = collect($notices)->pluck('change_ids')->flatten()->unique();
|
||||
|
||||
foreach ($claimedChangeIds as $changeId) {
|
||||
$view = EventDateChangeView::query()->firstOrNew([
|
||||
'user_id' => $lockedUser->getKey(),
|
||||
'event_date_change_id' => $changeId,
|
||||
]);
|
||||
$view->display_count = min(
|
||||
self::MAX_DISPLAYS,
|
||||
((int) $view->display_count) + 1,
|
||||
);
|
||||
$view->last_displayed_at = now();
|
||||
$view->save();
|
||||
}
|
||||
|
||||
return $notices;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
class EventDateTextFormatter
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private const MONTHS = [
|
||||
1 => 'Enero',
|
||||
2 => 'Febrero',
|
||||
3 => 'Marzo',
|
||||
4 => 'Abril',
|
||||
5 => 'Mayo',
|
||||
6 => 'Junio',
|
||||
7 => 'Julio',
|
||||
8 => 'Agosto',
|
||||
9 => 'Septiembre',
|
||||
10 => 'Octubre',
|
||||
11 => 'Noviembre',
|
||||
12 => 'Diciembre',
|
||||
];
|
||||
|
||||
/** @param iterable<string> $dates */
|
||||
public function format(iterable $dates): ?string
|
||||
{
|
||||
return $this->formatWithOptions($dates, false, false);
|
||||
}
|
||||
|
||||
/** @param iterable<string> $dates */
|
||||
public function formatForSentence(iterable $dates): ?string
|
||||
{
|
||||
return $this->formatWithOptions($dates, true, true);
|
||||
}
|
||||
|
||||
/** @param iterable<string> $dates */
|
||||
private function formatWithOptions(
|
||||
iterable $dates,
|
||||
bool $padDays,
|
||||
bool $includeYearPreposition,
|
||||
): ?string {
|
||||
$normalizedDates = collect($dates)
|
||||
->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date))
|
||||
->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
|
||||
->sortBy(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
|
||||
->values();
|
||||
|
||||
if ($normalizedDates->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$years = $normalizedDates
|
||||
->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y'))
|
||||
->map(function ($yearDates, string $year) use ($padDays, $includeYearPreposition): string {
|
||||
$months = $yearDates
|
||||
->groupBy(fn (DateTimeImmutable $date): string => $date->format('n'))
|
||||
->map(function ($monthDates, string $month) use ($padDays): string {
|
||||
$days = $monthDates
|
||||
->map(fn (DateTimeImmutable $date): string => $padDays
|
||||
? $date->format('d')
|
||||
: (string) ((int) $date->format('j')))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $this->join($days).' de '.self::MONTHS[(int) $month];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $this->join($months).($includeYearPreposition ? ' de ' : ' ').$year;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $this->join($years);
|
||||
}
|
||||
|
||||
/** @param array<int, string> $parts */
|
||||
private function join(array $parts): string
|
||||
{
|
||||
if (count($parts) <= 1) {
|
||||
return $parts[0] ?? '';
|
||||
}
|
||||
|
||||
$last = array_pop($parts);
|
||||
|
||||
return implode(', ', $parts).' y '.$last;
|
||||
}
|
||||
}
|
||||
350
app/Domains/Ticketing/Event/Services/EventService.php
Normal file
350
app/Domains/Ticketing/Event/Services/EventService.php
Normal file
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Services\InvalidateEventDateCartsService;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Catalog\Services\VariantReplacementService;
|
||||
use App\Domains\Event\Enums\EventDateChangeType;
|
||||
use App\Domains\Event\Events\EventDateRescheduled;
|
||||
use App\Domains\Event\Events\EventDateSuspended;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Event\Models\EventDateChange;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventService
|
||||
{
|
||||
private const CONTACT_CODES = [
|
||||
'whatsapp_url' => 'whatsapp',
|
||||
'instagram_url' => 'instagram',
|
||||
'facebook_url' => 'facebook',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
|
||||
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
|
||||
private readonly VariantReplacementService $variantReplacementService,
|
||||
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
|
||||
) {}
|
||||
|
||||
public function forTenant(Tenant $tenant): Tenant
|
||||
{
|
||||
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function updateForTenant(Tenant $tenant, array $data): Tenant
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data): Tenant {
|
||||
$tenant = Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
||||
$tenant->update([
|
||||
'event_title' => $data['title'],
|
||||
'event_location' => $data['location'],
|
||||
...array_intersect_key($data, array_flip([
|
||||
'allow_ticket_refund',
|
||||
'allow_ticket_total_refund',
|
||||
'allow_ticket_partial_refund',
|
||||
'ticket_partial_refund_percentage',
|
||||
])),
|
||||
]);
|
||||
|
||||
if (array_key_exists('social_media', $data)) {
|
||||
$this->syncSocialMedia($tenant, $data['social_media']);
|
||||
} else {
|
||||
$this->syncLegacyContact($tenant, $data['contact']);
|
||||
}
|
||||
|
||||
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array{date: string, start_time: string, end_time: string} $data */
|
||||
public function createDateForTenant(Tenant $tenant, array $data): EventDate
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data): EventDate {
|
||||
$attributes = $this->dateAttributes($data);
|
||||
|
||||
if ($tenant->eventDates()->where($attributes)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'date' => ['La fecha y el horario ya existen.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $tenant->eventDates()->create($attributes)->load('validityTime');
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array{date: string} $data */
|
||||
public function rescheduleDateForTenant(
|
||||
Tenant $tenant,
|
||||
EventDate $eventDate,
|
||||
array $data,
|
||||
?User $createdBy = null,
|
||||
): EventDate {
|
||||
return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate {
|
||||
$source = $this->lockedDateForTenant($tenant, $eventDate);
|
||||
|
||||
if ($source->suspended_at !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'event_date' => ['No se puede reprogramar una fecha suspendida.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($source->rescheduled_to_event_date_id !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'event_date' => ['La fecha ya fue reprogramada.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$destination = $tenant->eventDates()
|
||||
->whereDate('date', $data['date'])
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($destination === null) {
|
||||
$destination = $tenant->eventDates()->create([
|
||||
'date' => $data['date'],
|
||||
'time_start' => $source->time_start,
|
||||
'time_end' => $source->time_end,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($destination->is($source) || $this->chainContains($destination, $source)) {
|
||||
throw ValidationException::withMessages([
|
||||
'date' => ['La reprogramación generaría una referencia circular.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$effectiveDestination = $this->effectiveEventDateResolver->resolve($destination);
|
||||
if ($effectiveDestination === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'date' => ['La fecha de destino no es utilizable.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$affectedDateIds = $this->affectedDateIds($tenant, $source);
|
||||
$this->invalidateEventDateCarts->invalidate($tenant, $affectedDateIds);
|
||||
$purchaseTickets = $this->affectedPurchaseResolver->resolve(
|
||||
$tenant,
|
||||
$affectedDateIds,
|
||||
);
|
||||
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
|
||||
$this->variantReplacementService->replaceEventDate($source, $effectiveDestination);
|
||||
|
||||
EventDateChange::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'change_type' => EventDateChangeType::Rescheduled,
|
||||
'source_event_date_id' => $source->getKey(),
|
||||
'destination_event_date_id' => $destination->getKey(),
|
||||
'created_by_user_id' => $createdBy?->getKey(),
|
||||
'previous_date' => $source->date->format('Y-m-d'),
|
||||
'new_date' => $destination->date->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
EventDateRescheduled::dispatch(
|
||||
$tenant->codigo,
|
||||
$source->getKey(),
|
||||
$destination->getKey(),
|
||||
$source->date->format('d/m/Y'),
|
||||
$destination->date->format('d/m/Y'),
|
||||
$purchaseTickets,
|
||||
);
|
||||
|
||||
return $source->fresh(['validityTime', 'rescheduledTo.validityTime']);
|
||||
});
|
||||
}
|
||||
|
||||
public function suspendDateForTenant(
|
||||
Tenant $tenant,
|
||||
EventDate $eventDate,
|
||||
?User $createdBy = null,
|
||||
): EventDate {
|
||||
return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate {
|
||||
$date = $this->lockedDateForTenant($tenant, $eventDate);
|
||||
|
||||
if ($date->rescheduled_to_event_date_id !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'event_date' => ['No se puede suspender una fecha que ya fue reprogramada.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($date->suspended_at !== null) {
|
||||
return $date->load('validityTime');
|
||||
}
|
||||
|
||||
$affectedDateIds = $this->affectedDateIds($tenant, $date);
|
||||
$this->invalidateEventDateCarts->invalidate(
|
||||
$tenant,
|
||||
$affectedDateIds,
|
||||
StockReservationService::REASON_EVENT_DATE_SUSPENDED,
|
||||
);
|
||||
$purchaseTickets = $this->affectedPurchaseResolver->resolve($tenant, $affectedDateIds);
|
||||
$date->update(['suspended_at' => now()]);
|
||||
$this->variantReplacementService->disableForSuspension($date);
|
||||
$this->disableTicketsWithoutUsableDates($tenant, $date);
|
||||
|
||||
EventDateChange::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'change_type' => EventDateChangeType::Suspended,
|
||||
'source_event_date_id' => $date->getKey(),
|
||||
'destination_event_date_id' => null,
|
||||
'created_by_user_id' => $createdBy?->getKey(),
|
||||
'previous_date' => $date->date->format('Y-m-d'),
|
||||
'new_date' => null,
|
||||
]);
|
||||
|
||||
EventDateSuspended::dispatch(
|
||||
$tenant->codigo,
|
||||
$date->getKey(),
|
||||
$date->date->format('d/m/Y'),
|
||||
$purchaseTickets,
|
||||
);
|
||||
|
||||
return $date->fresh('validityTime');
|
||||
});
|
||||
}
|
||||
|
||||
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate
|
||||
{
|
||||
return $tenant->eventDates()
|
||||
->whereKey($eventDate->getKey())
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function chainContains(EventDate $start, EventDate $expected): bool
|
||||
{
|
||||
$current = $start;
|
||||
$visited = [];
|
||||
|
||||
while ($current->rescheduled_to_event_date_id !== null) {
|
||||
if ($current->is($expected)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($visited[$current->getKey()])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$visited[$current->getKey()] = true;
|
||||
$current = $current->rescheduledTo()->lockForUpdate()->first();
|
||||
|
||||
if ($current === null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $current->is($expected);
|
||||
}
|
||||
|
||||
/** @return Collection<int, int> */
|
||||
private function affectedDateIds(Tenant $tenant, EventDate $eventDate): Collection
|
||||
{
|
||||
$affectedDateIds = collect([$eventDate->getKey()]);
|
||||
$frontier = $affectedDateIds;
|
||||
|
||||
while ($frontier->isNotEmpty()) {
|
||||
$predecessors = $tenant->eventDates()
|
||||
->whereIn('rescheduled_to_event_date_id', $frontier)
|
||||
->pluck('id')
|
||||
->diff($affectedDateIds)
|
||||
->values();
|
||||
$affectedDateIds = $affectedDateIds->merge($predecessors)->unique()->values();
|
||||
$frontier = $predecessors;
|
||||
}
|
||||
|
||||
return $affectedDateIds;
|
||||
}
|
||||
|
||||
private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void
|
||||
{
|
||||
$affectedDateIds = $this->affectedDateIds($tenant, $suspendedDate);
|
||||
|
||||
$variants = Variant::withTrashed()
|
||||
->where(function ($query) use ($affectedDateIds): void {
|
||||
$query->whereIn('event_date_id', $affectedDateIds)
|
||||
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
|
||||
->whereIn('event_dates.id', $affectedDateIds));
|
||||
})
|
||||
->with(['eventDates', 'eventDate'])
|
||||
->get();
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
$hasUsableDate = $variant->selectedEventDates()->contains(
|
||||
fn (EventDate $candidate): bool => $this->effectiveEventDateResolver->resolve($candidate) !== null
|
||||
);
|
||||
|
||||
if ($hasUsableDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Ticket::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('source_variant_id', $variant->getKey())
|
||||
->whereNull('disabled_at')
|
||||
->whereNull('cancelled_at')
|
||||
->whereNull('refunded_at')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->each(function (Ticket $ticket): void {
|
||||
$ticket->markAsDisabled();
|
||||
$ticket->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{date: string, start_time: string, end_time: string} $data
|
||||
* @return array{date: string, time_start: string, time_end: string}
|
||||
*/
|
||||
private function dateAttributes(array $data): array
|
||||
{
|
||||
return [
|
||||
'date' => $data['date'],
|
||||
'time_start' => $data['start_time'].':00',
|
||||
'time_end' => $data['end_time'].':00',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, string|null> $contact */
|
||||
private function syncLegacyContact(Tenant $tenant, array $contact): void
|
||||
{
|
||||
foreach (self::CONTACT_CODES as $field => $code) {
|
||||
$url = $contact[$field] ?? null;
|
||||
|
||||
if ($url === null || $url === '') {
|
||||
$tenant->socialMedia()->detach($code);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenant->socialMedia()->syncWithoutDetaching([
|
||||
$code => ['url' => $url],
|
||||
]);
|
||||
}
|
||||
|
||||
$tenant->unsetRelation('socialMedia');
|
||||
}
|
||||
|
||||
/** @param array<int, array{code: string, url: string, orden?: int}> $socialMedia */
|
||||
private function syncSocialMedia(Tenant $tenant, array $socialMedia): void
|
||||
{
|
||||
$associations = [];
|
||||
|
||||
foreach (array_values($socialMedia) as $index => $item) {
|
||||
$associations[$item['code']] = [
|
||||
'url' => $item['url'],
|
||||
'orden' => $item['orden'] ?? $index,
|
||||
];
|
||||
}
|
||||
|
||||
$tenant->socialMedia()->sync($associations);
|
||||
$tenant->unsetRelation('socialMedia');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user