# قفل ۱۵ دقیقه‌ای نوبت، اطلاعات بیمار جدا از پرداخت‌کننده، و تأیید پس از پرداخت

## پروژه

`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` هم‌زمان روی یک اسلات → فقط یکی موفق.
