'datetime', 'end_at' => 'datetime', 'booking_start_at' => 'date', 'booking_end_at' => 'date', 'age_limit_start' => 'date', 'age_limit_end' => 'date', 'gallery_media' => 'array', 'tags' => 'array', 'sort' => 'integer', 'is_active' => 'boolean', 'total_quota' => 'integer', 'registered_count' => 'integer', 'last_approved_snapshot' => 'array', ]; public function scopeVisibleOnH5(Builder $query): Builder { return $query->where('is_active', true)->where('audit_status', self::AUDIT_APPROVED); } public function venues(): BelongsToMany { return $this->belongsToMany(Venue::class, 'ticket_grab_event_venue') ->withPivot('venue_total_quota', 'id') ->withTimestamps(); } public function eventVenuePivots(): HasMany { return $this->hasMany(TicketGrabEventVenue::class, 'ticket_grab_event_id'); } public function releaseDays(): HasMany { return $this->hasMany(TicketGrabVenueReleaseDay::class, 'ticket_grab_event_id'); } public function reservations(): HasMany { return $this->hasMany(Reservation::class, 'ticket_grab_event_id'); } public function scopeOrderByScheduleStatusPriority(Builder $query): Builder { $today = Carbon::now((string) config('app.timezone'))->toDateString(); return $query ->orderByRaw( 'CASE WHEN end_at IS NOT NULL AND DATE(end_at) < ? THEN 2 WHEN start_at IS NOT NULL AND DATE(start_at) > ? THEN 1 ELSE 0 END ASC', [$today, $today] ) ->orderByRaw('start_at IS NULL ASC') ->orderBy('start_at', 'asc') ->orderByDesc('id'); } public function scopeWhereComputedScheduleStatus(Builder $query, string $status): Builder { $tz = (string) config('app.timezone'); $today = Carbon::now($tz)->toDateString(); return match ($status) { 'not_started' => $query->whereNotNull('start_at')->whereDate('start_at', '>', $today), 'ended' => $query->whereNotNull('end_at')->whereDate('end_at', '<', $today), 'ongoing' => $query->where(function (Builder $q) use ($today) { $q->where(function (Builder $q2) use ($today) { $q2->whereNull('start_at')->orWhereDate('start_at', '<=', $today); })->where(function (Builder $q2) use ($today) { $q2->whereNull('end_at')->orWhereDate('end_at', '>=', $today); }); }), default => $query, }; } public static function computeScheduleStatusFromBounds(?Carbon $start, ?Carbon $end, ?string $tz = null): string { return Activity::computeScheduleStatusFromBounds($start, $end, $tz); } public static function refreshRegisteredCountFromReservations(int $eventId): void { $total = (int) Reservation::query() ->where('ticket_grab_event_id', $eventId) ->where('reservation_kind', 'ticket_grab') ->where('status', '!=', 'cancelled') ->get() ->sum(fn (Reservation $r) => max(1, (int) ($r->ticket_count ?? 1))); static::query()->where('id', $eventId)->update(['registered_count' => $total]); } /** * 与 H5 场馆页 bookingInfo 的 can_book_now 一致:在预约日窗口内、活动未结束、 * 处于每日放票时段内,且任一场馆「当日」放票行经滚入同步后仍有余量。 */ public function canGrabToday(): bool { $tz = (string) config('app.timezone'); $today = Carbon::now($tz)->toDateString(); if (! $this->booking_start_at || ! $this->booking_end_at) { return false; } if ($today < $this->booking_start_at->toDateString() || $today > $this->booking_end_at->toDateString()) { return false; } if ($this->end_at && $this->end_at->toDateString() < $today) { return false; } if (! self::isDailyReleaseTimeWindowOpen($this)) { return false; } TicketGrabReleaseDayService::syncCarryInChain($this->id); $venueIds = TicketGrabEventVenue::query() ->where('ticket_grab_event_id', $this->id) ->pluck('venue_id'); foreach ($venueIds as $vid) { $r = TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $this->id) ->where('venue_id', $vid) ->whereDate('release_date', $today) ->first(); if ($r && $r->remainingForDisplay() > 0) { return true; } } return false; } public static function isDailyReleaseTimeWindowOpen(self $e): bool { $start = (string) ($e->daily_release_start_time ?? '10:00'); $end = (string) ($e->daily_release_end_time ?? '23:59'); $now = now(); [$sh, $sm] = array_map('intval', explode(':', $start) + [0, 0]); [$eh, $em] = array_map('intval', explode(':', $end) + [0, 0]); $a = $now->copy()->setTime($sh, $sm, 0); $b = $now->copy()->setTime($eh, $em, 59); return $now->between($a, $b); } /** * JSON 输出中活动起止、预约、年龄限制等均为 Y-m-d,避免前端的 ISO+UTC 导致展示差一天。 */ public function toArray() { $a = parent::toArray(); if ($this->start_at) { $a['start_at'] = CalendarDateFormat::ymdFromDatetime($this->start_at); } if ($this->end_at) { $a['end_at'] = CalendarDateFormat::ymdFromDatetime($this->end_at); } if ($this->booking_start_at) { $a['booking_start_at'] = CalendarDateFormat::ymdFromDateValue($this->booking_start_at); } if ($this->booking_end_at) { $a['booking_end_at'] = CalendarDateFormat::ymdFromDateValue($this->booking_end_at); } if ($this->age_limit_start) { $a['age_limit_start'] = CalendarDateFormat::ymdFromDateValue($this->age_limit_start); } if ($this->age_limit_end) { $a['age_limit_end'] = CalendarDateFormat::ymdFromDateValue($this->age_limit_end); } return $a; } }