# قفل ۱۵ دقیقه‌ای نوبت، اطلاعات بیمار جدا از پرداخت‌کننده، و تأیید پس از پرداخت ## پروژه `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` هم‌زمان روی یک اسلات → فقط یکی موفق.