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>
This commit is contained in:
@@ -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` هنوز دستی کار کند (بعد از ریفکتور).
|
||||
Reference in New Issue
Block a user