Files
clinicpro/.claude/prompt/appointment-lock-patient-confirm.md
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

16 KiB
Raw Permalink Blame History

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

پروژه

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/بیمار

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 را قفل می‌کند ولی انقضا را نمی‌بیند

->andWhere('a.status IN (:activeStatuses)')
->setParameter('activeStatuses', [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED])
->andWhere('a.slotStart < :slotEnd')
->andWhere('a.slotEnd > :slotStart')

book() — بدون فیلد بیمار، بدون expiresAt

$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 — مبنا «زمان ویزیت گذشته» (نه مهلت پرداخت)

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 غایب

$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 معنا دارد. confirmednull.
    • 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 هنوز اجرا نشده):

->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):
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 اضافه کن:

} elseif ($payment->getType() === Payment::TYPE_APPOINTMENT) {
    $this->handleAppointmentConfirmation($payment);
}

و متد:

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