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