|
|
<?php
|
|
|
|
|
|
namespace App\Models;
|
|
|
|
|
|
use Carbon\Carbon;
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
|
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;
|
|
|
|
|
|
public const RESERVATION_TYPE_ONLINE = 'online';
|
|
|
|
|
|
public const RESERVATION_TYPE_OFFLINE = 'offline';
|
|
|
|
|
|
public const RESERVATION_TYPE_OTHER = 'other';
|
|
|
|
|
|
/** 需要报名:平台内在线报名(online);无需报名:none;亦可存自定义短文案,仅展示、逻辑同非 online */
|
|
|
public const RESERVATION_TYPE_PHONE = 'phone';
|
|
|
|
|
|
public const RESERVATION_TYPE_WECHAT_MP = 'wechat_mp';
|
|
|
|
|
|
public const RESERVATION_TYPE_OFFLINE_VISIT = 'offline_visit';
|
|
|
|
|
|
/** 无需报名(不需要在平台报名) */
|
|
|
public const RESERVATION_TYPE_NONE = 'none';
|
|
|
|
|
|
/** 门票说明:写入 offline_reservation_method,取值 free / paid */
|
|
|
public const TICKET_FREE = 'free';
|
|
|
|
|
|
public const TICKET_PAID = 'paid';
|
|
|
|
|
|
protected $fillable = [
|
|
|
'venue_id',
|
|
|
'verify_portal_pin',
|
|
|
'submitted_by',
|
|
|
'reservation_type',
|
|
|
'location',
|
|
|
'check_in_meeting_point',
|
|
|
'specific_time',
|
|
|
'offline_reservation_method',
|
|
|
'ticket_fee_note',
|
|
|
'external_url',
|
|
|
'title',
|
|
|
'contact_name',
|
|
|
'summary',
|
|
|
'category',
|
|
|
'quota',
|
|
|
'registered_count',
|
|
|
'start_at',
|
|
|
'end_at',
|
|
|
'address',
|
|
|
'contact_phone',
|
|
|
'lat',
|
|
|
'lng',
|
|
|
'detail_html',
|
|
|
'cover_image',
|
|
|
'gallery_media',
|
|
|
'behind_scenes_media',
|
|
|
'tags',
|
|
|
'reservation_notice',
|
|
|
'open_time',
|
|
|
'sort',
|
|
|
'is_active',
|
|
|
'is_hot',
|
|
|
'schedule_status',
|
|
|
'booking_audience',
|
|
|
'total_quota',
|
|
|
'min_people_per_order',
|
|
|
'max_people_per_order',
|
|
|
'audit_status',
|
|
|
'audit_remark',
|
|
|
'last_approved_snapshot',
|
|
|
'view_count',
|
|
|
'external_link_click_count',
|
|
|
];
|
|
|
|
|
|
/**
|
|
|
* @var list<string>
|
|
|
*/
|
|
|
protected $hidden = [
|
|
|
'verify_portal_pin',
|
|
|
];
|
|
|
|
|
|
public const AUDIT_APPROVED = 'approved';
|
|
|
|
|
|
public const AUDIT_PENDING = 'pending';
|
|
|
|
|
|
public const AUDIT_REJECTED = 'rejected';
|
|
|
|
|
|
protected $casts = [
|
|
|
'start_at' => '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 auditLogs(): HasMany
|
|
|
{
|
|
|
return $this->hasMany(ActivityAuditLog::class)->orderByDesc('created_at');
|
|
|
}
|
|
|
|
|
|
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');
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* SQL:0=进行中 1=未开始 2=已结束,与 {@see computeScheduleStatusForDisplay} / {@see scopeWhereComputedScheduleStatus} 一致。
|
|
|
*
|
|
|
* @return array{0: string, 1: array<int, Carbon|string>}
|
|
|
*/
|
|
|
public static function scheduleStatusPriorityRankSql(): array
|
|
|
{
|
|
|
$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';
|
|
|
$online = '(activities.reservation_type IS NULL OR activities.reservation_type = \'\' OR activities.reservation_type = \''.self::RESERVATION_TYPE_ONLINE.'\')';
|
|
|
|
|
|
$sql = 'CASE
|
|
|
WHEN ('.$online.'
|
|
|
AND (SELECT COUNT(*) FROM activity_days ad WHERE '.$sessionDaySql.') > 0)
|
|
|
THEN
|
|
|
CASE
|
|
|
WHEN (SELECT MIN(ad.session_start_at) FROM activity_days ad WHERE '.$sessionDaySql.') > ? THEN 1
|
|
|
WHEN (SELECT MAX(ad.session_end_at) FROM activity_days ad WHERE '.$sessionDaySql.') < ? THEN 2
|
|
|
ELSE 0
|
|
|
END
|
|
|
WHEN activities.end_at IS NOT NULL AND DATE(activities.end_at) < ? THEN 2
|
|
|
WHEN activities.start_at IS NOT NULL AND DATE(activities.start_at) > ? THEN 1
|
|
|
ELSE 0
|
|
|
END';
|
|
|
|
|
|
return [$sql, [$now, $now, $today, $today]];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* H5 活动列表:进行中 → 未开始 → 已结束;组内按开始时间、id。
|
|
|
*/
|
|
|
public function scopeOrderByScheduleStatusPriority(Builder $query): Builder
|
|
|
{
|
|
|
[$rankSql, $rankBindings] = static::scheduleStatusPriorityRankSql();
|
|
|
|
|
|
return $query
|
|
|
->orderByRaw($rankSql.' ASC', $rankBindings)
|
|
|
->orderByRaw('start_at IS NULL ASC')
|
|
|
->orderBy('start_at', 'asc')
|
|
|
->orderByTicketNoteFreeBeforePaid()
|
|
|
->orderByDesc('id');
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 热门优先,其次进行中 → 未开始 → 已结束;组内按开始时间、id。
|
|
|
*/
|
|
|
public function scopeOrderByHotThenScheduleStatusPriority(Builder $query): Builder
|
|
|
{
|
|
|
[$rankSql, $rankBindings] = static::scheduleStatusPriorityRankSql();
|
|
|
|
|
|
return $query
|
|
|
->orderByDesc('is_hot')
|
|
|
->orderByRaw($rankSql.' ASC', $rankBindings)
|
|
|
->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]);
|
|
|
}
|
|
|
}
|