From bff7001f49197c98fe94c36b3812d4e575e533db Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 15 Jun 2026 17:53:27 +0330 Subject: [PATCH] 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 --- .../appointment-lock-patient-confirm.md | 230 ++++++++++++++++++ migrations/Version20260615142236.php | 31 +++ src/Appointment/Entity/Appointment.php | 47 ++++ 3 files changed, 308 insertions(+) create mode 100644 .claude/prompt/appointment-lock-patient-confirm.md create mode 100644 migrations/Version20260615142236.php diff --git a/.claude/prompt/appointment-lock-patient-confirm.md b/.claude/prompt/appointment-lock-patient-confirm.md new file mode 100644 index 00000000..2fcf43a0 --- /dev/null +++ b/.claude/prompt/appointment-lock-patient-confirm.md @@ -0,0 +1,230 @@ +# قفل ۱۵ دقیقه‌ای نوبت، اطلاعات بیمار جدا از پرداخت‌کننده، و تأیید پس از پرداخت + +## پروژه + +`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` هم‌زمان روی یک اسلات → فقط یکی موفق. diff --git a/migrations/Version20260615142236.php b/migrations/Version20260615142236.php new file mode 100644 index 00000000..f905b10b --- /dev/null +++ b/migrations/Version20260615142236.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE appointments ADD expires_at INT DEFAULT NULL, ADD patient_name VARCHAR(150) DEFAULT NULL, ADD patient_mobile VARCHAR(20) DEFAULT NULL, ADD patient_national_code VARCHAR(20) DEFAULT NULL, ADD patient_gender VARCHAR(10) DEFAULT NULL, ADD patient_reason LONGTEXT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE appointments DROP expires_at, DROP patient_name, DROP patient_mobile, DROP patient_national_code, DROP patient_gender, DROP patient_reason'); + } +} diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 06e85382..e28317fb 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -25,6 +25,8 @@ class Appointment public const STATUS_EXPIRED = 'expired'; public const STATUS_NO_SHOW = 'no_show'; + public const PAYMENT_TTL = 900; // 15 minutes to pay before a pending booking expires + 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], @@ -63,6 +65,24 @@ class Appointment #[ORM\Column(type: 'string', length: 255, nullable: true)] private ?string $note = null; + #[ORM\Column(name: 'expires_at', type: 'integer', nullable: true)] + private ?int $expiresAt = null; + + #[ORM\Column(name: 'patient_name', type: 'string', length: 150, nullable: true)] + private ?string $patientName = null; + + #[ORM\Column(name: 'patient_mobile', type: 'string', length: 20, nullable: true)] + private ?string $patientMobile = null; + + #[ORM\Column(name: 'patient_national_code', type: 'string', length: 20, nullable: true)] + private ?string $patientNationalCode = null; + + #[ORM\Column(name: 'patient_gender', type: 'string', length: 10, nullable: true)] + private ?string $patientGender = null; + + #[ORM\Column(name: 'patient_reason', type: 'text', nullable: true)] + private ?string $patientReason = null; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -89,8 +109,26 @@ class Appointment public function getStatus(): string { return $this->status; } public function getNote(): ?string { return $this->note; } public function getVersion(): int { return $this->version; } + public function getExpiresAt(): ?int { return $this->expiresAt; } + public function getPatientName(): ?string { return $this->patientName; } + public function getPatientMobile(): ?string { return $this->patientMobile; } + public function getPatientNationalCode(): ?string { return $this->patientNationalCode; } + public function getPatientGender(): ?string { return $this->patientGender; } + public function getPatientReason(): ?string { return $this->patientReason; } public function setNote(?string $v): self { $this->note = $v; return $this; } + public function setPatientName(?string $v): self { $this->patientName = $v; return $this; } + public function setPatientMobile(?string $v): self { $this->patientMobile = $v; return $this; } + public function setPatientNationalCode(?string $v): self { $this->patientNationalCode = $v; return $this; } + public function setPatientGender(?string $v): self { $this->patientGender = $v; return $this; } + public function setPatientReason(?string $v): self { $this->patientReason = $v; return $this; } + + public function markPendingWithTtl(int $ttl): self + { + $this->expiresAt = time() + $ttl; + $this->updatedAt = time(); + return $this; + } public function canTransitionTo(string $newStatus): bool { @@ -107,6 +145,9 @@ class Appointment } $this->status = $newStatus; $this->updatedAt = time(); + if ($newStatus !== self::STATUS_PENDING) { + $this->expiresAt = null; + } return $this; } @@ -126,6 +167,12 @@ class Appointment 'slot_end' => $this->slotEnd, 'status' => $this->status, 'note' => $this->note, + 'expires_at' => $this->expiresAt, + 'patient_name' => $this->patientName, + 'patient_mobile' => $this->patientMobile, + 'patient_national_code' => $this->patientNationalCode, + 'patient_gender' => $this->patientGender, + 'patient_reason' => $this->patientReason, 'version' => $this->version, 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt,