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>
16 KiB
قفل ۱۵ دقیقهای نوبت، اطلاعات بیمار جدا از پرداختکننده، و تأیید پس از پرداخت
پروژه
clinicpro (Backend). این پرامپت اول اجرا شود.
Cross-repo: قرارداد API این پرامپت توسط سایت عمومی مصرف میشود. پرامپت همتا در فرانت:
nobat724_front/.claude/prompt/booking-lock-patient-flow.md
زمینه
چرخهی نوبتگیری تا حد خوبی پیاده است ولی سه شکاف منطقی دارد:
-
قفل بدون انقضا:
AppointmentRepository::isSlotTaken()وضعیتهایpendingوconfirmedرا «گرفته» حساب میکند. پس بهمحض ساخت نوبت (POST /api/v1/appointment→ status=pending)، اسلات قفل میشود و درخواست همزمان دیگر409میگیرد. اما هیچ انقضای ۱۵دقیقهای وجود ندارد: اگر کاربر پرداخت نکند، نوبت تا ابدpendingمیماند و اسلات برای همیشه قفل میشود. تنها انقضای موجود (findExpiredPending+CancelExpiredAppointmentsCommand) بر اساس گذشتن زمان ویزیت (slotStart < now) است، نه مهلت پرداخت. -
پرداخت موفق نوبت را تأیید نمیکند: در
PaymentController::handleCallbackپس از موفقیت، فقطsubscriptionوsms_walletپردازش میشوند؛ برایTYPE_APPOINTMENTهیچ کاری نمیشود — نوبتpendingمیماند، SMS/اعلان ارسال نمیشود. -
اطلاعات بیمار جدا از پرداختکننده نیست:
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معنا دارد.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 هنوز اجرا نشده):
->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(۴۰۹) استفاده کن. هر روشی انتخاب کردی، در پرامپت/کامیت توضیح بده و تست همزمانی را توصیف کن.
- یک unique constraint در سطح DB روی
- اگر کاربر یک نوبت
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: فیلدهای جدید requestbook()(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همزمان روی یک اسلات → فقط یکی موفق.