feat(online-booking): add design document for online resource booking with deposit feature

This commit is contained in:
hamed
2026-08-06 11:54:44 +03:30
parent 0e7970d6e0
commit 1a07dad17c
+607
View File
@@ -0,0 +1,607 @@
# نوبت‌دهی منبع‌محورِ آنلاین با بیعانهٔ درصدی
> **دامنه:** `clinicpro` (بک‌اند + پنل) و `nobat724_front` (سایت عمومی)
> **وضعیت:** سند طراحی. کد نوشته نشده.
> **تاریخ:** ۱۴۰۵ / 2026-08
این سند حاصل یک جلسهٔ تصمیم‌گیری است. هر بند «چه» را می‌گوید و «چرا» را کنارش.
تصمیم‌های گرفته‌شده در بخش ۳ فهرست‌اند و بقیهٔ سند نتیجهٔ آن‌هاست.
---
## ۱. مسئله
نوبت‌دهی منبع‌محور در `clinicpro` ساخته شده و کار می‌کند، ولی فقط داخل پنل.
هیچ بیماری از `nobat724` نمی‌تواند روی یک دستگاه یا اتاق نوبت بگیرد.
خواسته سه تکه دارد:
۱. مدیر کلینیک بتواند یک منبع را «آنلاین» کند تا در سایت عمومی دیده شود.
۲. برای هر سرویس، درصدی از هزینه به‌عنوان بیعانه آنلاین گرفته شود.
۳. مشخص شود این نوبت‌دهی در پروفایل پزشک است یا در کلینیک.
---
## ۲. آنچه امروز واقعاً هست
این بخش از روی کد نوشته شده، نه از روی مستندات. تفاوتشان در ۲.۴ آمده.
### ۲.۱ موتور منبع‌محور — کامل، ولی خصوصی
| موجودیت | مسیر |
|---|---|
| `ClinicResource` | `src/Resource/Entity/ClinicResource.php` |
| `ResourceServiceOffering` | `src/Resource/Entity/ResourceServiceOffering.php` |
| `ResourceCalendar` / `ResourceException` | `src/Resource/Entity/` |
| `AppointmentHold` / `OccupancyBucket` | `src/Appointment/Booking/Entity/` |
| `ResourceOccupancy` | `src/Appointment/Availability/Entity/` |
اندپوینت‌های موجود و فعال:
```
POST /api/v1/appointment-availability جستجوی وقت روی همهٔ منابع + پیشنهاد assignment
POST /api/v1/appointment-hold رزرو موقت ۹۰۰ ثانیه‌ای
POST /api/v1/appointment-confirm ثبت نهایی
GET /api/v1/resource/{uuid}/service-slots اسلات per منبع
POST /api/v1/pricing/quote پیش‌نمایش فاکتور
```
همه پشت JWT‌اند و محیط را از `EntityContextResolver` می‌گیرند.
بیمار ناشناس محیط ندارد، پس هیچ‌کدام از سایت عمومی قابل استفاده نیستند.
سه واقعیت که طراحی را قفل می‌کنند:
- **منبع مال یک محیط است.** محیط یعنی جفت `(entity_type, entity_id)` که یا مطب شخصی
یک پزشک است یا یک کلینیک. پس منبع از قبل هر دو حالت را می‌پذیرد.
- **هر منبع پزشک ناظر دارد** (`supervisor`، در ساخت الزامی). نوبتِ منبع پزشکش را از
همین‌جا می‌گیرد.
- **تضمین ضدتداخل در دیتابیس است**، با کلید یکتای زیر روی سطل‌های پنج‌دقیقه‌ای:
```sql
UNIQUE (resource_id, bucket_at, seat)
```
### ۲.۲ پرداخت — یک عدد ثابت
مبلغ هر پرداخت نوبت از کلید `appointment_fee_rials` تنظیمات سایت خوانده می‌شود.
نه از سرویس می‌آید، نه از فاکتور، نه از client.
پول به ترمینال **پلتفرم** می‌رود، نه به حساب کلینیک.
بعد از آن دفتر کیف پول روی `user_id` و درخواست تسویه با تأیید ادمین.
کیف پول جدول موجودی ندارد؛ `wallet_transactions` یک دفتر است و موجودی از جمع ردیف‌ها
درمی‌آید. بیمار هم `User` است، پس اعتبار دادن به بیمار هیچ مهاجرت ساختاری نمی‌خواهد.
### ۲.۳ بیعانه — سه تکهٔ ناهم‌راستا
- `Appointment.deposit_required` و `deposit_amount_rials` — عدد دستی که کاربر پنل وارد
می‌کند. هیچ ربطی به قیمت سرویس ندارد.
- `deposit_percent` — فقط پارامتر ورودی `POST /pricing/quote`. هیچ‌جا ذخیره نمی‌شود.
یعنی هر client هر عددی بفرستد همان محاسبه می‌شود.
- `ResourceServiceOffering.price_rials` — یک override قیمت per منبع که در کد هست ولی
`docs/api/pricing.md` می‌گوید «تنها منبع قیمت `ServiceItem.price_rials` است».
هیچ‌کدام قابل استفاده در پرداخت آنلاین نیستند.
### ۲.۴ ⚠️ سه مستندی که کد ندارند
بررسی مستقیم `src/` نشان می‌دهد این‌ها **پیاده‌سازی نشده‌اند**، هرچند مستند دارند:
| مستند | ادعا | واقعیت در کد |
|---|---|---|
| `docs/api/cancellation.md` | سیاست لغو، جریمه، `deposit_refundable` | رشتهٔ `cancellation` در کل `src/` صفر تطابق دارد |
| `docs/api/policy.md` | موتور قوانین شش‌دسته‌ای | `PolicyController` و `policy-schema` وجود ندارند |
| `appointment/{uuid}/cancel` و `cancellation-preview` | فراخوانی‌شده در `nobat724_front` | route وجود ندارد؛ لغو با `PATCH /appointment/{uuid}/status` انجام می‌شود |
این روی بند ۵ همین سند اثر مستقیم دارد: مبلغ قابل بازگشت قرار است از سیاست لغو بیاید،
و آن سیاست هنوز ساخته نشده. یا باید در همین فاز ساخته شود، یا فاز اول با یک قاعدهٔ
ثابت شروع کند. تصمیمش در ۹.۱ آمده.
### ۲.۵ شکاف اشغال — بلوکرِ اصلی
دو مسیر ثبت نوبت روی منبع وجود دارد و فقط یکی جدول اشغال را پر می‌کند:
```
POST /api/v1/my/appointment با resource_uuid → resource_occupancy نمی‌سازد
موتور منبع‌محور (hold/confirm) → resource_occupancy می‌سازد
```
موتور منبع‌محور فقط `resource_occupancy` را می‌خواند.
پس نوبتی که منشی از پنل روی لیزر ثبت کرده، برای آن موتور **نامرئی** است.
تا امروز بی‌خطر بوده چون آن موتور فقط داخل پنل بود و پنل هر دو منبع را می‌خواند.
با عمومی‌شدن، دابل‌بوکینگ قطعی است — و بدتر، قفل دیتابیس هم نمی‌گیردش، چون آن کلید
یکتا فقط روی `resource_occupancy` است.
### ۲.۶ سایت عمومی — پزشک‌محورِ خالص
مسیر نوبت‌گیری `/appointment/[doctorId]` است و همهٔ اندپوینت‌ها `doctor_uuid` می‌گیرند:
```
GET api/v1/appointment-booking-locations/{doctor_uuid}
GET api/v1/appointment-booking-services/{doctor_uuid}
GET api/v1/appointment-slots?doctor_uuid=…
GET api/v1/appointment-service-slots?doctor_uuid=…
```
`clinic_uuid` فقط یک پارامتر کنار آن‌هاست که «کدام برنامهٔ هفتگی» را انتخاب می‌کند.
نبودنش یعنی مطب شخصی، نه wildcard.
صفحهٔ `/clinic/[slug]` ویترین است: اطلاعات کلینیک و فهرست پزشکان. نوبت‌دهی مستقل ندارد.
`booking_mode` در `WeeklySchedule.setting.meta` می‌نشیند و امروز دو مقدار دارد:
`slot` و `service`.
---
## ۳. تصمیم‌ها
| # | تصمیم | چرا |
|---|---|---|
| ۱ | بیمار فقط **سرویس و زمان** انتخاب می‌کند | تخصیص منبع را موتور می‌دهد؛ نمایشش به بیمار یعنی تصمیمی که هیچ بیماری نمی‌تواند بگیرد |
| ۲ | نقطهٔ ورود را **مالکِ منبع** تعیین می‌کند | منبع و سرویس هر دو مال یک محیط‌اند؛ URL نباید دربارهٔ مالکیت تقویم دروغ بگوید |
| ۳ | آنلاین‌بودن = **پرچم روی منبع × `bookable` روی سرویس** | «این دستگاه قابل عرضه است» و «این خدمت قابل فروش است» دو سؤال جدایند |
| ۴ | درصد بیعانه روی **`ServiceItem`**، با پیش‌فرض محیط | قیمت روی سرویس است، پس درصدِ قیمت هم همان‌جاست |
| ۵ | درصد روی **قیمت پایه** اعمال می‌شود | بیمهٔ بیمار آنلاین معلوم نیست؛ عدد باید برای همه یکسان و قابل پیش‌بینی باشد |
| ۶ | بیعانه **اختیاری**؛ نبودش یعنی همان `appointment_fee_rials` | یک تراکنش، دو منبع مبلغ |
| ۷ | پرداخت **بعد از ثبت نهایی**، روی نوبتِ پرداخت‌نشده | کل ماشین پرداخت و استرداد دست‌نخورده می‌ماند |
| ۸ | نام پزشک **پیش از پرداخت** نشان داده می‌شود | بیمار قبل از پول دادن بداند نزد کی می‌رود |
| ۹ | استرداد به **کیف پول بیمار**؛ نقد فقط دستی | فوری و بدون درگاه؛ نقدشدن یک تصمیم انسانی می‌ماند |
| ۱۰ | مسیر پنل هم **`resource_occupancy` بنویسد** | یک منبع حقیقت برای اشغال؛ بدون آن قفل دیتابیس بی‌اثر است |
| ۱۱ | **سه اندپوینت عمومی تازه**، محیط از URL | قرارداد متفاوت است نه فقط مجوز متفاوت |
| ۱۲ | در سایت، **مقدار سوم `booking_mode`** روی همان صفحه | لاگین و پرداخت و نتیجه و داشبورد همه مشترک‌اند |
| ۱۳ | تنظیم‌کنندهٔ درصد = **مالک محیط** | درصد یک فیلد کنار قیمت است؛ دو مالک برای دو فیلد هم‌ردیف یعنی دو مدل دسترسی در یک فرم |
فرض‌های پذیرفته‌شده:
- رزرو موقت هم لاگین می‌خواهد. وگرنه ناشناس می‌تواند صندلی‌ها را خالی نگه دارد.
- بیعانه فقط برای رزرو منبع‌محور است. مسیر `slot` و `service` فعلی دست‌نخورده می‌ماند.
---
## ۴. واژگان
| واژه | معنی دقیق |
|---|---|
| **منبع** | هرچیزی که ممکن است اشغال باشد: پزشک، پرسنل، اتاق، دستگاه، تخت |
| **منبع آنلاین** | منبعی که پرچم `online_bookable` دارد و در سایت عمومی دیده می‌شود |
| **سرویسِ آنلاین** | سرویسی که هم `bookable` است و هم دست‌کم یک offering فعال روی یک منبع آنلاین دارد |
| **ناظر** | پزشکِ مسئول یک منبع. پزشکِ نوبت از او می‌آید. با «پلِ منبع» فرق دارد |
| **بیعانه** | پیش‌پرداختِ بخشی از هزینهٔ سرویس. از فاکتور نهایی کسر می‌شود، اضافه بر آن نیست |
| **کارمزد نوبت** | همان `appointment_fee_rials` سراسری. درآمد پلتفرم، نه پیش‌پرداخت خدمت |
| **نوبتِ پرداخت‌نشده** | نوبتی که ثبت شده ولی مهلت دارد؛ با موفقیت پرداخت مهلتش برداشته می‌شود |
| **اعتبار بیمار** | موجودی کیف پول بیمار. خرج رزرو بعدی می‌شود |
واژه‌هایی که عمداً استفاده **نمی‌شوند**:
- «شعبه» — از محصول حذف شده؛ لنگر محیطی همان `doctor_addresses` است.
- «تخفیف» به‌جای بیعانه — بیعانه مبلغ را کم نمی‌کند، فقط زمان پرداختش را جلو می‌اندازد.
---
## ۵. تغییرات دیتابیس
### ۵.۱ `clinic_resources`
```sql
ALTER TABLE clinic_resources
ADD COLUMN online_bookable TINYINT(1) NOT NULL DEFAULT 0;
```
پیش‌فرض `0` عمدی است. دستگاه داخلی و اتاق عمل نباید با یک deploy به سایت نشت کنند.
### ۵.۲ `service_items`
```sql
ALTER TABLE service_items
ADD COLUMN online_deposit_percent SMALLINT NULL;
```
`NULL` یعنی «از پیش‌فرض محیط بخوان»، نه «صفر».
تفاوتشان لازم است: صفر یعنی «این سرویس عمداً بیعانه ندارد».
بازهٔ مجاز `0..100`. بیرونش `422`.
### ۵.۳ تنظیمات محیط
یک کلید تازه در تنظیمات همان محیط:
```
online_deposit_percent_default
```
نبودنش یعنی صفر، یعنی رفتار فعلی.
### ۵.۴ `appointments`
فیلد تازه‌ای لازم نیست. `deposit_required` و `deposit_amount_rials` از قبل هستند و
همان‌ها با مقدارِ محاسبه‌شدهٔ سرور پر می‌شوند.
⚠️ تنها تفاوت: تا امروز این دو را کاربر پنل دستی می‌نوشت. از این پس در مسیر آنلاین
**سرور** می‌نویسد و ورودی client پذیرفته نمی‌شود.
### ۵.۵ `wallet_transactions`
ساختار تغییر نمی‌کند. فقط نوع تازه‌ای از `description` و یک `payment_id` که به پرداختِ
اصلی اشاره می‌کند تا زنجیرهٔ «پرداخت ← لغو ← اعتبار ← مصرف» قابل ردیابی بماند.
### ۵.۶ مهاجرت backfill اشغال
برای هر نوبتِ آیندهٔ دارای `resource_id` که ردیف `resource_occupancy` ندارد، ردیف
ساخته شود. نوبت‌های گذشته لازم نیستند — اشغالِ گذشته کسی را بلاک نمی‌کند.
⚠️ این مهاجرت باید **قبل از** روشن‌کردن هر منبع آنلاین اجرا شود.
---
## ۶. قرارداد API
### ۶.۱ اندپوینت‌های عمومی تازه
هر سه بدون JWT. محیط از مسیر می‌آید، نه از توکن.
```
GET /api/v1/public/booking/{entityType}/{entityUuid}/services
POST /api/v1/public/booking/{entityType}/{entityUuid}/availability
POST /api/v1/public/booking/{entityType}/{entityUuid}/hold
```
`entityType` یکی از `doctor` یا `clinic` است.
درونشان دقیقاً همان سرویس‌های موجود صدا زده می‌شوند —
`AvailabilityService`, `HoldService`, `ResourceServiceResolver`.
منطق تازه‌ای نوشته نمی‌شود؛ فقط منبع محیط عوض می‌شود.
#### `GET …/services`
فقط سرویس‌هایی که هر سه شرط را دارند:
- `service_items.bookable = 1`
- دست‌کم یک `ResourceServiceOffering` فعال دارند
- آن offering روی منبعی با `online_bookable = 1` و `active = 1` است
پاسخ:
```json
{
"success": true,
"data": {
"entity": { "type": "clinic", "uuid": "…", "name": "کلینیک پوست یزد" },
"sections": [
{
"uuid": "…", "name": "لیزر",
"items": [
{
"uuid": "…",
"name": "لیزر فول‌بادی",
"price_rials": 20000000,
"deposit_percent": 30,
"deposit_rials": 6000000,
"duration_minutes": 50
}
]
}
]
}
}
```
`deposit_rials` را **سرور** حساب می‌کند. client هرگز مبلغ نمی‌فرستد.
`duration_minutes` از زنجیرهٔ حلِ منبع می‌آید، نه از `duration_minutes` خام سرویس —
همان «RF فرکشنال» روی یک دستگاه ۵۰ دقیقه است و روی دیگری ۴۰. وقتی چند منبع آن سرویس
را می‌دهند، کمینهٔ مدت‌ها نمایش داده می‌شود و مدت واقعی در پاسخ رزرو موقت می‌آید.
#### `POST …/availability`
بدنه:
```json
{ "service_uuid": "…", "from": 1785529800, "to": 1785616200, "item_uuids": [] }
```
همان قواعد اندپوینت خصوصی: هر دو سر بازه شامل، سقف ۹۰ روز.
تفاوت‌ها:
- فقط منابع `online_bookable` در جستجو شرکت می‌کنند.
- `assignment` در پاسخ **برنمی‌گردد**. بیمار منبع را نمی‌بیند.
- به‌جایش هر اسلات `doctor` دارد: `{ uuid, name }` از ناظرِ منبعِ پیشنهادی.
```json
{
"success": true,
"data": {
"slots": [
{ "start": 1785562200, "end": 1785565200,
"doctor": { "uuid": "…", "name": "امیر کاظمی" } }
]
}
}
```
#### `POST …/hold`
**لاگین لازم است.** تنها اندپوینت از این سه که JWT می‌خواهد.
دلیل: رزرو موقت صندلی می‌گیرد، و ناشناسی که صندلی می‌گیرد قابل پاسخگویی نیست.
پاسخ همان `hold_uuid` و بازه و `expires_at` است، به‌علاوهٔ:
```json
{
"doctor": { "uuid": "…", "name": "امیر کاظمی" },
"deposit_rials": 6000000,
"price_rials": 20000000
}
```
این پاسخ ورودی صفحهٔ تأیید است. بیمار پیش از پرداخت هر سه را می‌بیند:
پزشک، هزینهٔ کل، مبلغی که الان می‌پردازد.
### ۶.۲ تغییر در `POST /api/v1/appointment-confirm`
نوبت با `deposit_required` و `deposit_amount_rials`ِ محاسبه‌شده ساخته می‌شود و
`expires_at` می‌گیرد — دقیقاً مثل نوبت‌های عمومی امروز.
`doctor_uuid` در بدنه دیگر لازم نیست وقتی رزرو از مسیر عمومی آمده؛ از ناظرِ منبع حل
می‌شود. فرستادن پزشکی غیر از ناظر ⇒ `422`.
### ۶.۳ تغییر در `POST /api/v1/payment/appointment`
امروز مبلغ همیشه `appointment_fee_rials` است. از این پس:
```
اگر appointment.deposit_required → مبلغ = deposit_amount_rials
در غیر این صورت → مبلغ = appointment_fee_rials
```
انتخاب همچنان **سمت سرور** انجام می‌شود و از `appointment_uuid` مشتق است.
هیچ فیلد تازه‌ای در بدنهٔ درخواست اضافه نمی‌شود.
اگر بیمار موجودی کیف پول داشته باشد، اول از آن کسر می‌شود و فقط مابقی به درگاه می‌رود.
صفر شدن مابقی یعنی پرداخت بدون درگاه: نوبت مستقیم تأیید می‌شود و ردیف `Payment` با
`gateway = wallet` ثبت می‌گردد تا گزارش مالی ردیف کم نداشته باشد.
### ۶.۴ تغییر در `GET /api/v1/payment/config`
یک فیلد تازه که فرانت با آن می‌فهمد کدام عدد را نشان بدهد:
```json
{ "appointment_fee_rials": 150000, "wallet_balance_rials": 0 }
```
### ۶.۵ اندپوینت‌های پنل که تغییر می‌کنند
```
PATCH /api/v1/resource/{uuid} + online_bookable
PATCH /api/v1/service-item/{uuid} + online_deposit_percent
```
هر دو با همان مجوزهای فعلی. `online_bookable` با `appointment_settings.update`،
`online_deposit_percent` با `services.update`.
⚠️ `PUT /api/v1/resource/{uuid}/services` که offeringها را می‌نویسد بدون تغییر می‌ماند.
---
## ۷. جریان کامل
```
بیمار در صفحهٔ کلینیک یا پزشک
│ GET …/services فهرست سرویس‌ها + قیمت + بیعانه
انتخاب سرویس
│ POST …/availability ساعت‌های آزاد + نام پزشک هر ساعت
انتخاب ساعت → لاگین اگر نکرده
│ POST …/hold صندلی گرفته شد، ۹۰۰ ثانیه مهلت
صفحهٔ تأیید: پزشک ‧ هزینهٔ کل ‧ مبلغ پرداخت الان
│ POST /appointment-confirm نوبتِ پرداخت‌نشده با مهلت
│ POST /payment/appointment مبلغ = بیعانه یا کارمزد
درگاه → callback
│ مهلت برداشته می‌شود، پیامک می‌رود
نوبت pending → تأیید کلینیک → حضور → تسویهٔ مابقی در محل
```
نکته‌های ترتیب:
- **مهلت رزرو موقت و مهلت پرداخت یک عددند** (۹۰۰ ثانیه). دو عدد متفاوت یعنی دو حقیقت
متفاوت و یکی از آن دو همیشه غلط است.
- **پس از `confirm`، رزرو موقت هیچ منبعی را دوباره نمی‌گیرد.** صندلی از لحظهٔ رزرو گرفته
شده و اینجا فقط برچسبش عوض می‌شود.
- **پرداخت نوبت را قطعی نمی‌کند.** نوبت `pending` می‌ماند و قطعی‌شدن همچنان تصمیم
کلینیک است — رفتار فعلی، بدون تغییر.
- **انقضای بدون پرداخت** باید هم نوبت را `expired` کند و هم سطل‌های یکتای اشغال را حذف.
ردیف‌های `resource_occupancy` حذف فیزیکی نمی‌شوند و به `released` می‌روند؛ حذف نکردنِ
سطل‌ها یعنی آن زمان برای همیشه قفل می‌ماند.
---
## ۸. پیش‌نیاز اجباری — یکی‌شدن جدول اشغال
بدون این بند، بقیهٔ سند قابل اجرا نیست.
**قاعده:** هر نوبتی که `resource_id` دارد، از هر مسیری که ساخته شود، ردیف
`resource_occupancy` می‌سازد.
مسیرهای درگیر:
```
POST /api/v1/my/appointment با resource_uuid ← امروز نمی‌سازد
POST /api/v1/appointment مسیر عمومی فعلی ← امروز نمی‌سازد
مسیر ادمین در src/Admin/ ← بررسی شود
```
جای درست این کار **یک سرویس مشترک** است، نه تکرار در هر controller.
هر جا نوبت با منبع ساخته یا لغو یا جابه‌جا می‌شود، همان سرویس صدا زده شود.
پس از آن، خواندنِ اشغال از دو منبع در پنل دیگر لازم نیست و می‌تواند ساده شود — ولی
حذفش در همین فاز اجباری نیست و بهتر است یک فاز عقب‌تر انجام شود.
تست مرجع: دو رزرو هم‌زمان روی یک منبع و یک بازه، یکی از دو مسیر متفاوت، باید `409`
بگیرد. اگر این تست از مسیر پنل رد شد، بند ۸ کامل نشده است.
---
## ۹. لغو، استرداد، اعتبار
### ۹.۱ ⚠️ پیش‌نیاز غایب
سیاست لغو در `docs/api/cancellation.md` مستند شده ولی در کد وجود ندارد.
همچنین `appointment/{uuid}/cancel` و `cancellation-preview` که سایت صدایشان می‌زند.
دو راه:
- **الف — فاز اول با قاعدهٔ ثابت.** بیعانه تا `free_window_hours` ساعت قبل کاملاً
برمی‌گردد، بعد از آن هیچ. عدد در تنظیمات محیط، بدون موتور سیاست.
- **ب — ساخت کامل دامنهٔ لغو در همین فاز.**
توصیه: الف. دامنهٔ لغو مسئلهٔ مستقلی است و گره‌زدنش به این قابلیت هر دو را عقب می‌اندازد.
قرارداد اعتبار طوری نوشته شود که بعداً منبع عدد از تنظیمات به سیاست منتقل شود بدون
تغییر در مسیر پول.
### ۹.۲ جریان لغو
```
بیمار لغو می‌کند
│ مبلغ قابل بازگشت حساب می‌شود
اسلات و منابع فوراً آزاد می‌شوند
│ ردیف credit در wallet_transactions
اعتبار بیمار
```
اسلات **قبل از** هر عملیات مالی آزاد می‌شود. برعکسش یعنی صندلی تا تسویهٔ حساب قفل بماند.
### ۹.۳ اعتبار بیمار
- خرج رزرو بعدی می‌شود، خودکار، در گام پرداخت.
- برای نقد کردن، بیمار درخواست می‌دهد و ادمین با همان مسیر refund درگاه اجرا می‌کند.
- اعتبار منقضی نمی‌شود. انقضای پول مسئلهٔ حقوقی است، نه فنی.
⚠️ استرداد ملت در sandbox شبیه‌سازی نمی‌شود (`bpRefundRequest` همیشه کد ۳۴).
یعنی مسیر نقدکردن فقط در prod قابل تست است. مسیر اعتبار کاملاً قابل تست است — یک دلیل
دیگر برای اینکه مسیر پیش‌فرض اعتبار باشد نه نقد.
---
## ۱۰. تغییرات پنل `clinicpro`
| صفحه | تغییر |
|---|---|
| `/admin/resources/{uuid}` | سوییچ «نمایش در نوبت‌دهی آنلاین». پیش‌فرض خاموش |
| فرم سرویس | فیلد «درصد بیعانهٔ آنلاین»، خالی = پیش‌فرض محیط |
| تنظیمات محیط | «درصد بیعانهٔ پیش‌فرض» |
| `/admin/resources` | ستون یا نشانِ «آنلاین» در فهرست |
| صفحهٔ نوبت | نمایش مبلغ پرداخت‌شده و مانده |
قواعد الزامی پنل:
- هر boolean با `components/ui/Switch` است. checkbox خام ممنوع.
- هر انتخاب با `SearchableSelect` است. `<select>` بومی ممنوع.
- صفحهٔ زیرمجموعه دکمهٔ بازگشت دارد؛ با `backTo` روی `PageHeader` یا `BackButton`.
- هیچ طراحی تازه‌ای ساخته نمی‌شود. توکن‌ها و کامپوننت‌های موجود.
یک هشدار در UI لازم است: وقتی مدیر منبعی را آنلاین می‌کند و آن منبع سرویسِ `bookable`
ندارد، چیزی در سایت ظاهر نمی‌شود. سکوت در این حالت شبیه باگ است.
---
## ۱۱. تغییرات `nobat724_front`
### ۱۱.۱ مقدار سوم `booking_mode`
`resource` کنار `slot` و `service`.
همان صفحهٔ `/appointment/[doctorId]` این حالت را هم می‌شناسد.
### ۱۱.۲ مسیر تازه برای کلینیک
```
/clinic/[slug]/appointment
```
همان کامپوننت نوبت‌گیری، با محیطِ کلینیک به‌جای پزشک.
یک کامپوننت، دو میزبان.
صفحهٔ `/clinic/[slug]` یک دکمهٔ «رزرو نوبت» می‌گیرد که فقط وقتی کلینیک منبع آنلاین
دارد نمایش داده می‌شود.
### ۱۱.۳ گام‌های تازهٔ UI
- کارت سرویس با دو عدد: هزینهٔ کل، و «پرداخت آنلاین: X ریال».
- صفحهٔ تأیید پیش از پرداخت با نام پزشک.
- در صفحهٔ پرداخت، اگر اعتبار دارد: «از اعتبار شما کسر می‌شود».
### ۱۱.۴ داشبورد بیمار
- تب اعتبار: موجودی و تاریخچه.
- در فهرست نوبت‌ها: مبلغ پرداخت‌شده و مانده.
همهٔ رشته‌ها فارسی، تاریخ‌ها شمسی، جهت RTL.
---
## ۱۲. آنچه در دامنه نیست
صریح می‌آید تا بعداً به‌عنوان «فراموش شد» برنگردد:
- بیمار منبع را انتخاب نمی‌کند و اسمش را نمی‌بیند.
- پرداخت مستقیم به حساب کلینیک انجام نمی‌شود. پول به پلتفرم می‌رود و تسویه با مسیر
موجود است.
- تسویهٔ مابقی هزینه آنلاین نیست. حضوری است.
- بیمهٔ بیمار در محاسبهٔ بیعانه دخالت نمی‌کند.
- برداشت نقدی خودکار از کیف پول بیمار ساخته نمی‌شود.
- موتور قوانین و دامنهٔ لغو در این فاز ساخته نمی‌شوند.
- `booking_mode` قبلی تغییر نمی‌کند و مسیرهای `slot` و `service` دست‌نخورده‌اند.
---
## ۱۳. ریسک‌ها و مرزها
| ریسک | مهار |
|---|---|
| دابل‌بوکینگ بین پنل و سایت | بند ۸، پیش از هر روشن‌کردن |
| نشت دستگاه داخلی به سایت | پیش‌فرض `online_bookable = 0` |
| مبلغ دستکاری‌شده از client | مبلغ فقط سمت سرور از `appointment_uuid` مشتق می‌شود |
| اختلاف عددِ سایت با فاکتور کلینیک | درصد روی قیمت پایه؛ فاکتور کامل حضوری |
| پزشک ناخواسته | نمایش نام پزشک پیش از پرداخت + امکان رها کردن رزرو |
| رزرو ناشناس و اشغال صندلی | رزرو موقت لاگین می‌خواهد |
| استرداد غیرقابل تست | مسیر پیش‌فرض اعتبار است، نه درگاه |
| تناقض قیمت offering با قیمت سرویس | `ResourceServiceOffering.price_rials` در این مسیر خوانده نمی‌شود؛ تصمیم دربارهٔ حذف یا رسمی‌کردنش خارج از این فاز |
---
## ۱۴. ترتیب اجرا
هر گام مستقلاً قابل تست و قابل deploy است.
**گام ۰ — یکی‌شدن اشغال**
سرویس مشترک نوشتن اشغال. مهاجرت backfill. تست دو-مسیره.
بدون این، هیچ گام دیگری نباید روی prod برود.
**گام ۱ — پرچم‌ها**
`online_bookable` روی منبع. `online_deposit_percent` روی سرویس و تنظیمات محیط.
دو سوییچ در پنل. هنوز هیچ چیزی عمومی نیست.
**گام ۲ — سه اندپوینت عمومی**
سرویس‌ها، جستجو، رزرو موقت. با تست مالکیت محیط و تست منبعِ غیرآنلاین.
**گام ۳ — پرداخت**
انتخاب مبلغ در `payment/appointment`. کسر از کیف پول. `gateway = wallet`.
**گام ۴ — سایت**
مقدار سوم `booking_mode`. مسیر کلینیک. صفحهٔ تأیید. نمایش دو عدد.
**گام ۵ — لغو و اعتبار**
قاعدهٔ ثابت پنجرهٔ رایگان. ردیف اعتبار. تب اعتبار در داشبورد.
قواعد ثابت پروژه که در هر گام رعایت می‌شوند:
- هیچ گامی بدون تست موفق و خطا و مرزی تمام نیست.
- هر تغییر route یا شکل پاسخ، همان جلسه در `docs/api/*` می‌نشیند.
- entity تازه یا `TenantOwnedTrait` می‌گیرد یا با دلیل در `GlobalTables` ثبت می‌شود.
- دادهٔ محیط دیگر `404` می‌گیرد، نه `403`.