Files
clinicpro/.claude/prompt/appointment-lock-patient-confirm.md
T
hamedandClaude Opus 4.8 bff7001f49 feat(appointment): add expires_at and patient fields to Appointment
Add a 15-minute payment TTL (expires_at) plus patient_name/mobile/
national_code/gender/reason columns so a booking can hold a slot
temporarily and record a patient distinct from the paying user. New
markPendingWithTtl() sets the lock; transitioning out of pending clears
expires_at. All columns nullable (migration added).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:53:27 +03:30

231 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# قفل ۱۵ دقیقه‌ای نوبت، اطلاعات بیمار جدا از پرداخت‌کننده، و تأیید پس از پرداخت
## پروژه
`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` هم‌زمان روی یک اسلات → فقط یکی موفق.