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` هنوز دستی کار کند (بعد از ریفکتور).
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"symfony/property-info": "7.4.*",
|
"symfony/property-info": "7.4.*",
|
||||||
"symfony/rate-limiter": "7.4.*",
|
"symfony/rate-limiter": "7.4.*",
|
||||||
"symfony/runtime": "7.4.*",
|
"symfony/runtime": "7.4.*",
|
||||||
|
"symfony/scheduler": "7.4.*",
|
||||||
"symfony/security-bundle": "7.4.*",
|
"symfony/security-bundle": "7.4.*",
|
||||||
"symfony/serializer": "7.4.*",
|
"symfony/serializer": "7.4.*",
|
||||||
"symfony/twig-bundle": "7.4.*",
|
"symfony/twig-bundle": "7.4.*",
|
||||||
|
|||||||
Generated
+86
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "245ea537c0b4605205b72071da16ee4b",
|
"content-hash": "0fd2472709b043fbb4c5f1b34a590095",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "doctrine/collections",
|
"name": "doctrine/collections",
|
||||||
@@ -5178,6 +5178,91 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-05-23T18:04:28+00:00"
|
"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",
|
"name": "symfony/security-bundle",
|
||||||
"version": "v7.4.13",
|
"version": "v7.4.13",
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ framework:
|
|||||||
multiplier: 2
|
multiplier: 2
|
||||||
failed: 'doctrine://default?queue_name=failed'
|
failed: 'doctrine://default?queue_name=failed'
|
||||||
sync: 'sync://'
|
sync: 'sync://'
|
||||||
|
scheduler_default:
|
||||||
|
dsn: 'schedule://appointment_expiry'
|
||||||
|
|
||||||
routing:
|
routing:
|
||||||
'App\Shared\Message\SendSmsMessage': async
|
'App\Shared\Message\SendSmsMessage': async
|
||||||
|
'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
framework:
|
framework:
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* }>,
|
* }>,
|
||||||
* },
|
* },
|
||||||
* scheduler?: bool|array{ // Scheduler configuration
|
* 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
|
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
|
||||||
* http_client?: bool|array{ // HTTP Client configuration
|
* http_client?: bool|array{ // HTTP Client configuration
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Appointment\Command;
|
namespace App\Appointment\Command;
|
||||||
|
|
||||||
use App\Appointment\Entity\Appointment;
|
use App\Appointment\Service\AppointmentExpiryService;
|
||||||
use App\Appointment\Repository\AppointmentRepository;
|
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
@@ -15,31 +14,14 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
)]
|
)]
|
||||||
class CancelExpiredAppointmentsCommand extends Command
|
class CancelExpiredAppointmentsCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(private readonly AppointmentRepository $appointmentRepo)
|
public function __construct(private readonly AppointmentExpiryService $expiryService)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$now = time();
|
$count = $this->expiryService->expireStale();
|
||||||
|
|
||||||
$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
|
|
||||||
}
|
|
||||||
|
|
||||||
$output->writeln(sprintf('Expired %d appointments.', $count));
|
$output->writeln(sprintf('Expired %d appointments.', $count));
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Appointment\Message;
|
||||||
|
|
||||||
|
final class ExpireAppointmentsMessage
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Appointment\MessageHandler;
|
||||||
|
|
||||||
|
use App\Appointment\Message\ExpireAppointmentsMessage;
|
||||||
|
use App\Appointment\Service\AppointmentExpiryService;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler]
|
||||||
|
final class ExpireAppointmentsHandler
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AppointmentExpiryService $expiryService) {}
|
||||||
|
|
||||||
|
public function __invoke(ExpireAppointmentsMessage $message): void
|
||||||
|
{
|
||||||
|
$this->expiryService->expireStale();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Appointment\Schedule;
|
||||||
|
|
||||||
|
use App\Appointment\Message\ExpireAppointmentsMessage;
|
||||||
|
use Symfony\Component\Scheduler\Attribute\AsSchedule;
|
||||||
|
use Symfony\Component\Scheduler\RecurringMessage;
|
||||||
|
use Symfony\Component\Scheduler\Schedule;
|
||||||
|
use Symfony\Component\Scheduler\ScheduleProviderInterface;
|
||||||
|
|
||||||
|
#[AsSchedule('appointment_expiry')]
|
||||||
|
final class ExpireAppointmentsSchedule implements ScheduleProviderInterface
|
||||||
|
{
|
||||||
|
public function getSchedule(): Schedule
|
||||||
|
{
|
||||||
|
return (new Schedule())->add(
|
||||||
|
RecurringMessage::every('1 minute', new ExpireAppointmentsMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Appointment\Service;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\Appointment;
|
||||||
|
use App\Appointment\Repository\AppointmentRepository;
|
||||||
|
|
||||||
|
class AppointmentExpiryService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AppointmentRepository $appointmentRepo) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expire pending bookings whose payment window has lapsed or whose slot
|
||||||
|
* time has already passed. Idempotent — safe to run repeatedly.
|
||||||
|
*
|
||||||
|
* @return int number of appointments expired
|
||||||
|
*/
|
||||||
|
public function expireStale(): 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
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use Symfony\Component\Scheduler\Attribute\AsSchedule;
|
||||||
|
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
|
||||||
|
use Symfony\Component\Scheduler\ScheduleProviderInterface;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
|
#[AsSchedule]
|
||||||
|
class Schedule implements ScheduleProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CacheInterface $cache,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSchedule(): SymfonySchedule
|
||||||
|
{
|
||||||
|
return (new SymfonySchedule())
|
||||||
|
->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
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,6 +183,18 @@
|
|||||||
"config/routes.yaml"
|
"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": {
|
"symfony/security-bundle": {
|
||||||
"version": "7.4",
|
"version": "7.4",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
Reference in New Issue
Block a user