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

182 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# زمان‌بندی خودکار انقضای نوبت‌های پرداخت‌نشده با 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` هنوز دستی کار کند (بعد از ری‌فکتور).