'date', 'opens_at' => 'datetime', 'closes_at' => 'datetime', 'session_start_at' => 'datetime', 'session_end_at' => 'datetime', 'booking_deadline_at' => 'datetime', 'day_quota' => 'integer', 'booked_count' => 'integer', ]; /** 管理端/接口序列化时附带,与 H5 中 `time_range_text` 一致 */ protected $appends = ['time_range_text']; public function getTimeRangeTextAttribute(): string { return $this->formatSessionTimeRangeZh(); } protected static function booted(): void { static::saving(function (ActivityDay $day) { if ($day->session_start_at) { $tz = (string) config('app.timezone'); $day->activity_date = $day->session_start_at->copy()->timezone($tz)->format('Y-m-d'); } }); } public function activity(): BelongsTo { return $this->belongsTo(Activity::class); } /** * 场次时间段展示:同日则「Y年m月d日 H:i-H:i」。 */ public function formatSessionTimeRangeZh(): string { if (!$this->session_start_at || !$this->session_end_at) { return $this->activity_date?->format('Y年m月d日') ?? ''; } $tz = (string) config('app.timezone'); $s = $this->session_start_at->copy()->timezone($tz); $e = $this->session_end_at->copy()->timezone($tz); if ($s->toDateString() === $e->toDateString()) { return $s->format('Y年m月d日 H:i') . '-' . $e->format('H:i'); } return $s->format('Y年m月d日 H:i') . ' ~ ' . $e->format('Y年m月d日 H:i'); } public function isSessionMode(): bool { return $this->session_start_at !== null && $this->session_end_at !== null && $this->booking_deadline_at !== null; } /** * 当前时刻是否可预约:截止前、有余量、未重复预约等由业务层与 H5 数组字段共同约束。 * 这里仅表示「未过预约截止且有余量」(legacy 为原 opens/closes 窗口逻辑)。 */ public function isCurrentlyBookable(): bool { $available = max(0, (int) $this->day_quota - (int) $this->booked_count); if ($available <= 0) { return false; } if ($this->isSessionMode()) { return now()->lte($this->booking_deadline_at); } $closesAt = $this->closes_at ?: $this->opens_at?->copy()->endOfDay(); $now = now(); if (!$this->opens_at || !$closesAt) { return false; } $isOpenWindow = $this->opens_at->lte($now) && $closesAt->gte($now); return $isOpenWindow; } /** * H5 预约页/活动详情:单场次展示与可约态。 * * @return array */ public function toH5BookingDayArray(bool $alreadyReserved = false): array { $available = max(0, (int) $this->day_quota - (int) $this->booked_count); $now = now(); $enrolledCountText = '已报名数:' . (int) $this->booked_count; $bookedPeopleText = '已约' . (int) $this->booked_count . '人'; if ($this->isSessionMode()) { $deadline = $this->booking_deadline_at; $unavailableReason = null; $isBookable = false; if ($alreadyReserved) { $unavailableReason = 'already_reserved'; $actionHeadline = '已预约'; $statusText = '已约'; $statusKind = 'reserved'; } elseif (! $deadline) { $unavailableReason = 'closed'; $actionHeadline = '预约已结束'; $statusText = '预约已结束'; $statusKind = 'closed'; } elseif ($now->gt($deadline)) { $unavailableReason = 'deadline_passed'; $actionHeadline = '预约已结束'; $statusText = '已约' . (int) $this->booked_count . '人'; $statusKind = 'booked_info'; } elseif ($available <= 0) { $unavailableReason = 'sold_out'; $actionHeadline = '预约已满'; $statusText = '预约已满'; $statusKind = 'full'; } else { $isBookable = true; $unavailableReason = null; $actionHeadline = '立即预约'; $statusText = '已约' . (int) $this->booked_count . '人'; $statusKind = 'open'; } $timeDisplay = $this->formatSessionTimeRangeZh(); } else { // legacy:无场次字段时沿用原预约窗口 $timeDisplay = $this->activity_date?->format('Y年m月d日') ?? ''; if ($alreadyReserved) { $unavailableReason = 'already_reserved'; $actionHeadline = '已预约'; $statusText = '已约'; $statusKind = 'reserved'; } elseif ($available <= 0) { $unavailableReason = 'sold_out'; $actionHeadline = '预约已满'; $statusText = '预约已满'; $statusKind = 'full'; } else { $closesAt = $this->closes_at ?: $this->opens_at?->copy()->endOfDay(); if ($this->opens_at && $this->opens_at->gt($now)) { $unavailableReason = 'not_started'; $actionHeadline = '未开放'; $statusText = '未开放'; $statusKind = 'closed'; } elseif ($closesAt && $closesAt->lt($now)) { $unavailableReason = 'closed'; $actionHeadline = '预约已结束'; $statusText = '不可预约'; $statusKind = 'closed'; } else { $unavailableReason = null; $actionHeadline = '立即预约'; $statusText = '已约' . (int) $this->booked_count . '人'; $statusKind = 'open'; } } $isBookable = $this->isCurrentlyBookable() && ! $alreadyReserved; } if ($alreadyReserved) { $isBookable = false; } $closesAtLegacy = $this->closes_at ?: $this->opens_at?->copy()->endOfDay(); $isOpenWindow = $this->opens_at && $closesAtLegacy ? ($this->opens_at->lte($now) && $closesAtLegacy->gte($now)) : false; return [ 'id' => $this->id, 'session_name' => (string) ($this->session_name ?? ''), 'session_start_at' => $this->session_start_at?->format('Y-m-d H:i:s'), 'session_end_at' => $this->session_end_at?->format('Y-m-d H:i:s'), 'booking_deadline_at' => $this->booking_deadline_at?->format('Y-m-d H:i:s'), 'time_display' => $timeDisplay, 'activity_date' => $this->activity_date?->format('Y-m-d'), 'opens_at' => $this->opens_at?->format('Y-m-d H:i:s'), 'closes_at' => $closesAtLegacy?->format('Y-m-d H:i:s'), 'day_quota' => (int) $this->day_quota, 'booked_count' => (int) $this->booked_count, 'available_count' => $available, 'is_open' => $isOpenWindow, 'is_bookable' => $isBookable, 'unavailable_reason' => $unavailableReason, 'status_text' => $statusText, 'status_kind' => $statusKind, 'already_reserved' => $alreadyReserved, 'action_headline' => $actionHeadline, 'enrolled_count_text' => $enrolledCountText, 'booked_people_text' => $bookedPeopleText, ]; } }