# Appointment Booking API — رزرو موقت و ثبت نهایی چندمنبعی

> **Base:** `/api/v1` · **Auth:** JWT
> دنبالهٔ [appointment-availability.md](appointment-availability.md).

---

## سه مرحله

```
جستجو  →  رزرو موقت (hold)  →  ثبت نهایی (confirm)
```

مرحلهٔ میانی لازم است چون بین دیدن یک زمان و ثبتش، فاصله هست: بیمار فرم پر می‌کند،
پرداخت می‌کند، مردد می‌شود. بدون رزرو موقت، همان زمان به چند نفر پیشنهاد می‌شود و
آخری خطا می‌گیرد.

## چرا تضمین در دیتابیس است، نه در کد

قانون سوم جمع‌بندی مستند: **«جلوگیری از رزرو تکراری کار دیتابیس است، نه کار کد.»**

هر بررسیِ «آیا آزاد است؟» در PHP یک پنجرهٔ مسابقه بین خواندن و نوشتن دارد؛ دو درخواست
هم‌زمان هر دو «آزاد» می‌بینند و هر دو می‌نویسند.

MariaDB قید `EXCLUDE` بازه‌ای ندارد، پس هر بازهٔ اشغال به **سطل‌های ثابت پنج‌دقیقه‌ای**
شکسته می‌شود و کلید یکتای زیر تداخل را غیرممکن می‌کند:

```sql
UNIQUE (resource_id, bucket_at, seat)
```

کد فقط `INSERT` می‌زند؛ اگر دیتابیس ردش کرد، همان یعنی «گرفته شده».

**`seat` ظرفیت را بیان می‌کند.** اتاق سه‌تخته صندلی‌های ۰ تا ۲ دارد؛ تلاش از صندلی ۰
شروع می‌شود و با هر برخورد یکی جلو می‌رود. چهارمین رزروِ هم‌زمان جایی برای نشستن پیدا
نمی‌کند و `409` می‌گیرد. شمردن ظرفیت در PHP دقیقاً همان مسابقه‌ای را می‌ساخت که این
طراحی حذفش می‌کند.

---

## `POST /api/v1/appointment-hold`

```json
{
  "service_uuid": "…",
  "branch_uuid": "…",
  "start": 1785562200,
  "item_uuids": ["…"],
  "patient_gender": "female",
  "assignment": { "room": ["…"], "operator": ["…"], "device": ["…"] }
}
```

`assignment` همان چیزی است که جستجوی وقت پیشنهاد داده. **هر نیازمندی باید منبع داشته
باشد**؛ وگرنه `422` — رزروی که نصف منابع لازم را بگیرد، هنگام حضور بیمار کم می‌آورد.

**۲۰۱:**

```json
{
  "success": true,
  "data": {
    "hold_uuid": "…",
    "starts_at": 1785562200,
    "ends_at": 1785565800,
    "expires_at": 1785563100,
    "confirmed": false,
    "assignment": { "room": [{ "uuid": "…", "name": "اتاق ۲" }] }
  }
}
```

مهلت **۹۰۰ ثانیه** است — همان مهلتی که پرداخت نوبت دارد؛ دو عدد متفاوت یعنی دو حقیقت
متفاوت.

بلافاصله پس از رزرو، `POST /appointment-availability` آن زمان را دیگر برنمی‌گرداند.

**۴۰۹ `ERR_SLOT_TAKEN`:** منبع در آن بازه ظرفیت خالی ندارد.
**۴۲۲:** نبودِ منبع برای یک نقش · `assignment` خالی.
**۴۰۴:** سرویس، شعبه یا منبع محیط دیگر.

> **رزرو نیمه‌کاره نمی‌ماند.** اگر منبع دوم جا نداشت، منبع اول هم آزاد می‌شود و خودِ
> رزرو حذف — وگرنه منبعی قفل می‌ماند که هرگز نوبتی رویش ثبت نمی‌شود.

## `DELETE /api/v1/appointment-hold/{uuid}`

آزادسازی زودهنگام؛ آن زمان بلافاصله دوباره در جستجو ظاهر می‌شود.
رزروی که ثبت نهایی شده آزاد نمی‌شود (`422`).

## `POST /api/v1/appointment-confirm`

```json
{ "hold_uuid": "…", "doctor_uuid": "…", "patient_uuid": "…" }
```

`patient_uuid` اختیاری است؛ نبودش یعنی خودِ کاربر (منشی می‌تواند برای دیگری ثبت کند).

**۲۰۰:** `appointment_uuid` + بازه + تخصیص.

تبدیل `hold → booked` **هیچ منبعی را دوباره نمی‌گیرد**: صندلی‌ها از لحظهٔ رزرو موقت
گرفته شده‌اند و اینجا فقط برچسبشان عوض می‌شود. اگر ثبت نهایی دوباره رزرو می‌کرد، همان
پنجرهٔ مسابقه‌ای که رزرو موقت حذفش کرده بود برمی‌گشت.

**۴۰۹ `ERR_HOLD_EXPIRED`** روی رزروِ منقضی · **۴۰۹ `ERR_SLOT_TAKEN`** روی رزروِ
قبلاً ثبت‌شده · **۴۰۴** روی رزرو کاربر دیگر (نه ۴۰۳ — وجودش نباید لو برود).

## `POST /api/v1/appointment/{uuid}/rebook`

جابه‌جایی: **اول** رزرو جدید، بعد آزادسازی قدیم. ترتیب عمدی است — اگر رزرو جدید شکست
بخورد، نوبت قدیمی دست‌نخورده می‌ماند و بیمار بی‌نوبت نمی‌شود.

---

## اشغال: یک ردیف per (بخش × منبع)

نه یکی per نوبت. همین ریزدانگی ظرفیت آزاد می‌کند.

مثال واقعی (و تستِ مرجع): نوبت ۵۵ دقیقه‌ای با بخش‌های بی‌حسی ۵ · انتظار ۳۰ · لیزر ۲۰:

| منبع | تعداد ردیف اشغال |
|---|---|
| اتاق | **۳** — هر سه بخش |
| اپراتور | **۲** — فقط بی‌حسی و لیزر |

اپراتور در بازهٔ انتظار **هیچ ردیفی ندارد** و برای بیمار دیگری آزاد است.

بازهٔ ثبت‌شده گسترده‌تر از بازهٔ بخش است: زمان آماده‌سازی و تمیزکاری منبع هم درونش
می‌آید.

### `status`

| مقدار | یعنی |
|---|---|
| `hold` | رزرو موقت، تا پایان مهلت |
| `booked` | نوبت قطعی |
| `released` | لغو یا منقضی |

**لغو، ردیف را حذف فیزیکی نمی‌کند.** تاریخچهٔ اینکه چه منبعی کِی گرفته شده بود ورودی
گزارش بهره‌وری است؛ حذفش یعنی پاک کردن همان چیزی که قرار است اندازه بگیریم. ولی
سطل‌های یکتایی حذف می‌شوند، وگرنه آن زمان برای همیشه قفل می‌ماند.

---

## تور ایمنی دوگانه

نوبت با همان سازندهٔ موجود ساخته می‌شود، پس `active_slot_key`، رویدادها و مسیر پرداخت
دقیقاً مثل قبل کار می‌کنند. اشغال چندمنبعی **کنار** آن می‌نشیند، نه به‌جایش.

---

## تست‌ها

```bash
ddev exec php bin/phpunit tests/Appointment/HoldAndBookTest.php   # ۱۲ تست
```

دو تست از همه مهم‌ترند: رزرو دوم روی همان منبع و بازه که `409` می‌گیرد، و تستی که
**مستقیم روی یک اتصال جدا** ردیف تکراری می‌نویسد و انتظار نقض کلید یکتا دارد — اگر آن
یکی بشکند، یعنی تضمین فقط در کد بوده است.

---

## قوانین وابسته به بیمار

`POST /api/v1/appointment-hold` پیش از گرفتن صندلی دو دسته را اجرا می‌کند:

| دسته | خطا | معنی |
|---|---|---|
| `eligibility` | `ERR_VALIDATION_001` / `422` | قانونی این بیمار را برای این خدمت رد کرده |
| `eligibility` (`require_flag`) | `ERR_VALIDATION_002` / `422` | پرچمی مثل `has_parental_consent` در بدنه نیامده |
| `spacing` | `ERR_VALIDATION_001` / `422` | فاصله تا نوبت قبلیِ همان دستهٔ کاتالوگ کمتر از `min_days_between` است |

جای اجرا عمداً لحظهٔ رزرو موقت است نه ثبت نهایی: شنیدن «واجد شرایط نیستید» بعد از ده
دقیقه نگه‌داشتن صندلی، هم وقت بیمار را تلف می‌کند هم صندلی را.

جزئیات: [policy.md](policy.md)

---

## اعتبار پکیج

`confirm` یک جلسه از پکیج معتبر بیمار کسر می‌کند (ردیف `consume`) و لغو نوبت آن را
برمی‌گرداند (ردیف `refund`) — ردیف مصرف حذف نمی‌شود. کلید یکتای دفتر تضمین می‌کند
اجرای دوبارهٔ `confirm` جلسهٔ دوم نخورد.

جزئیات: [package.md](package.md)
