'datetime', 'end_at' => 'datetime', 'lat' => 'float', 'lng' => 'float', 'gallery_media' => 'array', 'tags' => 'array', 'sort' => 'integer', 'is_active' => 'boolean', 'total_quota' => 'integer', 'min_people_per_order' => 'integer', 'max_people_per_order' => 'integer', 'last_approved_snapshot' => 'array', ]; /** 活动待审期间若用快照会与 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 reservations(): HasMany { return $this->hasMany(Reservation::class); } public function activityDays(): HasMany { return $this->hasMany(ActivityDay::class)->orderBy('activity_date'); } /** * 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]) ->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') ->orderByDesc('id'); } /** * 按活动开始/结束日(应用时区下的日历日)计算「未开始 / 进行中 / 已结束」,与后台列表筛选一致。 */ 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, }; } /** * 按未取消预约的票数合计,回写 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]); } }