Files
clinicpro/.claude/prompt/schedule-cancel-expired-appointments.md
T
hamedandClaude Opus 4.8 71ab8892c9 feat(appointment): auto-expire unpaid bookings via Symfony Scheduler
Run the 15-min payment-expiry every minute through Symfony Scheduler so
unpaid pending bookings flip to expired without a system crontab. Install
symfony/scheduler; extract the expiry logic into AppointmentExpiryService
(reused by the existing command); add ExpireAppointmentsMessage + handler
and an #[AsSchedule] provider (RecurringMessage::every 1 minute); wire a
scheduler_default transport in messenger.yaml. Slots already free
just-in-time via isSlotTaken, so this only syncs the DB status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:43:50 +03:30

8.6 KiB
Raw Blame History

زمان‌بندی خودکار انقضای نوبت‌های پرداخت‌نشده با Symfony Scheduler

پروژه

clinicpro (Backend). تغییر صرفاً backend است.

زمینه

منطق قفل ۱۵دقیقه‌ای نوبت از قبل کامل است:

  • Appointment فیلد expires_at دارد و موقع ساخت markPendingWithTtl(900) می‌خورد.
  • AppointmentRepository::isSlotTaken نوبت pendingِ منقضی را «گرفته» حساب نمی‌کند → اسلات همان لحظه‌ی انقضا نرم‌آزاد می‌شود (کاربر بعدی می‌تواند رزرو کند، حتی قبل از اجرای هر job).
  • AppointmentRepository::findPaymentExpired($now) نوبت‌های pendingی که expires_at < now را برمی‌گرداند.
  • App\Appointment\Command\CancelExpiredAppointmentsCommand (app:cancel-expired-appointments) این‌ها را pending → expired می‌کند تا وضعیت DB/داشبورد تمیز بماند.

شکاف: این command هیچ زمان‌بندی‌ای ندارد و باید مرتب (هر ۱ دقیقه) اجرا شود. می‌خواهیم با Symfony Scheduler (داخل کد، ورژن‌خورده، مستقل از crontab سرور) این را هندل کنیم.

مشکل / هدف

یک Schedule در خود اپلیکیشن تعریف کن که هر ۱ دقیقه منطق انقضای نوبت‌های پرداخت‌نشده را اجرا کند، تا نوبت‌های pendingی که ۱۵ دقیقه‌شان گذشته به‌صورت خودکار expired شوند و وضعیت با واقعیتِ آزاد بودن اسلات هم‌خوان بماند.

فایل‌های مرتبط

فایل نقش
composer.json افزودن symfony/scheduler
config/packages/messenger.yaml transport جدید scheduler_default و routing پیام schedule
src/Appointment/Schedule/ExpireAppointmentsSchedule.php (جدید) ScheduleProvider با #[AsSchedule]
src/Appointment/Message/ExpireAppointmentsMessage.php (جدید) پیام تریگر
src/Appointment/MessageHandler/ExpireAppointmentsHandler.php (جدید) اجرای منطق انقضا
src/Appointment/Command/CancelExpiredAppointmentsCommand.php منطق موجود — منطق را به یک سرویس مشترک منتقل کن تا هم command و هم handler از آن استفاده کنند
src/Appointment/Repository/AppointmentRepository.php findPaymentExpired, findExpiredPending (موجود)
clinicpro/CLAUDE.md یا README ذکر نحوه‌ی اجرای worker

وضعیت فعلی (کد واقعی)

messenger.yaml

framework:
    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
            sync: 'sync://'
        routing:
            'App\Shared\Message\SendSmsMessage': async

Command (منطق انقضا که باید مشترک شود)

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $now = time();
    $expired = [];
    foreach ([...$this->appointmentRepo->findPaymentExpired($now), ...$this->appointmentRepo->findExpiredPending($now)] as $a) {
        $expired[$a->getUuid()] = $a;
    }
    $count = 0;
    foreach ($expired as $a) {
        $a->transitionTo(Appointment::STATUS_EXPIRED);
        $this->appointmentRepo->save($a, false);
        $count++;
    }
    if ($count > 0) $this->appointmentRepo->save(reset($expired));
    // ...
}

symfony/scheduler در composer نصب نیست (فقط symfony/messenger هست). باید نصب شود.

وظایف

۱. نصب symfony/scheduler

ddev composer require symfony/scheduler
  • بررسی کن نسخه با 7.4.* بقیه‌ی کامپوننت‌های symfony هم‌خوان باشد.

۲. استخراج منطق انقضا به یک سرویس مشترک

برای پرهیز از تکرار بین command و handler، یک سرویس بساز (مثلاً src/Appointment/Service/AppointmentExpiryService.php):

class AppointmentExpiryService
{
    public function __construct(private readonly AppointmentRepository $appointmentRepo) {}

    /** @return int تعداد نوبت‌های منقضی‌شده */
    public function expireStale(): int
    {
        $now = time();
        $expired = [];
        foreach ([...$this->appointmentRepo->findPaymentExpired($now), ...$this->appointmentRepo->findExpiredPending($now)] as $a) {
            $expired[$a->getUuid()] = $a;
        }
        $count = 0;
        foreach ($expired as $a) {
            $a->transitionTo(Appointment::STATUS_EXPIRED);
            $this->appointmentRepo->save($a, false);
            $count++;
        }
        if ($count > 0) $this->appointmentRepo->save(reset($expired));
        return $count;
    }
}
  • CancelExpiredAppointmentsCommand::execute را به فراخوانی $this->expiryService->expireStale() ساده کن (command برای اجرای دستی/دیباگ می‌ماند).

۳. پیام و هندلر

src/Appointment/Message/ExpireAppointmentsMessage.php:

final class ExpireAppointmentsMessage {}

src/Appointment/MessageHandler/ExpireAppointmentsHandler.php:

#[AsMessageHandler]
final class ExpireAppointmentsHandler
{
    public function __construct(private readonly AppointmentExpiryService $expiryService) {}
    public function __invoke(ExpireAppointmentsMessage $message): void
    {
        $this->expiryService->expireStale();
    }
}

۴. ScheduleProvider

src/Appointment/Schedule/ExpireAppointmentsSchedule.php:

#[AsSchedule('appointment_expiry')]
final class ExpireAppointmentsSchedule implements ScheduleProviderInterface
{
    public function getSchedule(): Schedule
    {
        return (new Schedule())->add(
            RecurringMessage::every('1 minute', new ExpireAppointmentsMessage())
        );
    }
}

۵. transport و routing در messenger.yaml

framework:
    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
            sync: 'sync://'
            scheduler_default:
                dsn: 'schedule://appointment_expiry'
        routing:
            'App\Shared\Message\SendSmsMessage': async
            'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default
  • نام schedule (appointment_expiry) در #[AsSchedule(...)] و schedule://... باید یکی باشد.

۶. اجرای worker (مستندسازی + ddev)

  • این مکانیزم نیاز به یک worker دائمی دارد:
    php bin/console messenger:consume scheduler_default
    
  • در clinicpro/CLAUDE.md (بخش Commands) این را اضافه کن. اگر ddev راهی برای daemonize دارد (مثلاً web_extra_daemons در .ddev/config.yaml)، یک entry برای اجرای دائمی این consume اضافه کن تا در dev خودکار اجرا شود؛ اگر مطمئن نیستی، فقط مستند کن و متوقف شو و بپرس قبل از تغییر .ddev/config.yaml.

نکات مهم

  • آزادسازی اسلات از قبل just-in-time است (در isSlotTaken). این schedule صرفاً وضعیت pending → expired را همگام می‌کند؛ پس حتی اگر worker لحظه‌ای down باشد، اسلات‌ها همچنان درست آزاد می‌مانند و فقط flip وضعیت تأخیر می‌گیرد. این را در پیام/گزارش ذکر کن.
  • idempotent: expireStale باید بارها قابل‌اجرا باشد بدون اثر جانبی (فقط pendingِ منقضی را flip می‌کند؛ transitionTo از ALLOWED_TRANSITIONS عبور می‌کند).
  • تاریخ‌ها Unix timestamp؛ از time() استفاده کن.
  • پیام/هندلر/سرویس را تمیز و کم‌وابستگی نگه‌دار (فقط AppointmentRepository).
  • تست:
    • ddev composer require symfony/scheduler موفق
    • ddev exec php -l روی فایل‌های جدید
    • ddev exec php bin/console cache:clear
    • ddev exec php bin/console debug:messenger یا debug:scheduler → schedule appointment_expiry دیده شود
    • یک نوبت pending با expires_at گذشته بساز (یا SQL آن را به گذشته ببر)، سپس ddev exec php bin/console messenger:consume scheduler_default --limit=1 -v و تأیید کن نوبت expired شد و اسلات در isSlotTaken آزاد است.
    • CancelExpiredAppointmentsCommand هنوز دستی کار کند (بعد از ری‌فکتور).