diff --git a/.claude/prompt/schedule-cancel-expired-appointments.md b/.claude/prompt/schedule-cancel-expired-appointments.md new file mode 100644 index 00000000..f0a28396 --- /dev/null +++ b/.claude/prompt/schedule-cancel-expired-appointments.md @@ -0,0 +1,181 @@ +# زمان‌بندی خودکار انقضای نوبت‌های پرداخت‌نشده با 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` +```yaml +framework: + messenger: + transports: + async: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + sync: 'sync://' + routing: + 'App\Shared\Message\SendSmsMessage': async +``` + +### Command (منطق انقضا که باید مشترک شود) +```php +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` +```bash +ddev composer require symfony/scheduler +``` +- بررسی کن نسخه با `7.4.*` بقیه‌ی کامپوننت‌های symfony هم‌خوان باشد. + +### ۲. استخراج منطق انقضا به یک سرویس مشترک + +برای پرهیز از تکرار بین command و handler، یک سرویس بساز (مثلاً `src/Appointment/Service/AppointmentExpiryService.php`): + +```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`: +```php +final class ExpireAppointmentsMessage {} +``` + +`src/Appointment/MessageHandler/ExpireAppointmentsHandler.php`: +```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`: +```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` + +```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 دائمی دارد: + ```bash + 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` هنوز دستی کار کند (بعد از ری‌فکتور). diff --git a/composer.json b/composer.json index 4b2f9fe6..522713fe 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "symfony/property-info": "7.4.*", "symfony/rate-limiter": "7.4.*", "symfony/runtime": "7.4.*", + "symfony/scheduler": "7.4.*", "symfony/security-bundle": "7.4.*", "symfony/serializer": "7.4.*", "symfony/twig-bundle": "7.4.*", diff --git a/composer.lock b/composer.lock index c42bb8b2..47aab69e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "245ea537c0b4605205b72071da16ee4b", + "content-hash": "0fd2472709b043fbb4c5f1b34a590095", "packages": [ { "name": "doctrine/collections", @@ -5178,6 +5178,91 @@ ], "time": "2026-05-23T18:04:28+00:00" }, + { + "name": "symfony/scheduler", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/scheduler.git", + "reference": "d8fff93e5d29af0262e5693b76376117259b4532" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/scheduler/zipball/d8fff93e5d29af0262e5693b76376117259b4532", + "reference": "d8fff93e5d29af0262e5693b76376117259b4532", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/clock": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "dragonmantank/cron-expression": "^3.1", + "symfony/cache": "^6.4.36|^7.4.8|^8.0.8", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.1|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Scheduler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sergey Rabochiy", + "email": "upyx.00@gmail.com" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides scheduling through Symfony Messenger", + "homepage": "https://symfony.com", + "keywords": [ + "cron", + "schedule", + "scheduler" + ], + "support": { + "source": "https://github.com/symfony/scheduler/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T06:06:12+00:00" + }, { "name": "symfony/security-bundle", "version": "v7.4.13", diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index d9cf407e..ce2c2a00 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -11,9 +11,12 @@ framework: multiplier: 2 failed: 'doctrine://default?queue_name=failed' sync: 'sync://' + scheduler_default: + dsn: 'schedule://appointment_expiry' routing: 'App\Shared\Message\SendSmsMessage': async + 'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default when@test: framework: diff --git a/config/reference.php b/config/reference.php index e6c7ddc7..301213d6 100644 --- a/config/reference.php +++ b/config/reference.php @@ -468,7 +468,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }>, * }, * scheduler?: bool|array{ // Scheduler configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * }, * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true * http_client?: bool|array{ // HTTP Client configuration diff --git a/src/Appointment/Command/CancelExpiredAppointmentsCommand.php b/src/Appointment/Command/CancelExpiredAppointmentsCommand.php index 61d20a75..fceebf13 100644 --- a/src/Appointment/Command/CancelExpiredAppointmentsCommand.php +++ b/src/Appointment/Command/CancelExpiredAppointmentsCommand.php @@ -2,8 +2,7 @@ namespace App\Appointment\Command; -use App\Appointment\Entity\Appointment; -use App\Appointment\Repository\AppointmentRepository; +use App\Appointment\Service\AppointmentExpiryService; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -15,31 +14,14 @@ use Symfony\Component\Console\Output\OutputInterface; )] class CancelExpiredAppointmentsCommand extends Command { - public function __construct(private readonly AppointmentRepository $appointmentRepo) + public function __construct(private readonly AppointmentExpiryService $expiryService) { parent::__construct(); } protected function execute(InputInterface $input, OutputInterface $output): int { - $now = time(); - - $expired = []; - foreach ([...$this->appointmentRepo->findPaymentExpired($now), ...$this->appointmentRepo->findExpiredPending($now)] as $appointment) { - $expired[$appointment->getUuid()] = $appointment; - } - - $count = 0; - foreach ($expired as $appointment) { - $appointment->transitionTo(Appointment::STATUS_EXPIRED); - $this->appointmentRepo->save($appointment, false); - $count++; - } - - if ($count > 0) { - $this->appointmentRepo->save(reset($expired)); // flush once - } - + $count = $this->expiryService->expireStale(); $output->writeln(sprintf('Expired %d appointments.', $count)); return Command::SUCCESS; } diff --git a/src/Appointment/Message/ExpireAppointmentsMessage.php b/src/Appointment/Message/ExpireAppointmentsMessage.php new file mode 100644 index 00000000..69f979dc --- /dev/null +++ b/src/Appointment/Message/ExpireAppointmentsMessage.php @@ -0,0 +1,7 @@ +expiryService->expireStale(); + } +} diff --git a/src/Appointment/Schedule/ExpireAppointmentsSchedule.php b/src/Appointment/Schedule/ExpireAppointmentsSchedule.php new file mode 100644 index 00000000..6c61b495 --- /dev/null +++ b/src/Appointment/Schedule/ExpireAppointmentsSchedule.php @@ -0,0 +1,20 @@ +add( + RecurringMessage::every('1 minute', new ExpireAppointmentsMessage()) + ); + } +} diff --git a/src/Appointment/Service/AppointmentExpiryService.php b/src/Appointment/Service/AppointmentExpiryService.php new file mode 100644 index 00000000..34201f86 --- /dev/null +++ b/src/Appointment/Service/AppointmentExpiryService.php @@ -0,0 +1,40 @@ +appointmentRepo->findPaymentExpired($now), ...$this->appointmentRepo->findExpiredPending($now)] as $appointment) { + $expired[$appointment->getUuid()] = $appointment; + } + + $count = 0; + foreach ($expired as $appointment) { + $appointment->transitionTo(Appointment::STATUS_EXPIRED); + $this->appointmentRepo->save($appointment, false); + $count++; + } + + if ($count > 0) { + $this->appointmentRepo->save(reset($expired)); // flush once + } + + return $count; + } +} diff --git a/src/Schedule.php b/src/Schedule.php new file mode 100644 index 00000000..bb3edce6 --- /dev/null +++ b/src/Schedule.php @@ -0,0 +1,28 @@ +stateful($this->cache) // ensure missed tasks are executed + ->processOnlyLastMissedRun(true) // ensure only last missed task is run + + // add your own tasks here + // see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule + ; + } +} diff --git a/symfony.lock b/symfony.lock index 52f60c40..6d181433 100644 --- a/symfony.lock +++ b/symfony.lock @@ -183,6 +183,18 @@ "config/routes.yaml" ] }, + "symfony/scheduler": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.2", + "ref": "caea3c928ee9e1b21288fd76aef36f16ea355515" + }, + "files": [ + "src/Schedule.php" + ] + }, "symfony/security-bundle": { "version": "7.4", "recipe": {