diff --git a/.claude/prompt/appointment-lock-patient-confirm.md b/.claude/prompt/appointment-lock-patient-confirm.md new file mode 100644 index 00000000..2fcf43a0 --- /dev/null +++ b/.claude/prompt/appointment-lock-patient-confirm.md @@ -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` هم‌زمان روی یک اسلات → فقط یکی موفق. diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 9097d0cd..83d6e85d 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -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` | --- diff --git a/docs/api/payment.md b/docs/api/payment.md index a27b4fa5..760247b7 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -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 | |------|------|-------------| diff --git a/migrations/Version20260615142236.php b/migrations/Version20260615142236.php new file mode 100644 index 00000000..f905b10b --- /dev/null +++ b/migrations/Version20260615142236.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/src/Appointment/Command/CancelExpiredAppointmentsCommand.php b/src/Appointment/Command/CancelExpiredAppointmentsCommand.php index f1fd98b8..61d20a75 100644 --- a/src/Appointment/Command/CancelExpiredAppointmentsCommand.php +++ b/src/Appointment/Command/CancelExpiredAppointmentsCommand.php @@ -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)); diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index e9222475..6d9b1790 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -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); } diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 06e85382..e28317fb 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -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, diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php index 7b12012d..f86aab47 100644 --- a/src/Appointment/Repository/AppointmentRepository.php +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -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 { diff --git a/src/Appointment/Repository/SlotTakenException.php b/src/Appointment/Repository/SlotTakenException.php new file mode 100644 index 00000000..8821bbd3 --- /dev/null +++ b/src/Appointment/Repository/SlotTakenException.php @@ -0,0 +1,7 @@ +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() ?? [];