# زمان‌بندی خودکار انقضای نوبت‌های پرداخت‌نشده با 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` هنوز دستی کار کند (بعد از ری‌فکتور).
