'datetime', 'end_at' => 'datetime', 'lat' => 'float', 'lng' => 'float', 'gallery_media' => 'array', 'behind_scenes_media' => 'array', 'tags' => 'array', 'sort' => 'integer', 'is_active' => 'boolean', 'is_hot' => 'boolean', 'total_quota' => 'integer', 'min_people_per_order' => 'integer', 'max_people_per_order' => 'integer', 'last_approved_snapshot' => 'array', 'view_count' => 'integer', 'external_link_click_count' => 'integer', ]; protected static function booted(): void { static::saving(function (Activity $activity) { if (self::computeScheduleStatusFromBounds($activity->start_at, $activity->end_at) === 'ended') { $activity->is_hot = false; } }); } /** * 已结束活动不应保留热门标记(与列表/首页规则一致)。用于定时任务批量纠正无人保存的记录。 */ public static function clearHotFlagsForEndedActivities(): int { $tz = (string) config('app.timezone'); $today = Carbon::now($tz)->toDateString(); return (int) static::query() ->where('is_hot', true) ->whereNotNull('end_at') ->whereDate('end_at', '<', $today) ->update(['is_hot' => false]); } /** 活动待审期间若用快照会与 activity_days 不一致,故前台仅展示已审核通过的记录。 */ public function scopeVisibleOnH5(Builder $query): Builder { return $query->where('is_active', true)->where('audit_status', self::AUDIT_APPROVED); } public function venue(): BelongsTo { return $this->belongsTo(Venue::class); } public function submitter(): BelongsTo { return $this->belongsTo(User::class, 'submitted_by'); } public function reservations(): HasMany { return $this->hasMany(Reservation::class); } public function activityDays(): HasMany { return $this->hasMany(ActivityDay::class) ->orderBy('session_start_at') ->orderBy('id'); } /** * H5 活动列表/热门:未结束优先(按开始日期升序,空开始日置后);已结束置底(按结束日期降序)。 */ public function scopeOrderForH5Listing(Builder $query): Builder { $today = now()->startOfDay(); return $query ->orderByRaw('CASE WHEN end_at IS NOT NULL AND end_at < ? THEN 1 ELSE 0 END ASC', [$today]) ->orderByRaw('CASE WHEN end_at IS NOT NULL AND end_at < ? THEN end_at END DESC', [$today]) ->orderByRaw('CASE WHEN (end_at IS NULL OR end_at >= ?) AND start_at IS NULL THEN 1 ELSE 0 END ASC', [$today]) ->orderByRaw('CASE WHEN end_at IS NULL OR end_at >= ? THEN start_at END ASC', [$today]) ->orderByTicketNoteFreeBeforePaid() ->orderByDesc('id'); } /** * H5 活动列表:进行中 → 未开始 → 已结束;组内按开始时间、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') ->orderByTicketNoteFreeBeforePaid() ->orderByDesc('id'); } /** * 热门优先,其次进行中 → 未开始 → 已结束;组内按开始时间、id。 */ public function scopeOrderByHotThenScheduleStatusPriority(Builder $query): Builder { $today = Carbon::now((string) config('app.timezone'))->toDateString(); return $query ->orderByDesc('is_hot') ->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') ->orderByTicketNoteFreeBeforePaid() ->orderByDesc('id'); } /** * 门票说明:免费等非 paid 在前,收费(paid)在后。 */ public function scopeOrderByTicketNoteFreeBeforePaid(Builder $query): Builder { return $query->orderByRaw( 'CASE WHEN offline_reservation_method = ? THEN 1 ELSE 0 END ASC', [self::TICKET_PAID] ); } /** * 按活动开始/结束日(应用时区下的日历日)计算「未开始 / 进行中 / 已结束」,与后台列表筛选一致。 */ public static function computeScheduleStatusFromBounds(?Carbon $start, ?Carbon $end, ?string $tz = null): string { $tz = $tz ?? (string) config('app.timezone'); $today = Carbon::now($tz)->toDateString(); $startD = $start ? $start->copy()->timezone($tz)->toDateString() : null; $endD = $end ? $end->copy()->timezone($tz)->toDateString() : null; if (! $startD && ! $endD) { return 'ongoing'; } if ($startD && ! $endD) { return $today < $startD ? 'not_started' : 'ongoing'; } if (! $startD && $endD) { return $today > $endD ? 'ended' : 'ongoing'; } if ($today < $startD) { return 'not_started'; } if ($today > $endD) { return 'ended'; } return 'ongoing'; } /** 列表筛选:按日期实时计算,不依赖 schedule_status 字段的缓存值。 */ 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, }; } /** * 是否所有场次单日名额均已耗尽(各场 day_quota - booked_count 均 <= 0)。 * 无场次时返回 false,避免与「约满」等同。 */ public function areAllActivityDaySlotsExhausted(): bool { if (! $this->relationLoaded('activityDays')) { $this->load('activityDays'); } $days = $this->activityDays; if ($days->isEmpty()) { return false; } return ! $days->contains(function (ActivityDay $d) { return max(0, (int) $d->day_quota - (int) $d->booked_count) > 0; }); } /** * 列表用:仍有未约满名额,但所有「有余量」的场次均已过可预约时间(截止后未满)。 * 「预约未开始」的不算截止。 */ public function isBookingClosedWithRemainingCapacity(): bool { if (! $this->relationLoaded('activityDays')) { $this->load('activityDays'); } $days = $this->activityDays; if ($days->isEmpty()) { return false; } $now = Carbon::now(); $hasRemainder = false; foreach ($days as $d) { $avail = max(0, (int) $d->day_quota - (int) $d->booked_count); if ($avail <= 0) { continue; } $hasRemainder = true; if (! $this->activityDayBookingPeriodEnded($d, $now)) { return false; } } return $hasRemainder; } private function activityDayBookingPeriodEnded(ActivityDay $d, Carbon $now): bool { if ($d->isSessionMode()) { if ($d->booking_opens_at !== null && $d->booking_opens_at->gt($now)) { return false; } $deadline = $d->booking_deadline_at; if (! $deadline) { return false; } return $now->gt($deadline); } if (! $d->opens_at) { return false; } if ($d->opens_at->gt($now)) { return false; } $closesAt = $d->closes_at ?: $d->opens_at->copy()->endOfDay(); return $closesAt->lt($now); } /** * 按未取消预约的票数合计,回写 activities.registered_count(展示用「已预约总人数」)。 */ public static function refreshRegisteredCountFromReservations(int $activityId): void { $total = (int) Reservation::query() ->where('activity_id', $activityId) ->where('status', '!=', 'cancelled') ->get() ->sum(fn (Reservation $r) => max(1, (int) ($r->ticket_count ?? 1))); static::query()->where('id', $activityId)->update(['registered_count' => $total]); } }