'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::computeScheduleStatusForDisplay($activity) === 'ended') { $activity->is_hot = false; } }); } /** * 已结束活动不应保留热门标记(与列表/首页规则一致)。用于定时任务批量纠正无人保存的记录。 */ public static function clearHotFlagsForEndedActivities(): int { $tz = (string) config('app.timezone'); $today = Carbon::now($tz)->toDateString(); $now = Carbon::now($tz); $sessionDaySql = 'ad.activity_id = activities.id AND ad.session_start_at IS NOT NULL AND ad.session_end_at IS NOT NULL AND ad.booking_deadline_at IS NOT NULL'; return (int) static::query() ->where('is_hot', true) ->where(function (Builder $q) use ($today, $now, $sessionDaySql) { $q->where(function (Builder $sess) use ($now, $sessionDaySql) { $sess->where(function (Builder $t) { $t->whereNull('reservation_type') ->orWhere('reservation_type', '') ->orWhere('reservation_type', self::RESERVATION_TYPE_ONLINE); })->whereRaw( '(SELECT MAX(ad.session_end_at) FROM activity_days ad WHERE '.$sessionDaySql.') < ?', [$now] ); })->orWhere(function (Builder $cls) use ($today, $sessionDaySql) { $cls->where(function (Builder $x) use ($sessionDaySql) { $x->where(function (Builder $off) { $off->whereNotNull('reservation_type') ->where('reservation_type', '!=', '') ->where('reservation_type', '!=', self::RESERVATION_TYPE_ONLINE); })->orWhereRaw( '(SELECT COUNT(*) FROM activity_days ad WHERE '.$sessionDaySql.') = 0' ); })->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'; } /** * 平台内「需要报名」(online / 空类型)且存在场次模式活动日时,用各场 session 起止时间的 min/max * 判断未开始 / 进行中 / 已结束;否则与 {@see computeScheduleStatusFromBounds} 一致(活动级日历日)。 */ public static function computeScheduleStatusForDisplay(Activity $activity): string { if (! self::isOnlineReservationType((string) ($activity->reservation_type ?? self::RESERVATION_TYPE_ONLINE))) { return self::computeScheduleStatusFromBounds($activity->start_at, $activity->end_at); } $days = $activity->relationLoaded('activityDays') ? $activity->activityDays : $activity->activityDays()->get(); $sessionDays = $days->filter(fn (ActivityDay $d) => $d->isSessionMode()); if ($sessionDays->isEmpty()) { return self::computeScheduleStatusFromBounds($activity->start_at, $activity->end_at); } $tz = (string) config('app.timezone'); $now = Carbon::now($tz); $minStart = $sessionDays->min('session_start_at'); $maxEnd = $sessionDays->max('session_end_at'); if (! $minStart instanceof Carbon || ! $maxEnd instanceof Carbon) { return self::computeScheduleStatusFromBounds($activity->start_at, $activity->end_at); } $minStart = $minStart->copy()->timezone($tz); $maxEnd = $maxEnd->copy()->timezone($tz); if ($now->lt($minStart)) { return 'not_started'; } if ($now->gt($maxEnd)) { return 'ended'; } return 'ongoing'; } public static function isOnlineReservationType(string $reservationType): bool { $t = trim($reservationType); return $t === '' || $t === self::RESERVATION_TYPE_ONLINE; } /** 列表筛选:实时计算,与 {@see computeScheduleStatusForDisplay} 一致(含场次结束时间)。 */ public function scopeWhereComputedScheduleStatus(Builder $query, string $status): Builder { $tz = (string) config('app.timezone'); $today = Carbon::now($tz)->toDateString(); $now = Carbon::now($tz); $sessionDaySql = 'ad.activity_id = activities.id AND ad.session_start_at IS NOT NULL AND ad.session_end_at IS NOT NULL AND ad.booking_deadline_at IS NOT NULL'; $onlineReservation = function (Builder $q): void { $q->where(function (Builder $t) { $t->whereNull('reservation_type') ->orWhere('reservation_type', '') ->orWhere('reservation_type', self::RESERVATION_TYPE_ONLINE); }); }; $offlineOrNoSessionDays = function (Builder $q) use ($sessionDaySql): void { $q->where(function (Builder $x) use ($sessionDaySql) { $x->where(function (Builder $off) { $off->whereNotNull('reservation_type') ->where('reservation_type', '!=', '') ->where('reservation_type', '!=', self::RESERVATION_TYPE_ONLINE); })->orWhereRaw( '(SELECT COUNT(*) FROM activity_days ad WHERE '.$sessionDaySql.') = 0' ); }); }; return match ($status) { 'not_started' => $query->where(function (Builder $q) use ($onlineReservation, $offlineOrNoSessionDays, $today, $now, $sessionDaySql) { $q->where(function (Builder $sess) use ($onlineReservation, $now, $sessionDaySql) { $onlineReservation($sess); $sess->whereRaw( '(SELECT MIN(ad.session_start_at) FROM activity_days ad WHERE '.$sessionDaySql.') > ?', [$now] ); })->orWhere(function (Builder $cls) use ($offlineOrNoSessionDays, $today) { $offlineOrNoSessionDays($cls); $cls->whereNotNull('start_at')->whereDate('start_at', '>', $today); }); }), 'ended' => $query->where(function (Builder $q) use ($onlineReservation, $offlineOrNoSessionDays, $today, $now, $sessionDaySql) { $q->where(function (Builder $sess) use ($onlineReservation, $now, $sessionDaySql) { $onlineReservation($sess); $sess->whereRaw( '(SELECT MAX(ad.session_end_at) FROM activity_days ad WHERE '.$sessionDaySql.') < ?', [$now] ); })->orWhere(function (Builder $cls) use ($offlineOrNoSessionDays, $today) { $offlineOrNoSessionDays($cls); $cls->whereNotNull('end_at')->whereDate('end_at', '<', $today); }); }), 'ongoing' => $query->where(function (Builder $q) use ($onlineReservation, $offlineOrNoSessionDays, $today, $now, $sessionDaySql) { $q->where(function (Builder $sess) use ($onlineReservation, $now, $sessionDaySql) { $onlineReservation($sess); $sess->whereRaw('(SELECT COUNT(*) FROM activity_days ad WHERE '.$sessionDaySql.') > 0') ->whereRaw( '(SELECT MIN(ad.session_start_at) FROM activity_days ad WHERE '.$sessionDaySql.') <= ?', [$now] ) ->whereRaw( '(SELECT MAX(ad.session_end_at) FROM activity_days ad WHERE '.$sessionDaySql.') >= ?', [$now] ); })->orWhere(function (Builder $cls) use ($offlineOrNoSessionDays, $today) { $offlineOrNoSessionDays($cls); $cls->where(function (Builder $c) use ($today) { $c->whereNull('start_at')->orWhereDate('start_at', '<=', $today); })->where(function (Builder $c) use ($today) { $c->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]); } }