608 lines
31 KiB
Markdown
608 lines
31 KiB
Markdown
# نوبتدهی منبعمحورِ آنلاین با بیعانهٔ درصدی
|
||
|
||
> **دامنه:** `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`.
|