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>
This commit is contained in:
@@ -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` همزمان روی یک اسلات → فقط یکی موفق.
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user