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.

129 lines
4.3 KiB

<?php
namespace App\Services;
use App\Models\PhoneBookingBan;
use App\Models\Reservation;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class NoShowBlacklistService
{
/**
* 扫描「活动日已过、仍为待核销、从未核销」的预约,累计满 3 次则写入全平台禁约。
* 禁约截止 = 第 3 条对应活动的「活动开始日」+ 3 个月(应用时区日历)。
* 若该手机号已有未过期禁约,则跳过;若曾禁约已过期,只统计禁约期满日之后的未核销场次。
*
* @return int 本次新写入或更新的禁约条数
*/
public function syncAll(): int
{
$tz = (string) config('app.timezone');
$today = Carbon::now($tz)->toDateString();
$phones = Reservation::query()
->join('activity_days', 'activity_days.id', '=', 'reservations.activity_day_id')
->where('reservations.status', 'pending')
->whereNull('reservations.verified_at')
->whereNotNull('reservations.activity_day_id')
->whereDate('activity_days.activity_date', '<', $today)
->distinct()
->pluck('reservations.visitor_phone');
$applied = 0;
foreach ($phones as $phone) {
if (PhoneBookingBan::isGloballyBlocked((string) $phone)) {
continue;
}
if ($this->applyBanIfNeeded((string) $phone, $tz, $today)) {
$applied++;
}
}
return $applied;
}
/** 预约前对单号同步一次,避免仅依赖定时任务产生空窗期。 */
public function syncForPhone(string $phone): bool
{
$tz = (string) config('app.timezone');
$today = Carbon::now($tz)->toDateString();
if (PhoneBookingBan::isGloballyBlocked($phone)) {
return false;
}
return $this->applyBanIfNeeded($phone, $tz, $today);
}
private function applyBanIfNeeded(string $phone, string $tz, string $today): bool
{
$cutoff = $this->noShowCutoffDateString($phone);
/** @var Collection<int, Reservation> $rows */
$rows = Reservation::query()
->join('activity_days', 'activity_days.id', '=', 'reservations.activity_day_id')
->join('activities', 'activities.id', '=', 'reservations.activity_id')
->where('reservations.visitor_phone', $phone)
->where('reservations.status', 'pending')
->whereNull('reservations.verified_at')
->whereNotNull('reservations.activity_day_id')
->whereDate('activity_days.activity_date', '<', $today)
->when($cutoff, fn ($q) => $q->whereDate('activity_days.activity_date', '>=', $cutoff))
->orderByRaw('activities.start_at IS NULL ASC')
->orderBy('activities.start_at')
->orderBy('activity_days.activity_date')
->orderBy('reservations.id')
->select('reservations.*')
->get();
if ($rows->count() < 3) {
return false;
}
$third = $rows->values()->get(2);
$third->loadMissing(['activity', 'activityDay']);
$activity = $third->activity;
$day = $third->activityDay;
if (! $activity || ! $day) {
return false;
}
$anchor = $activity->start_at
? $activity->start_at->copy()->timezone($tz)->startOfDay()
: Carbon::parse($day->activity_date->format('Y-m-d'), $tz)->startOfDay();
$banUntil = $anchor->copy()->addMonths(3)->startOfDay();
if ($banUntil->lte(now())) {
return false;
}
PhoneBookingBan::query()->updateOrCreate(
['visitor_phone' => $phone],
[
'ban_until' => $banUntil,
'reason' => '累计3次预约成功后未核销',
],
);
return true;
}
/**
* 禁约期满后,新统计的「未核销」场次须从该日期(含)起算。
*/
private function noShowCutoffDateString(string $phone): ?string
{
$max = PhoneBookingBan::query()
->where('visitor_phone', $phone)
->where('ban_until', '<=', now())
->max('ban_until');
if (! $max) {
return null;
}
return Carbon::parse($max)->timezone(config('app.timezone'))->toDateString();
}
}