Merge branch 'feat/appointment-lock-patient'

# Conflicts:
#	src/Appointment/Controller/AppointmentController.php
This commit is contained in:
hamed
2026-06-15 18:29:25 +03:30
10 changed files with 438 additions and 16 deletions
@@ -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` هم‌زمان روی یک اسلات → فقط یکی موفق.
+26 -6
View File
@@ -129,6 +129,12 @@ Book an appointment slot.
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"slot_start": 1718438400,
"slot_end": 1718439600,
"for_self": false,
"patient_name": "علی احمدی",
"patient_mobile": "09120000000",
"patient_national_code": "0012345678",
"patient_gender": "male",
"patient_reason": "چکاپ",
"note": "لطفاً سریع ویزیت شوم"
}
```
@@ -138,21 +144,35 @@ Book an appointment slot.
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
| `slot_start` | integer | ✅ | Slot start (Unix timestamp) |
| `slot_end` | integer | ✅ | Slot end (Unix timestamp) |
| `for_self` | boolean | ❌ | `true` (default) = patient is the logged-in payer; `false` = booking for someone else |
| `patient_name` | string | ⚠️ | Required when `for_self=false`; otherwise filled from the payer's profile |
| `patient_mobile` | string | ⚠️ | Required when `for_self=false`; otherwise the payer's mobile |
| `patient_national_code` | string | ❌ | Patient national code (only when for another person) |
| `patient_gender` | string | ❌ | `male` / `female` |
| `patient_reason` | string | ❌ | Reason for visit |
| `note` | string | ❌ | Patient note |
> **Payer vs patient:** the authenticated user (`user`) is always the payer; the `patient_*` fields describe who the visit is for and are stored separately. **Temporary lock:** the slot is held by the new `pending` booking for **15 minutes** (`expires_at = created_at + 900`). If payment is not completed in time, the booking is moved to `expired` and the slot is freed (see `app:cancel-expired-appointments`). An expired pending booking no longer blocks the slot even before the cron runs.
### Response `201`
```json
{
"success": true,
"data": {
"uuid": "appt-uuid-...",
"doctor": { "uuid": "...", "title": "دکتر علی احمدی" },
"user": { "uuid": "...", "real_name": "..." },
"doctor": { "uuid": "...", "name": "دکتر علی احمدی" },
"user": { "uuid": "...", "mobile": "..." },
"slot_start": 1718438400,
"slot_end": 1718439600,
"status": "pending",
"note": "...",
"price": 500000,
"expires_at": 1718438100,
"patient_name": "علی احمدی",
"patient_mobile": "09120000000",
"patient_national_code": "0012345678",
"patient_gender": "male",
"patient_reason": "چکاپ",
"version": 1,
"created_at": 1717000000
}
}
@@ -171,9 +191,9 @@ Book an appointment slot.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| `ERR_CONFLICT_001` | 409 | Slot already booked |
| `ERR_VALIDATION_001` | 422 | Invalid slot times |
| `ERR_VALIDATION_002` | 404 | Doctor not found |
| `ERR_CONFLICT_001` | 409 | Slot already booked (incl. concurrent booking — the booking is atomic) |
| `ERR_VALIDATION_001` | 422 | Invalid slot times, past slot, or missing patient name/mobile when `for_self=false` |
---
+2
View File
@@ -62,6 +62,8 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew
}
```
> **On successful callback** for an appointment payment, the booking is transitioned `pending → confirmed` (its 15-minute `expires_at` is cleared) and a confirmation SMS is dispatched to the patient's mobile. If the booking already lapsed to `expired` before payment confirmed, it is **not** re-confirmed (the transition is rejected) — handle refund out of band.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260615142236 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
@@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:cancel-expired-appointments',
description: 'Marks pending appointments whose slot_start is in the past as expired',
description: 'Expires pending bookings whose 15-min payment window lapsed or whose slot time has passed',
)]
class CancelExpiredAppointmentsCommand extends Command
{
@@ -22,9 +22,14 @@ class CancelExpiredAppointmentsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$expired = $this->appointmentRepo->findExpiredPending(time());
$count = 0;
$now = time();
$expired = [];
foreach ([...$this->appointmentRepo->findPaymentExpired($now), ...$this->appointmentRepo->findExpiredPending($now)] as $appointment) {
$expired[$appointment->getUuid()] = $appointment;
}
$count = 0;
foreach ($expired as $appointment) {
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
$this->appointmentRepo->save($appointment, false);
@@ -32,7 +37,7 @@ class CancelExpiredAppointmentsCommand extends Command
}
if ($count > 0) {
$this->appointmentRepo->save($expired[0]); // flush once
$this->appointmentRepo->save(reset($expired)); // flush once
}
$output->writeln(sprintf('Expired %d appointments.', $count));
@@ -5,6 +5,7 @@ namespace App\Appointment\Controller;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\SlotTakenException;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Appointment\Service\SlotCalculatorService;
use App\Auth\Entity\User;
@@ -230,14 +231,34 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($this->appointmentRepo->isSlotTaken($doctor, $slotStart, $slotEnd)) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
}
$forSelf = (bool) ($data['for_self'] ?? true);
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
if (isset($data['note'])) $appointment->setNote($data['note']);
$this->appointmentRepo->save($appointment);
if ($forSelf) {
$appointment->setPatientName($user->getRealName());
$appointment->setPatientMobile($user->getMobileNumber());
} else {
$patientName = trim($data['patient_name'] ?? '');
$patientMobile = trim($data['patient_mobile'] ?? '');
if ($patientName === '' || $patientMobile === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام و شماره موبایل بیمار الزامی است', 422);
}
$appointment->setPatientName($patientName);
$appointment->setPatientMobile($patientMobile);
$appointment->setPatientNationalCode($data['patient_national_code'] ?? null);
$appointment->setPatientGender($data['patient_gender'] ?? null);
$appointment->setPatientReason($data['patient_reason'] ?? null);
}
$appointment->markPendingWithTtl(Appointment::PAYMENT_TTL);
try {
$this->appointmentRepo->bookAtomically($appointment);
} catch (SlotTakenException) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
}
return $this->success(['data' => $appointment->toArray()], 201);
}
+47
View File
@@ -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,
@@ -21,17 +21,39 @@ class AppointmentRepository extends ServiceEntityRepository
return $this->findOneBy(['uuid' => $uuid]);
}
/**
* Persist a booking atomically: re-check the slot inside a transaction so
* two concurrent requests for the same slot cannot both succeed.
*
* @throws SlotTakenException if the slot is taken when the transaction commits
*/
public function bookAtomically(Appointment $appointment): void
{
$em = $this->getEntityManager();
$em->wrapInTransaction(function () use ($em, $appointment): void {
if ($this->isSlotTaken($appointment->getDoctor(), $appointment->getSlotStart(), $appointment->getSlotEnd())) {
throw new SlotTakenException();
}
$em->persist($appointment);
$em->flush();
});
}
/** Check if a slot is already taken (confirmed or pending) */
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
{
$qb = $this->createQueryBuilder('a')
->select('COUNT(a.id)')
->where('a.doctor = :doctor')
->andWhere('a.status IN (:activeStatuses)')
->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('doctor', $doctor)
->setParameter('activeStatuses', [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED])
->setParameter('confirmed', Appointment::STATUS_CONFIRMED)
->setParameter('pending', Appointment::STATUS_PENDING)
->setParameter('now', time())
->setParameter('slotStart', $slotStart)
->setParameter('slotEnd', $slotEnd);
@@ -58,6 +80,19 @@ class AppointmentRepository extends ServiceEntityRepository
return $this->findBy($criteria, ['slotStart' => 'DESC']);
}
/** @return Appointment[] pending bookings whose 15-minute payment window has lapsed */
public function findPaymentExpired(int $now): array
{
return $this->createQueryBuilder('a')
->where('a.status = :status')
->andWhere('a.expiresAt IS NOT NULL')
->andWhere('a.expiresAt < :now')
->setParameter('status', Appointment::STATUS_PENDING)
->setParameter('now', $now)
->getQuery()
->getResult();
}
/** @return Appointment[] pending appointments older than given timestamp */
public function findExpiredPending(int $before): array
{
@@ -0,0 +1,7 @@
<?php
namespace App\Appointment\Repository;
class SlotTakenException extends \RuntimeException
{
}
@@ -16,6 +16,7 @@ use App\Payment\Repository\PaymentRepository;
use App\Payment\Service\CircuitBreakerService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Service\SmsService;
use App\Sms\Service\SmsWalletService;
use App\Subscription\Service\SubscriptionService;
use OpenApi\Attributes as OA;
@@ -44,6 +45,7 @@ class PaymentController extends BaseController
private readonly CircuitBreakerService $circuitBreaker,
private readonly SubscriptionService $subscriptionService,
private readonly SmsWalletService $smsWalletService,
private readonly SmsService $smsService,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly SiteConfigRepository $configRepo,
@@ -273,6 +275,8 @@ class PaymentController extends BaseController
$this->handleSubscriptionActivation($payment);
} elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) {
$this->handleSmsWalletCharge($payment);
} elseif ($payment->getType() === Payment::TYPE_APPOINTMENT) {
$this->handleAppointmentConfirmation($payment);
}
return $this->redirectToFrontend($payment, true);
@@ -590,6 +594,26 @@ class PaymentController extends BaseController
$this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment);
}
private function handleAppointmentConfirmation(Payment $payment): void
{
$appointment = $payment->getAppointment();
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
return;
}
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->appointmentRepo->save($appointment);
$mobile = $appointment->getPatientMobile();
if ($mobile) {
$when = date('Y-m-d H:i', $appointment->getSlotStart());
$this->smsService->dispatchAsync(
$mobile,
sprintf('نوبت شما با %s در تاریخ %s ثبت و تأیید شد.', $appointment->getDoctor()->getName(), $when)
);
}
}
private function handleSubscriptionActivation(Payment $payment): void
{
$meta = $payment->getMetadata() ?? [];