Merge branch 'feat/appointment-lock-patient'
# Conflicts: # src/Appointment/Controller/AppointmentController.php
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
# قفل ۱۵ دقیقهای نوبت، اطلاعات بیمار جدا از پرداختکننده، و تأیید پس از پرداخت
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend). **این پرامپت اول اجرا شود.**
|
||||
|
||||
> **Cross-repo:** قرارداد API این پرامپت توسط سایت عمومی مصرف میشود. پرامپت همتا در فرانت:
|
||||
> `nobat724_front/.claude/prompt/booking-lock-patient-flow.md`
|
||||
|
||||
## زمینه
|
||||
|
||||
چرخهی نوبتگیری تا حد خوبی پیاده است ولی سه شکاف منطقی دارد:
|
||||
|
||||
1. **قفل بدون انقضا:** `AppointmentRepository::isSlotTaken()` وضعیتهای `pending` و `confirmed` را «گرفته» حساب میکند. پس بهمحض ساخت نوبت (`POST /api/v1/appointment` → status=`pending`)، اسلات قفل میشود و درخواست همزمان دیگر `409` میگیرد. **اما** هیچ انقضای ۱۵دقیقهای وجود ندارد: اگر کاربر پرداخت نکند، نوبت تا ابد `pending` میماند و اسلات برای همیشه قفل میشود. تنها انقضای موجود (`findExpiredPending` + `CancelExpiredAppointmentsCommand`) بر اساس **گذشتن زمان ویزیت** (`slotStart < now`) است، نه مهلت پرداخت.
|
||||
|
||||
2. **پرداخت موفق نوبت را تأیید نمیکند:** در `PaymentController::handleCallback` پس از موفقیت، فقط `subscription` و `sms_wallet` پردازش میشوند؛ برای `TYPE_APPOINTMENT` هیچ کاری نمیشود — نوبت `pending` میماند، SMS/اعلان ارسال نمیشود.
|
||||
|
||||
3. **اطلاعات بیمار جدا از پرداختکننده نیست:** `Appointment` فقط به `user` (پرداختکنندهی لاگینشده) وصل است و هیچ فیلد بیماری (نام، موبایل، کد ملی، جنسیت، علت) ندارد. «نوبت برای شخص دیگر» جایی ذخیره نمیشود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
پیادهسازی منطق نوبتگیری مرحلهبهمرحله با تصمیمات قطعیشده:
|
||||
|
||||
- **قفل بعد از لاگین (مرحله Detail):** نوبت `pending` همان لحظهی ثبت اطلاعات ساخته میشود و یک `expires_at = now + 15min` میگیرد. تا انقضا اسلات قفل است؛ بعد از انقضای بدونپرداخت → `expired` و اسلات آزاد.
|
||||
- **اطلاعات بیمار روی خود `Appointment`:** فیلدهای `patient_name`, `patient_mobile`, `patient_national_code`, `patient_gender`, `patient_reason`. `user` = پرداختکننده (همیشه پر، از توکن). اگر «برای خودم» باشد این فیلدها از پروفایل پر میشوند؛ اگر «برای دیگری»، از فرم.
|
||||
- **پرداخت موفق → تأیید + SMS + اعلان:** callback برای `TYPE_APPOINTMENT` نوبت را `pending → confirmed` کند، و SMS تأیید به موبایل بیمار بفرستد.
|
||||
- **اتمیک و ضد همزمانی:** ساخت نوبت باید در برابر رزرو همزمان مقاوم باشد (دو کاربر، یک اسلات → فقط یکی موفق).
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Appointment/Entity/Appointment.php` | افزودن `expiresAt` + فیلدهای بیمار؛ status machine موجود |
|
||||
| `src/Appointment/Repository/AppointmentRepository.php` | `isSlotTaken` (لحاظکردن انقضا)، `findExpiredPending` (بر اساس مهلت پرداخت) |
|
||||
| `src/Appointment/Controller/AppointmentController.php` | `book()` — دریافت فیلدهای بیمار + ست `expiresAt` + اتمیک |
|
||||
| `src/Appointment/Command/CancelExpiredAppointmentsCommand.php` | انقضای نوبتهای پرداختنشده |
|
||||
| `src/Payment/Controller/PaymentController.php` | `handleCallback` — شاخهی `TYPE_APPOINTMENT`: confirm + SMS |
|
||||
| `src/Sms/Service/SmsService.php` | ارسال SMS تأیید |
|
||||
| `migrations/` | migration برای ستونهای جدید |
|
||||
| `docs/api/appointment.md`, `docs/api/payment.md` | مستندسازی |
|
||||
|
||||
## وضعیت فعلی (کد واقعی)
|
||||
|
||||
### `Appointment` — وضعیتها، optimistic lock، بدون expiresAt/بیمار
|
||||
|
||||
```php
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
// ...
|
||||
public const ALLOWED_TRANSITIONS = [
|
||||
self::STATUS_PENDING => [self::STATUS_CONFIRMED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_EXPIRED],
|
||||
self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
|
||||
];
|
||||
|
||||
#[ORM\Version]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)] // پرداختکننده/booker
|
||||
private User $user;
|
||||
#[ORM\Column(name: 'slot_start', type: 'integer')] private int $slotStart;
|
||||
#[ORM\Column(name: 'slot_end', type: 'integer')] private int $slotEnd;
|
||||
#[ORM\Column(type: 'string', length: 30)] private string $status = self::STATUS_PENDING;
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)] private ?string $note = null;
|
||||
```
|
||||
|
||||
### `isSlotTaken` — pending را قفل میکند ولی انقضا را نمیبیند
|
||||
|
||||
```php
|
||||
->andWhere('a.status IN (:activeStatuses)')
|
||||
->setParameter('activeStatuses', [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED])
|
||||
->andWhere('a.slotStart < :slotEnd')
|
||||
->andWhere('a.slotEnd > :slotStart')
|
||||
```
|
||||
|
||||
### `book()` — بدون فیلد بیمار، بدون expiresAt
|
||||
|
||||
```php
|
||||
$slotStart = (int) ($data['slot_start'] ?? 0);
|
||||
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
||||
// ... validation, $slotStart < time() rejected ...
|
||||
if ($this->appointmentRepo->isSlotTaken($doctor, $slotStart, $slotEnd)) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
|
||||
}
|
||||
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
||||
if (isset($data['note'])) $appointment->setNote($data['note']);
|
||||
$this->appointmentRepo->save($appointment);
|
||||
return $this->success(['data' => $appointment->toArray()], 201);
|
||||
```
|
||||
|
||||
### `findExpiredPending` + Command — مبنا «زمان ویزیت گذشته» (نه مهلت پرداخت)
|
||||
|
||||
```php
|
||||
public function findExpiredPending(int $before): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.status = :status')->andWhere('a.slotStart < :before')
|
||||
->setParameter('status', Appointment::STATUS_PENDING)
|
||||
->setParameter('before', $before)
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
```
|
||||
|
||||
### `PaymentController::handleCallback` — شاخهی appointment غایب
|
||||
|
||||
```php
|
||||
$payment->setStatus(Payment::STATUS_SUCCESS);
|
||||
$payment->setReferenceId($result->referenceId);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) {
|
||||
$this->handleSubscriptionActivation($payment);
|
||||
} elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) {
|
||||
$this->handleSmsWalletCharge($payment);
|
||||
}
|
||||
// ❌ هیچ شاخهای برای TYPE_APPOINTMENT
|
||||
return $this->redirectToFrontend($payment, true);
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
اجرای مرحلهبهمرحله؛ بعد از هر قابلیت: `php -l`، در صورت تغییر Entity → `migrations:diff` + `migrate`، و تست؛ سپس commit.
|
||||
|
||||
### ۱. افزودن `expiresAt` و فیلدهای بیمار به `Appointment` (+ migration)
|
||||
|
||||
- ستونها:
|
||||
- `expires_at` (integer, nullable) — Unix؛ فقط برای `pending` معنا دارد. `confirmed` → `null`.
|
||||
- `patient_name` (string, nullable), `patient_mobile` (string, nullable), `patient_national_code` (string, nullable), `patient_gender` (string, nullable), `patient_reason` (string|text, nullable).
|
||||
- یک ثابت `const PAYMENT_TTL = 900;` (۱۵ دقیقه).
|
||||
- متد `markPendingWithTtl(int $ttl)` که `expiresAt = time() + $ttl` ست کند، و در `transitionTo(CONFIRMED)` مقدار `expiresAt` را `null` کن.
|
||||
- setter/getterهای فیلدهای بیمار + اضافهکردنشان به `toArray()` (با کلیدهای `patient_*` و `expires_at`).
|
||||
- `migrations:diff` → بازبینی → `migrate`. ستونها nullable تا رکوردهای موجود نشکنند.
|
||||
|
||||
### ۲. لحاظکردن انقضا در `isSlotTaken` (آزادسازی نرم قبل از اجرای Command)
|
||||
|
||||
اسلات فقط وقتی «گرفته» است که `confirmed` باشد، **یا** `pending`ای که هنوز منقضی نشده (`expires_at > now` یا `expires_at IS NULL`). pendingِ منقضی نباید قفل کند (حتی اگر Command هنوز اجرا نشده):
|
||||
|
||||
```php
|
||||
->andWhere('a.slotStart < :slotEnd')
|
||||
->andWhere('a.slotEnd > :slotStart')
|
||||
->andWhere(
|
||||
'a.status = :confirmed OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
|
||||
)
|
||||
->setParameter('confirmed', Appointment::STATUS_CONFIRMED)
|
||||
->setParameter('pending', Appointment::STATUS_PENDING)
|
||||
->setParameter('now', time())
|
||||
```
|
||||
|
||||
> این تضمین میکند حتی اگر Command دیر اجرا شود، اسلاتِ pendingِ منقضی فوراً برای کاربر بعدی آزاد است.
|
||||
|
||||
### ۳. ساخت نوبت اتمیک + ست expiresAt + فیلدهای بیمار در `book()`
|
||||
|
||||
- ورودیهای جدید (همه اختیاری جز وقتی برای دیگری است): `patient_name`, `patient_mobile`, `patient_national_code`, `patient_gender`, `patient_reason`, و یک فلگ `for_self` (boolean).
|
||||
- اگر `for_self === true` (یا فیلد بیمار نیامد): `patient_*` را از پروفایل کاربر لاگینشده پر کن (نام، موبایل کاربر). اگر `for_self === false`: `patient_name` و `patient_mobile` اجباریاند (۴۲۲ اگر نبودند).
|
||||
- بعد از ساخت، `markPendingWithTtl(Appointment::PAYMENT_TTL)` را صدا بزن.
|
||||
- **اتمیک/concurrency:** الگوی فعلی (`isSlotTaken` سپس insert) بین دو درخواست همزمان race دارد. برای اتمیککردن:
|
||||
- یک **unique constraint** در سطح DB روی `(doctor_id, slot_start)` فقط برای ردیفهای فعال ممکن نیست (MySQL partial unique ندارد). بهجایش: `isSlotTaken` را داخل یک تراکنش با قفل اجرا کن، یا روی insert از `UniqueConstraint(doctor_id, slot_start, status)` استفاده کن و در صورت `UniqueConstraintViolationException` آن را به `409` ترجمه کن.
|
||||
- **رویکرد پیشنهادی (ساده و مؤثر):** کل عملیات را در `wrapInTransaction` بپیچ؛ `isSlotTaken` با `LockMode::PESSIMISTIC_WRITE` روی ردیفهای همبازه، سپس insert. اگر این پیچیده شد، از optimistic موجود + ترجمهی `UniqueConstraintViolationException`/خطای رزرو همزمان به `ERR_CONFLICT_001` (۴۰۹) استفاده کن. **هر روشی انتخاب کردی، در پرامپت/کامیت توضیح بده و تست همزمانی را توصیف کن.**
|
||||
- اگر کاربر یک نوبت `pending` فعالِ منقضینشده روی همین اسلات و همین doctor دارد، بهجای ساخت دوباره همان را برگردان (idempotency سبک) — اختیاری ولی UX بهتر.
|
||||
|
||||
### ۴. انقضای نوبتهای پرداختنشده
|
||||
|
||||
- `findExpiredPending` را به مبنای **مهلت پرداخت** تغییر بده (یا یک متد جدید `findPaymentExpired`):
|
||||
|
||||
```php
|
||||
public function findPaymentExpired(int $now): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.status = :pending')
|
||||
->andWhere('a.expiresAt IS NOT NULL')
|
||||
->andWhere('a.expiresAt < :now')
|
||||
->setParameter('pending', Appointment::STATUS_PENDING)
|
||||
->setParameter('now', $now)
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
```
|
||||
|
||||
- `CancelExpiredAppointmentsCommand` را به این متد وصل کن و هر کدام را `transitionTo(STATUS_EXPIRED)` کن. (رفتار «زمان ویزیت گذشته» را اگر لازم است در یک متد/منطق جدا نگهدار؛ اگر تکراری شد ادغام کن.)
|
||||
- این Command باید مرتب اجرا شود (هر ۱ دقیقه). با `messenger:consume` یا cron. اگر زیرساخت scheduler/cron در پروژه هست از همان استفاده کن؛ اگر نیست، در مستندات ذکر کن که باید cron تنظیم شود (مثلاً `* * * * * php bin/console app:cancel-expired-appointments`).
|
||||
|
||||
### ۵. تأیید نوبت + SMS در callback پرداخت موفق
|
||||
|
||||
در `PaymentController::handleCallback` بعد از `STATUS_SUCCESS`، شاخهی `TYPE_APPOINTMENT` اضافه کن:
|
||||
|
||||
```php
|
||||
} elseif ($payment->getType() === Payment::TYPE_APPOINTMENT) {
|
||||
$this->handleAppointmentConfirmation($payment);
|
||||
}
|
||||
```
|
||||
|
||||
و متد:
|
||||
|
||||
```php
|
||||
private function handleAppointmentConfirmation(Payment $payment): void
|
||||
{
|
||||
$appointment = $payment->getAppointment();
|
||||
if ($appointment === null) return;
|
||||
if ($appointment->getStatus() === Appointment::STATUS_PENDING) {
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED); // expiresAt → null داخل transition
|
||||
$this->appointmentRepo->save($appointment);
|
||||
// SMS تأیید به موبایل بیمار
|
||||
$this->smsService->...($appointment->getPatientMobile(), ...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- از `SmsService` موجود برای ارسال استفاده کن؛ شکل واقعی متد ارسال را از `src/Sms/Service/SmsService.php` بخوان (حدس نزن). اگر قالب SMS از `SmsTemplate` میآید، از همان الگو پیروی کن.
|
||||
- «اعلانهای لازم» (notification) — اگر زیرساخت notification جدا در پروژه هست از آن استفاده کن؛ اگر نیست، فعلاً فقط SMS کافی است و در گزارش ذکر کن.
|
||||
- اگر نوبت قبلاً به هر دلیل `expired` شده ولی پرداخت موفق شد (race نادر): تصمیم بگیر (refund/خطا) — حداقل لاگ کن و نوبت را confirmed نکن اگر transition مجاز نیست (`canTransitionTo` را چک کن).
|
||||
|
||||
### ۶. مستندسازی
|
||||
|
||||
- `docs/api/appointment.md`: فیلدهای جدید request `book()` (`patient_*`, `for_self`)، فیلدهای جدید response (`patient_*`, `expires_at`)، و توضیح قفل ۱۵دقیقهای + انقضا.
|
||||
- `docs/api/payment.md`: توضیح که پرداخت موفقِ نوع appointment، نوبت را confirmed و SMS تأیید ارسال میکند.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **تاریخها Unix timestamp صحیح** (نه DateTime). `expiresAt`, `slotStart` همه integer.
|
||||
- **status machine موجود را حفظ کن:** transitions از `ALLOWED_TRANSITIONS` عبور کنند؛ مستقیم `status` را ست نکن، از `transitionTo` استفاده کن. `pending → expired` و `pending → confirmed` از قبل مجازند.
|
||||
- **`isSlotTaken` قلب همهجاست** (هم `book`، هم `SlotCalculatorService`). تغییر آن روی نمایش اسلاتها هم اثر دارد — یعنی اسلاتی که pendingِ منقضی دارد دوباره `is_available` میشود؛ این **مطلوب** است.
|
||||
- **اتمیکبودن را واقعاً تست کن:** دو درخواست همزمان `book` روی یک اسلات → یکی ۲۰۱، دیگری ۴۰۹. اگر optimistic version کافی نیست، unique constraint یا pessimistic lock اضافه کن.
|
||||
- **پرداختکننده ≠ بیمار:** `user` همیشه از توکن (پرداختکننده)؛ `patient_*` جداگانه. هرگز `user` را با بیمار قاطی نکن.
|
||||
- همه controllerها از `BaseController`؛ پاسخها `success()`/`error()`؛ خطاها با `AppException`/`ErrorCodes`.
|
||||
- بعد از تغییر Entity حتماً `migrations:diff` و `migrate`. ستونها nullable.
|
||||
- تستها:
|
||||
- `book` با `for_self:false` و فیلد بیمار → نوبت با `patient_*` و `expires_at ≈ now+900`.
|
||||
- بدون پرداخت، بعد از گذشت TTL: `isSlotTaken` همان اسلات → false؛ و Command نوبت را `expired` کند.
|
||||
- `curl` کامل callback پرداخت موفق (یا تست واحد) → نوبت `confirmed`، `expires_at=null`، SMS لاگ شود.
|
||||
- دو `book` همزمان روی یک اسلات → فقط یکی موفق.
|
||||
+26
-6
@@ -129,6 +129,12 @@ Book an appointment slot.
|
||||
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"slot_start": 1718438400,
|
||||
"slot_end": 1718439600,
|
||||
"for_self": false,
|
||||
"patient_name": "علی احمدی",
|
||||
"patient_mobile": "09120000000",
|
||||
"patient_national_code": "0012345678",
|
||||
"patient_gender": "male",
|
||||
"patient_reason": "چکاپ",
|
||||
"note": "لطفاً سریع ویزیت شوم"
|
||||
}
|
||||
```
|
||||
@@ -138,21 +144,35 @@ Book an appointment slot.
|
||||
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
|
||||
| `slot_start` | integer | ✅ | Slot start (Unix timestamp) |
|
||||
| `slot_end` | integer | ✅ | Slot end (Unix timestamp) |
|
||||
| `for_self` | boolean | ❌ | `true` (default) = patient is the logged-in payer; `false` = booking for someone else |
|
||||
| `patient_name` | string | ⚠️ | Required when `for_self=false`; otherwise filled from the payer's profile |
|
||||
| `patient_mobile` | string | ⚠️ | Required when `for_self=false`; otherwise the payer's mobile |
|
||||
| `patient_national_code` | string | ❌ | Patient national code (only when for another person) |
|
||||
| `patient_gender` | string | ❌ | `male` / `female` |
|
||||
| `patient_reason` | string | ❌ | Reason for visit |
|
||||
| `note` | string | ❌ | Patient note |
|
||||
|
||||
> **Payer vs patient:** the authenticated user (`user`) is always the payer; the `patient_*` fields describe who the visit is for and are stored separately. **Temporary lock:** the slot is held by the new `pending` booking for **15 minutes** (`expires_at = created_at + 900`). If payment is not completed in time, the booking is moved to `expired` and the slot is freed (see `app:cancel-expired-appointments`). An expired pending booking no longer blocks the slot even before the cron runs.
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "appt-uuid-...",
|
||||
"doctor": { "uuid": "...", "title": "دکتر علی احمدی" },
|
||||
"user": { "uuid": "...", "real_name": "..." },
|
||||
"doctor": { "uuid": "...", "name": "دکتر علی احمدی" },
|
||||
"user": { "uuid": "...", "mobile": "..." },
|
||||
"slot_start": 1718438400,
|
||||
"slot_end": 1718439600,
|
||||
"status": "pending",
|
||||
"note": "...",
|
||||
"price": 500000,
|
||||
"expires_at": 1718438100,
|
||||
"patient_name": "علی احمدی",
|
||||
"patient_mobile": "09120000000",
|
||||
"patient_national_code": "0012345678",
|
||||
"patient_gender": "male",
|
||||
"patient_reason": "چکاپ",
|
||||
"version": 1,
|
||||
"created_at": 1717000000
|
||||
}
|
||||
}
|
||||
@@ -171,9 +191,9 @@ Book an appointment slot.
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_001` | 401 | Missing token |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
||||
| `ERR_CONFLICT_001` | 409 | Slot already booked |
|
||||
| `ERR_VALIDATION_001` | 422 | Invalid slot times |
|
||||
| `ERR_VALIDATION_002` | 404 | Doctor not found |
|
||||
| `ERR_CONFLICT_001` | 409 | Slot already booked (incl. concurrent booking — the booking is atomic) |
|
||||
| `ERR_VALIDATION_001` | 422 | Invalid slot times, past slot, or missing patient name/mobile when `for_self=false` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew
|
||||
}
|
||||
```
|
||||
|
||||
> **On successful callback** for an appointment payment, the booking is transitioned `pending → confirmed` (its 15-minute `expires_at` is cleared) and a confirmation SMS is dispatched to the patient's mobile. If the booking already lapsed to `expired` before payment confirmed, it is **not** re-confirmed (the transition is rejected) — handle refund out of band.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260615142236 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE appointments ADD expires_at INT DEFAULT NULL, ADD patient_name VARCHAR(150) DEFAULT NULL, ADD patient_mobile VARCHAR(20) DEFAULT NULL, ADD patient_national_code VARCHAR(20) DEFAULT NULL, ADD patient_gender VARCHAR(10) DEFAULT NULL, ADD patient_reason LONGTEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE appointments DROP expires_at, DROP patient_name, DROP patient_mobile, DROP patient_national_code, DROP patient_gender, DROP patient_reason');
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:cancel-expired-appointments',
|
||||
description: 'Marks pending appointments whose slot_start is in the past as expired',
|
||||
description: 'Expires pending bookings whose 15-min payment window lapsed or whose slot time has passed',
|
||||
)]
|
||||
class CancelExpiredAppointmentsCommand extends Command
|
||||
{
|
||||
@@ -22,9 +22,14 @@ class CancelExpiredAppointmentsCommand extends Command
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$expired = $this->appointmentRepo->findExpiredPending(time());
|
||||
$count = 0;
|
||||
$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);
|
||||
@@ -32,7 +37,7 @@ class CancelExpiredAppointmentsCommand extends Command
|
||||
}
|
||||
|
||||
if ($count > 0) {
|
||||
$this->appointmentRepo->save($expired[0]); // flush once
|
||||
$this->appointmentRepo->save(reset($expired)); // flush once
|
||||
}
|
||||
|
||||
$output->writeln(sprintf('Expired %d appointments.', $count));
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Appointment\Controller;
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Appointment\Repository\SlotTakenException;
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Appointment\Service\SlotCalculatorService;
|
||||
use App\Auth\Entity\User;
|
||||
@@ -230,14 +231,34 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($this->appointmentRepo->isSlotTaken($doctor, $slotStart, $slotEnd)) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
|
||||
}
|
||||
$forSelf = (bool) ($data['for_self'] ?? true);
|
||||
|
||||
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
||||
if (isset($data['note'])) $appointment->setNote($data['note']);
|
||||
|
||||
$this->appointmentRepo->save($appointment);
|
||||
if ($forSelf) {
|
||||
$appointment->setPatientName($user->getRealName());
|
||||
$appointment->setPatientMobile($user->getMobileNumber());
|
||||
} else {
|
||||
$patientName = trim($data['patient_name'] ?? '');
|
||||
$patientMobile = trim($data['patient_mobile'] ?? '');
|
||||
if ($patientName === '' || $patientMobile === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام و شماره موبایل بیمار الزامی است', 422);
|
||||
}
|
||||
$appointment->setPatientName($patientName);
|
||||
$appointment->setPatientMobile($patientMobile);
|
||||
$appointment->setPatientNationalCode($data['patient_national_code'] ?? null);
|
||||
$appointment->setPatientGender($data['patient_gender'] ?? null);
|
||||
$appointment->setPatientReason($data['patient_reason'] ?? null);
|
||||
}
|
||||
|
||||
$appointment->markPendingWithTtl(Appointment::PAYMENT_TTL);
|
||||
|
||||
try {
|
||||
$this->appointmentRepo->bookAtomically($appointment);
|
||||
} catch (SlotTakenException) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()], 201);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ class Appointment
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_NO_SHOW = 'no_show';
|
||||
|
||||
public const PAYMENT_TTL = 900; // 15 minutes to pay before a pending booking expires
|
||||
|
||||
public const ALLOWED_TRANSITIONS = [
|
||||
self::STATUS_PENDING => [self::STATUS_CONFIRMED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_EXPIRED],
|
||||
self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
|
||||
@@ -63,6 +65,24 @@ class Appointment
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $note = null;
|
||||
|
||||
#[ORM\Column(name: 'expires_at', type: 'integer', nullable: true)]
|
||||
private ?int $expiresAt = null;
|
||||
|
||||
#[ORM\Column(name: 'patient_name', type: 'string', length: 150, nullable: true)]
|
||||
private ?string $patientName = null;
|
||||
|
||||
#[ORM\Column(name: 'patient_mobile', type: 'string', length: 20, nullable: true)]
|
||||
private ?string $patientMobile = null;
|
||||
|
||||
#[ORM\Column(name: 'patient_national_code', type: 'string', length: 20, nullable: true)]
|
||||
private ?string $patientNationalCode = null;
|
||||
|
||||
#[ORM\Column(name: 'patient_gender', type: 'string', length: 10, nullable: true)]
|
||||
private ?string $patientGender = null;
|
||||
|
||||
#[ORM\Column(name: 'patient_reason', type: 'text', nullable: true)]
|
||||
private ?string $patientReason = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -89,8 +109,26 @@ class Appointment
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getNote(): ?string { return $this->note; }
|
||||
public function getVersion(): int { return $this->version; }
|
||||
public function getExpiresAt(): ?int { return $this->expiresAt; }
|
||||
public function getPatientName(): ?string { return $this->patientName; }
|
||||
public function getPatientMobile(): ?string { return $this->patientMobile; }
|
||||
public function getPatientNationalCode(): ?string { return $this->patientNationalCode; }
|
||||
public function getPatientGender(): ?string { return $this->patientGender; }
|
||||
public function getPatientReason(): ?string { return $this->patientReason; }
|
||||
|
||||
public function setNote(?string $v): self { $this->note = $v; return $this; }
|
||||
public function setPatientName(?string $v): self { $this->patientName = $v; return $this; }
|
||||
public function setPatientMobile(?string $v): self { $this->patientMobile = $v; return $this; }
|
||||
public function setPatientNationalCode(?string $v): self { $this->patientNationalCode = $v; return $this; }
|
||||
public function setPatientGender(?string $v): self { $this->patientGender = $v; return $this; }
|
||||
public function setPatientReason(?string $v): self { $this->patientReason = $v; return $this; }
|
||||
|
||||
public function markPendingWithTtl(int $ttl): self
|
||||
{
|
||||
$this->expiresAt = time() + $ttl;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function canTransitionTo(string $newStatus): bool
|
||||
{
|
||||
@@ -107,6 +145,9 @@ class Appointment
|
||||
}
|
||||
$this->status = $newStatus;
|
||||
$this->updatedAt = time();
|
||||
if ($newStatus !== self::STATUS_PENDING) {
|
||||
$this->expiresAt = null;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -126,6 +167,12 @@ class Appointment
|
||||
'slot_end' => $this->slotEnd,
|
||||
'status' => $this->status,
|
||||
'note' => $this->note,
|
||||
'expires_at' => $this->expiresAt,
|
||||
'patient_name' => $this->patientName,
|
||||
'patient_mobile' => $this->patientMobile,
|
||||
'patient_national_code' => $this->patientNationalCode,
|
||||
'patient_gender' => $this->patientGender,
|
||||
'patient_reason' => $this->patientReason,
|
||||
'version' => $this->version,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
|
||||
@@ -21,17 +21,39 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a booking atomically: re-check the slot inside a transaction so
|
||||
* two concurrent requests for the same slot cannot both succeed.
|
||||
*
|
||||
* @throws SlotTakenException if the slot is taken when the transaction commits
|
||||
*/
|
||||
public function bookAtomically(Appointment $appointment): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
$em->wrapInTransaction(function () use ($em, $appointment): void {
|
||||
if ($this->isSlotTaken($appointment->getDoctor(), $appointment->getSlotStart(), $appointment->getSlotEnd())) {
|
||||
throw new SlotTakenException();
|
||||
}
|
||||
$em->persist($appointment);
|
||||
$em->flush();
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if a slot is already taken (confirmed or pending) */
|
||||
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->select('COUNT(a.id)')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.status IN (:activeStatuses)')
|
||||
->andWhere('a.slotStart < :slotEnd')
|
||||
->andWhere('a.slotEnd > :slotStart')
|
||||
->andWhere(
|
||||
'a.status = :confirmed OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
|
||||
)
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('activeStatuses', [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED])
|
||||
->setParameter('confirmed', Appointment::STATUS_CONFIRMED)
|
||||
->setParameter('pending', Appointment::STATUS_PENDING)
|
||||
->setParameter('now', time())
|
||||
->setParameter('slotStart', $slotStart)
|
||||
->setParameter('slotEnd', $slotEnd);
|
||||
|
||||
@@ -58,6 +80,19 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
return $this->findBy($criteria, ['slotStart' => 'DESC']);
|
||||
}
|
||||
|
||||
/** @return Appointment[] pending bookings whose 15-minute payment window has lapsed */
|
||||
public function findPaymentExpired(int $now): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.status = :status')
|
||||
->andWhere('a.expiresAt IS NOT NULL')
|
||||
->andWhere('a.expiresAt < :now')
|
||||
->setParameter('status', Appointment::STATUS_PENDING)
|
||||
->setParameter('now', $now)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @return Appointment[] pending appointments older than given timestamp */
|
||||
public function findExpiredPending(int $before): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
class SlotTakenException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use App\Payment\Repository\PaymentRepository;
|
||||
use App\Payment\Service\CircuitBreakerService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Service\SmsService;
|
||||
use App\Sms\Service\SmsWalletService;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -44,6 +45,7 @@ class PaymentController extends BaseController
|
||||
private readonly CircuitBreakerService $circuitBreaker,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly SmsWalletService $smsWalletService,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
@@ -273,6 +275,8 @@ class PaymentController extends BaseController
|
||||
$this->handleSubscriptionActivation($payment);
|
||||
} elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) {
|
||||
$this->handleSmsWalletCharge($payment);
|
||||
} elseif ($payment->getType() === Payment::TYPE_APPOINTMENT) {
|
||||
$this->handleAppointmentConfirmation($payment);
|
||||
}
|
||||
|
||||
return $this->redirectToFrontend($payment, true);
|
||||
@@ -590,6 +594,26 @@ class PaymentController extends BaseController
|
||||
$this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment);
|
||||
}
|
||||
|
||||
private function handleAppointmentConfirmation(Payment $payment): void
|
||||
{
|
||||
$appointment = $payment->getAppointment();
|
||||
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$this->appointmentRepo->save($appointment);
|
||||
|
||||
$mobile = $appointment->getPatientMobile();
|
||||
if ($mobile) {
|
||||
$when = date('Y-m-d H:i', $appointment->getSlotStart());
|
||||
$this->smsService->dispatchAsync(
|
||||
$mobile,
|
||||
sprintf('نوبت شما با %s در تاریخ %s ثبت و تأیید شد.', $appointment->getDoctor()->getName(), $when)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleSubscriptionActivation(Payment $payment): void
|
||||
{
|
||||
$meta = $payment->getMetadata() ?? [];
|
||||
|
||||
Reference in New Issue
Block a user