You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

313 lines
10 KiB

4 weeks ago
<?php
namespace App\Models;
3 weeks ago
use Carbon\Carbon;
4 weeks ago
use Illuminate\Database\Eloquent\Builder;
2 weeks ago
use Illuminate\Database\Eloquent\Factories\HasFactory;
4 weeks ago
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Activity extends Model
{
use HasFactory, SoftDeletes;
2 weeks ago
public const RESERVATION_TYPE_ONLINE = 'online';
public const RESERVATION_TYPE_OFFLINE = 'offline';
public const RESERVATION_TYPE_OTHER = 'other';
1 week ago
/** 需要报名平台内在线报名online无需报名none亦可存自定义短文案仅展示、逻辑同非 online */
1 week ago
public const RESERVATION_TYPE_PHONE = 'phone';
public const RESERVATION_TYPE_WECHAT_MP = 'wechat_mp';
public const RESERVATION_TYPE_OFFLINE_VISIT = 'offline_visit';
1 week ago
/** 无需报名(不需要在平台报名) */
1 week ago
public const RESERVATION_TYPE_NONE = 'none';
1 week ago
/** 门票说明:写入 offline_reservation_method取值 free / paid */
public const TICKET_FREE = 'free';
public const TICKET_PAID = 'paid';
4 weeks ago
protected $fillable = [
'venue_id',
1 week ago
'submitted_by',
2 weeks ago
'reservation_type',
'location',
1 week ago
'check_in_meeting_point',
2 weeks ago
'specific_time',
'offline_reservation_method',
1 week ago
'ticket_fee_note',
2 weeks ago
'external_url',
4 weeks ago
'title',
2 weeks ago
'contact_name',
4 weeks ago
'summary',
'category',
'quota',
'registered_count',
'start_at',
'end_at',
'address',
3 weeks ago
'contact_phone',
4 weeks ago
'lat',
'lng',
'detail_html',
'cover_image',
'gallery_media',
1 week ago
'behind_scenes_media',
4 weeks ago
'tags',
'reservation_notice',
'open_time',
'sort',
'is_active',
1 week ago
'is_hot',
3 weeks ago
'schedule_status',
4 weeks ago
'booking_audience',
'total_quota',
'min_people_per_order',
'max_people_per_order',
3 weeks ago
'audit_status',
'audit_remark',
'last_approved_snapshot',
2 weeks ago
'view_count',
'external_link_click_count',
4 weeks ago
];
3 weeks ago
public const AUDIT_APPROVED = 'approved';
public const AUDIT_PENDING = 'pending';
public const AUDIT_REJECTED = 'rejected';
4 weeks ago
protected $casts = [
'start_at' => 'datetime',
'end_at' => 'datetime',
'lat' => 'float',
'lng' => 'float',
'gallery_media' => 'array',
1 week ago
'behind_scenes_media' => 'array',
4 weeks ago
'tags' => 'array',
'sort' => 'integer',
'is_active' => 'boolean',
1 week ago
'is_hot' => 'boolean',
4 weeks ago
'total_quota' => 'integer',
'min_people_per_order' => 'integer',
'max_people_per_order' => 'integer',
3 weeks ago
'last_approved_snapshot' => 'array',
2 weeks ago
'view_count' => 'integer',
'external_link_click_count' => 'integer',
4 weeks ago
];
1 week ago
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]);
}
3 weeks ago
/** 活动待审期间若用快照会与 activity_days 不一致,故前台仅展示已审核通过的记录。 */
public function scopeVisibleOnH5(Builder $query): Builder
{
return $query->where('is_active', true)->where('audit_status', self::AUDIT_APPROVED);
}
4 weeks ago
public function venue(): BelongsTo
{
return $this->belongsTo(Venue::class);
}
1 week ago
public function submitter(): BelongsTo
{
return $this->belongsTo(User::class, 'submitted_by');
}
4 weeks ago
public function reservations(): HasMany
{
return $this->hasMany(Reservation::class);
}
public function activityDays(): HasMany
{
2 weeks ago
return $this->hasMany(ActivityDay::class)
->orderBy('session_start_at')
->orderBy('id');
4 weeks ago
}
4 weeks ago
/**
* 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])
1 week ago
->orderByTicketNoteFreeBeforePaid()
4 weeks ago
->orderByDesc('id');
}
3 weeks ago
/**
* 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')
1 week ago
->orderByTicketNoteFreeBeforePaid()
3 weeks ago
->orderByDesc('id');
}
1 week ago
/**
* 热门优先,其次进行中 → 未开始 → 已结束组内按开始时间、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')
1 week ago
->orderByTicketNoteFreeBeforePaid()
1 week ago
->orderByDesc('id');
}
1 week ago
/**
* 门票说明:免费等非 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]
);
}
3 weeks ago
/**
* 按活动开始/结束日(应用时区下的日历日)计算「未开始 / 进行中 / 已结束」,与后台列表筛选一致。
*/
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,
};
}
1 week ago
/**
* 是否所有场次单日名额均已耗尽(各场 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;
});
}
4 weeks ago
/**
* 按未取消预约的票数合计,回写 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]);
}
}