- Implemented PublicResourceBookingController to handle public resource booking requests. - Added methods for retrieving bookable resources, available slots, and month availability. - Created PublicResourceBookingService to manage public resource offerings and service visibility. - Developed tests for public resource booking to ensure correct functionality and error handling.
981 lines
49 KiB
Markdown
981 lines
49 KiB
Markdown
# Resource API — منابع، نوع منبع، مهارت و استخر
|
||
|
||
> **Base:** `/api/v1` · **Auth:** JWT روی همهٔ اندپوینتها
|
||
> **مجوز:** `appointment_settings` (`view` خواندن، `update` نوشتن) — همان مجوز تنظیمات
|
||
> نوبتدهی؛ مجوز تازهای ساخته نشده.
|
||
>
|
||
> **استثنای خواندن برای نوبتدهی (2026-08):** چهار اندپوینتِ خواندنی که ورودیِ ثبت
|
||
> نوبتاند — `GET /resources`، `GET /resource/{uuid}/services`،
|
||
> `GET /resource/{uuid}/day-slots`، `GET /resource/{uuid}/service-slots` — با
|
||
> **`appointments.view` هم** باز میشوند. منشی یا پزشکِ عضوی که اجازهٔ ثبت نوبت دارد
|
||
> ولی تنظیمات نوبتدهی برایش بسته است، وگرنه نمیتوانست همان نوبتی را که مجاز است
|
||
> ثبت کند. نوشتن همچنان فقط `appointment_settings.update`.
|
||
|
||
---
|
||
|
||
## منبع چیست
|
||
|
||
قانون طلایی اول مستند: **«تقویم مال منبع است، نه مال پزشک.»** منبع هر چیزی است که
|
||
ممکن است اشغال باشد: پزشک، اپراتور، دستیار، دستگاه، اتاق، تخت، یونیت.
|
||
|
||
هر منبع مال یک **محل نوبتدهی** است — همان رکورد `doctor_addresses`. پس همهجا
|
||
`address_uuid` است، نه `branch_id`. مفهوم «شعبه» از محصول حذف شد و خودِ آدرس فقط لنگرِ
|
||
نامرئیِ محیط ماند؛ فهرستش در [doctor.md](doctor.md#get-apiv1addresses).
|
||
|
||
### پل، نه ادغام
|
||
|
||
`Doctor` و `ClinicStaff` هرکدام هویت مستقل و مصرفکنندهٔ زنده دارند
|
||
(`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیلشان به زیرکلاسِ منبع
|
||
یعنی مهاجرت همزمان همهٔ آن مسیرها. بهجایش هر منبع **حداکثر یک** پل دارد:
|
||
|
||
| `subject_kind` | یعنی |
|
||
|---|---|
|
||
| `doctor` / `staff` | منبع همان موجودیت است |
|
||
| `null` | دستگاه یا تجهیزات — منبعی که پشتش موجودیت دیگری نیست |
|
||
|
||
قید «حداکثر یکی» در خودِ entity اجبار میشود، نه با `CHECK` دیتابیس: MariaDB قید
|
||
چندستونی را قابل اتکا اجرا نمیکند.
|
||
|
||
**یکتایی `(doctor_id, address_id)` است، نه `(doctor_id)`.** یک `WeeklySchedule` per
|
||
جفت (پزشک، کلینیک) است ولی هر شیفتِ درونش `location_id` خودش را دارد، پس یک پزشک از
|
||
قبل در چند آدرسِ یک محیط کار میکند. کلید زدن فقط روی پزشک، این واقعیت را غیرقابلبیان
|
||
میکرد — و تقویمِ تسک ۰۳ دقیقاً per مکان است.
|
||
|
||
---
|
||
|
||
## سه قرارداد که باید بدانید
|
||
|
||
**۱. `capacity` یعنی همزمانی، نه تعداد ردیف.**
|
||
اتاق تزریق سهتخته **یک** منبع با ظرفیت ۳ است، نه سه منبع. با سه ردیف، موتور جستجو
|
||
باید سه تقویم را ادغام کند و «کدام تخت» بشود تصمیمی که هیچکس نمیخواهد بگیرد. با
|
||
ظرفیت ۳، شرط اشغال یک شمارش ساده در برابر یک سقف است.
|
||
منبعی که یک **شخص** است (پزشک/پرسنل) ظرفیت بیش از ۱ نمیپذیرد.
|
||
|
||
**۲. `setup_minutes` / `cleanup_minutes` جزو نوبت بیمار نیستند.**
|
||
بیمار ساعت ۱۰:۰۰ میآید و ۱۰:۳۰ میرود؛ ولی یونیت از ۹:۵۵ تا ۱۰:۴۰ در دسترس نیست.
|
||
با `WeeklySchedule.meta.buffer_minutes` قاطی نشود: آن فاصلهٔ سراسری بین دو نوبتِ
|
||
**پزشک** است، این per منبع. هر دو کنار هم زندگی میکنند و آن یکی دستنخورده است.
|
||
|
||
**۳. مهارت یک جدول است، نه یک قانون.**
|
||
«کدام اپراتور مجاز است با کدام دستگاه کار کند» یک اطلاعات است. با ۵۰ اپراتور و ۲۰۰
|
||
سرویس، سپردنش به موتور قوانین یعنی ۱۰٬۰۰۰ قانون.
|
||
با `ClinicStaff.job_title` قاطی نشود: آن متن آزاد و فقط برای نمایش است و هیچجا برای
|
||
تصمیمگیری parse نمیشود.
|
||
|
||
⚠️ همهٔ اندپوینتها برای دادهٔ محیط دیگر **۴۰۴** میدهند، نه ۴۰۳ — وجود دادهٔ محیط
|
||
بیگانه لو نمیرود. مالکیت صریح سنجیده میشود و به `TenantFilter` تکیه نمیشود، چون
|
||
جداسازی سختِ فیلتر فقط روی محیطِ *انتخابشده* اعمال میشود
|
||
([tenancy.md](../architecture/tenancy.md)).
|
||
|
||
---
|
||
|
||
## نوع منبع
|
||
|
||
### `GET /api/v1/resource-types`
|
||
|
||
خروجی واقعی (سه نوع سیستمی را `app:resource:backfill` ساخته):
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{ "uuid": "c39053f3-a051-4f03-942f-08a2274bf658", "code": "room", "name": "اتاق", "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 0 },
|
||
{ "uuid": "c0447da1-30af-430b-8cbd-b464ccd81624", "code": "staff", "name": "پرسنل", "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 0 },
|
||
{ "uuid": "dcabd1c3-d554-4613-a33d-7c69b7ae7efe", "code": "doctor", "name": "پزشک", "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 1 }
|
||
]
|
||
}
|
||
```
|
||
|
||
`resources_count` با **یک** کوئری گروهی پر میشود، نه یکی per نوع.
|
||
|
||
### `POST /api/v1/resource-types`
|
||
|
||
| فیلد | نوع | الزامی | قاعده |
|
||
|---|---|---|---|
|
||
| `code` | string | ✅ | `[a-z0-9_]{1,40}` · یکتا **per محیط** (همان کد در محیط دیگر مجاز است) |
|
||
| `name` | string | ✅ | نام نمایشی فارسی |
|
||
| `field_schema` | array\|null | ❌ | فیلدهای فرم ثبت درمان — [پایینتر](#فرم-ثبت-درمان) |
|
||
|
||
**۲۰۱** (خروجی واقعی):
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e",
|
||
"code": "laser",
|
||
"name": "دستگاه لیزر",
|
||
"is_system": false,
|
||
"active": true,
|
||
"created_at": 1785420676,
|
||
"updated_at": 1785420676,
|
||
"resources_count": 0
|
||
}
|
||
}
|
||
```
|
||
|
||
**۴۲۲:** کد تکراری در همان محیط (field `code`) · کد نامعتبر (field `code`) · نام خالی.
|
||
|
||
### `PATCH /api/v1/resource-type/{uuid}`
|
||
|
||
فقط `name` و `active` و `field_schema`. **`code` تغییر نمیکند** حتی روی نوع غیرسیستمی:
|
||
منابع موجود و پل خودکار با همان کد پیدا میشوند و عوض کردنش نگاشت را بیصدا میشکند.
|
||
فرستادنش خطا نمیدهد، نادیده گرفته میشود.
|
||
|
||
نبودنِ کلید `field_schema` یعنی «دست نزن»؛ `null` یا آرایهٔ خالی یعنی «این نوع منبع فرمی
|
||
ندارد» و هر دو به `null` ذخیره میشوند.
|
||
|
||
### فرم ثبت درمان
|
||
|
||
هر نوع منبع میگوید اپراتور بعد از درمانِ هر ناحیه با آن چه چیزی ثبت کند. تعریف اینجاست
|
||
نه روی سرویس، چون خودِ دستگاه تعیین میکند چه چیزی خواندنی است: لیزر انرژی و پالس و شات
|
||
دارد، دستگاه RF چیز دیگری. افزودن دستگاه تازه تنظیمات است، نه migration.
|
||
|
||
| فیلد | نوع | الزامی | قاعده |
|
||
|---|---|---|---|
|
||
| `key` | string | ✅ | `^[a-z][a-z0-9_]{0,39}$` · یکتا در همان schema |
|
||
| `label` | string | ✅ | برچسب فارسی که به اپراتور نشان داده میشود |
|
||
| `type` | string | ✅ | `select` یا `number` یا `text` — همین سه |
|
||
| `options` | array | فقط برای `select` | مقادیر ساده؛ فهرست خالی رد میشود |
|
||
| `required` | bool | ❌ | پیشفرض `false` |
|
||
| `sort_order` | int | ❌ | پیشفرض ترتیب آرایه؛ خروجی بر همین اساس مرتب میشود |
|
||
|
||
حداکثر ۲۰ فیلد. مقدار `text` حداکثر ۵۰۰ نویسه.
|
||
|
||
خروجی واقعی `PATCH` روی یک نوعِ لیزر:
|
||
|
||
```json
|
||
{
|
||
"key": "energy",
|
||
"label": "انرژی",
|
||
"type": "select",
|
||
"required": true,
|
||
"sort_order": 0,
|
||
"options": [7, 8, 9, 10, 12, 14, 16, 18]
|
||
}
|
||
```
|
||
|
||
**۴۲۲ های واقعی:**
|
||
|
||
```json
|
||
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"نوع فیلد «x» باید یکی از select، number، text باشد","field":"type"}]}
|
||
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_002","message":"فیلد انتخابی «energy» باید گزینه داشته باشد","field":"options"}]}
|
||
```
|
||
|
||
**مقادیر** هم با همین تعریف سنجیده میشوند، وقتی اپراتور ناحیهای را تمام میکند:
|
||
|
||
- کلیدی که در schema نیست **رد میشود**، نه اینکه بیصدا ذخیره شود — وگرنه اپراتور فکر
|
||
میکند چیزی ثبت کرده که هیچوقت دیده نمیشود.
|
||
- مقدار خارج از `options` رد میشود؛ مقایسه رشتهای است تا `"18"` و `18` یک گزینه باشند.
|
||
- فیلد `required` که نیامده باشد ۴۲۲ میگیرد؛ فیلد اختیاری از خروجی حذف میشود.
|
||
- برای نوع منبعی که `field_schema` ندارد، فرستادن هر مقداری ۴۲۲ است.
|
||
|
||
### `DELETE /api/v1/resource-type/{uuid}`
|
||
|
||
**۴۲۲** وقتی `is_system` است («نوع منبع سیستمی حذف نمیشود») یا منبعی از آن نوع وجود
|
||
دارد («این نوع روی N منبع استفاده شده است»).
|
||
|
||
---
|
||
|
||
## مهارت
|
||
|
||
### `GET /api/v1/skills` · `POST /api/v1/skills`
|
||
|
||
بدنهٔ ساخت فقط `name`. **۲۰۱** (خروجی واقعی):
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"uuid": "48e60355-6788-4202-a775-a07986658662",
|
||
"name": "لیزر آلکساندرایت",
|
||
"active": true,
|
||
"created_at": 1785420692,
|
||
"updated_at": 1785420692,
|
||
"resources_count": 0
|
||
}
|
||
}
|
||
```
|
||
|
||
### `PATCH /api/v1/skill/{uuid}` — `name` و `active`
|
||
|
||
### `DELETE /api/v1/skill/{uuid}`
|
||
|
||
مهارتی که روی منبعی نشسته حذف نمیشود؛ وگرنه `ON DELETE RESTRICT` خطای خام دیتابیس
|
||
میداد. **۴۲۲** (خروجی واقعی):
|
||
|
||
```json
|
||
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"این مهارت به 1 منبع داده شده است؛ اول از آنها برداشته شود"}]}
|
||
```
|
||
|
||
> رقم لاتین در پیام عمدی است — قرارداد پیامهای درونریزیِ بکاند همین است و
|
||
> قالببندی فارسی کارِ نمایش در کلاینت است.
|
||
|
||
---
|
||
|
||
## منابع
|
||
|
||
### `GET /api/v1/resources`
|
||
|
||
| Query | توضیح |
|
||
|---|---|
|
||
| `address_uuid` | فقط منابع این محل نوبتدهی |
|
||
| `type_uuid` | فقط این نوع |
|
||
| `skill_uuid` | فقط منابعی که این مهارت را دارند |
|
||
| `active` | `1` / `0` |
|
||
| `clinic_uuid` | انتخاب صریح محیط |
|
||
|
||
`skill_uuid` متعلق به محیط دیگر **۴۰۴** میدهد، نه «هیچ نتیجه» — سکوت اینجا یعنی
|
||
دیباگِ کور.
|
||
|
||
### `POST /api/v1/resource`
|
||
|
||
| فیلد | نوع | الزامی | قاعده |
|
||
|---|---|---|---|
|
||
| `address_uuid` | string | — | **از ۲۰۲۶-۰۸ اختیاری.** نیامدنش یعنی آدرسِ خودِ محیط؛ جفت محیطِ منبع از همین مشتق میشود، نه از بدنه |
|
||
| `type_uuid` | string | ✅ | |
|
||
| `supervisor_doctor_uuid` | string | ✅ | پزشکِ ناظرِ منبع. باید پزشکِ همین محیط باشد وگرنه ۴۰۴ |
|
||
| `name` | string | ✅ | حداکثر ۱۵۰ نویسه |
|
||
| `capacity` | int | — | پیشفرض ۱، حداقل ۱؛ روی منبعِ شخص حداکثر ۱ |
|
||
| `setup_minutes` | int | — | پیشفرض ۰، بازهٔ ۰..۴۸۰ |
|
||
| `cleanup_minutes` | int | — | پیشفرض ۰، بازهٔ ۰..۴۸۰ |
|
||
| `attributes` | object | — | حداکثر ۲۰ کلید · کلید `[a-z_]{1,40}` · مقدار فقط اسکالر |
|
||
| `active` | bool | — | پیشفرض `true` |
|
||
|
||
### سقف منابع بر اساس پلن اشتراک (2026-08)
|
||
|
||
ساخت منبع به سقفِ پلنِ **مؤثرِ** محیط محدود است — `subscription_plans.max_resources`:
|
||
|
||
| پلن | سقف |
|
||
|---|---|
|
||
| بدون اشتراک فعال (`free`) | ۱ منبع |
|
||
| `basic` — شامل دورهٔ آزمایشی | ۳ منبع |
|
||
| `professional` | نامحدود (`-1`) |
|
||
|
||
- شمارش روی **همهٔ** منابع همان جفتِ محیط است: فعال و غیرفعال، و منابعِ پلِ پزشک/پرسنل
|
||
هم شمرده میشوند. غیرفعالکردن جای خالی نمیسازد؛ فقط حذف میسازد.
|
||
- سقف پیش از هر اعتبارسنجی دیگری سنجیده میشود، پس بدنهٔ ناقص هم همین خطا را میگیرد.
|
||
- سقف فقط روی همین اندپوینت است. پلِ خودکارِ منبع برای پزشک/پرسنل مسدود نمیشود، ولی
|
||
در شمارش میآید.
|
||
- **۴۲۲ `ERR_RESOURCE_LIMIT_001`:** «پلن فعلی حداکثر N منبع را پشتیبانی میکند؛ برای
|
||
افزودن، پنل را ارتقا دهید».
|
||
|
||
سقف در `GET /api/v1/subscription/my` زیر `effective_plan.max_resources` میآید؛ پنل با
|
||
همان و شمارشِ `GET /api/v1/resources` دکمهٔ افزودن را میبندد. تست:
|
||
`tests/Resource/ResourceQuotaTest.php`.
|
||
|
||
### شعبه از منابع حذف شد (2026-08)
|
||
|
||
منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ خودِ کلینیکاند، و آن انتخابگر همیشه یک
|
||
گزینه داشت — یک کلیک اجباری که هیچ تصمیمی نبود.
|
||
|
||
| قبل | حالا |
|
||
|---|---|
|
||
| `address_uuid` در ساخت منبع و استخر الزامی | اختیاری؛ نیامدنش = آدرسِ خودِ محیط (اولین آدرس) |
|
||
| پنل «شعبه» میپرسید و ستون/فیلترش را داشت | هیچجای پنل شعبه پرسیده یا نشان داده نمیشود |
|
||
| `doctor_addresses.active = 0` روزِ منبع را خالی میکرد (`address_inactive`) | فعالبودنِ آدرس دیگر گیت نیست |
|
||
|
||
ستون `address_id` سرِ جایش میماند: منطقهٔ زمانی و جفتِ محیطِ منبع از آن میآیند. فقط
|
||
دیگر تصمیمِ کاربر نیست.
|
||
|
||
حذف گیتِ `address_inactive` یک باگ واقعی را میبندد: یک ردیف آدرسِ قدیمی با `active = 0`
|
||
همهٔ دستگاههای آن کلینیک را با پیامی خاموش میکرد که **هیچ صفحهای در پنل راهی برای
|
||
روشنکردنش نداشت** — هیچ اندپوینتی هم `active` آدرس را نمینویسد.
|
||
|
||
محیطی که هیچ آدرسی ندارد، `422` میگیرد با پیام «برای این محیط آدرسی ثبت نشده است —
|
||
ابتدا آدرس کلینیک را کامل کنید»، نه یک خطای مبهم.
|
||
|
||
تست: `tests/Resource/ResourceWithoutBranchTest.php`.
|
||
|
||
### `service_section` روی فهرست سرویسهای منبع (2026-08)
|
||
|
||
`GET /api/v1/resource/{uuid}/services` برای هر سرویس `service_section` را هم میدهد
|
||
(`{uuid, name}`). مودالِ رزرو منبع سرویسها را زیر بخششان گروه میکند — مثل نوبتدهی
|
||
سرویسیِ پزشک — و بدون این فیلد فهرست یک لیستِ تختِ بیدسته میشد.
|
||
|
||
خروجی واقعی:
|
||
|
||
```json
|
||
{
|
||
"service_name": "لیزر زیربغل",
|
||
"service_section": { "uuid": "9eac29e4-…", "name": "خدمات لیزر و زیبایی" },
|
||
"effective_duration_minutes": 20,
|
||
"active": true
|
||
}
|
||
```
|
||
|
||
### پزشک ناظر (2026-08)
|
||
|
||
هر منبع **الزاماً** زیر نظر یک پزشک است؛ صفحهٔ نوبتها تب منابع را زیر همان پزشک
|
||
میچیند، پس منبعِ بیناظر جایی برای دیدهشدن ندارد.
|
||
|
||
- `supervisor_doctor_uuid` در ساخت الزامی است → نبودش `422` با
|
||
`field: supervisor_doctor_uuid` و پیام «انتخاب پزشک ناظر الزامی است».
|
||
- در `PATCH` **اختیاری** است، ولی اگر بیاید نمیتواند خالی باشد (برداشتن ناظر ممنوع).
|
||
- پزشکِ خارج از محیط → `404` («پزشک ناظر یافت نشد») — وجود دادهٔ محیط بیگانه لو نمیرود.
|
||
- پاسخها فیلد `supervisor` را به شکل `{uuid, name}` برمیگردانند (یا `null` برای
|
||
ردیفهایی که پزشکشان حذف شده — کلید خارجی `SET NULL` است).
|
||
|
||
**ناظر با پلِ منبع فرق دارد.** `subject_kind`/`doctor_id` یعنی «این منبع خودِ همان
|
||
پزشک است» و `isPerson()` بر پایهاش ظرفیت را به ۱ قفل میکند. ناظرِ یک دستگاهِ
|
||
سهظرفیتی نباید آن را به منبعِ انسانی تبدیل کند، پس ستون جداست.
|
||
|
||
خروجی واقعی `GET /api/v1/resources?active=1`:
|
||
|
||
```json
|
||
{
|
||
"name": "اتاق ۱",
|
||
"supervisor": { "uuid": "631e81d8-0009-4e01-a40f-3029905a3f27", "name": "امیر کاظمی" },
|
||
"subject_kind": null,
|
||
"capacity": 1
|
||
}
|
||
```
|
||
|
||
**backfill:** ۱۳ منبعِ موجود در migration ناظر گرفتند — منبعِ مطب → همان پزشک، منبعِ
|
||
کلینیک → پزشکِ اولِ همان کلینیک. قابل تغییر از فرم ویرایش منبع.
|
||
|
||
`attributes` عمداً آزاد است — کلید ناشناخته پذیرفته میشود — ولی مقدارش باید ساده
|
||
باشد. دلیل: تسک ۰۵ قید `same_gender` و تسک ۰۹ شرطهای منبع را با مقایسهٔ ساده روی
|
||
همین مقادیر میسنجند؛ آرایهٔ تودرتو یعنی مقایسهٔ دلخواه، همان چیزی که بند ۸ مستند
|
||
ممنوع کرده. کلیدهای قراردادی: `gender`, `device_model`, `floor`, `brand`.
|
||
|
||
**۲۰۱** (خروجی واقعی):
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2",
|
||
"name": "لیزر آلکساندرایت ۱",
|
||
"address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
|
||
"address_name": "درمانگاه شبانه روزی صدرا ",
|
||
"type_uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e",
|
||
"type_code": "laser",
|
||
"type_name": "دستگاه لیزر",
|
||
"capacity": 1,
|
||
"setup_minutes": 5,
|
||
"cleanup_minutes": 10,
|
||
"attributes": { "device_model": "Candela GentleLase", "floor": "2" },
|
||
"subject_kind": null,
|
||
"subject_uuid": null,
|
||
"skills": [],
|
||
"active": true,
|
||
"created_at": 1785420692,
|
||
"updated_at": 1785420692
|
||
}
|
||
}
|
||
```
|
||
|
||
**۴۲۲ — ظرفیت روی منبعِ شخص** (خروجی واقعی):
|
||
|
||
```json
|
||
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"منبعی که یک شخص است نمیتواند ظرفیت بیش از ۱ داشته باشد","field":"capacity"}]}
|
||
```
|
||
|
||
**۴۲۲ — ویژگی غیر اسکالر** (خروجی واقعی):
|
||
|
||
```json
|
||
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"مقدار ویژگی «nested» باید یک مقدار ساده باشد","field":"attributes"}]}
|
||
```
|
||
|
||
سایر ۴۲۲ها: `capacity < 1` · نام خالی · کلید ویژگی غیر snake_case ·
|
||
`setup/cleanup` بیرون از ۰..۴۸۰.
|
||
|
||
### `GET/PATCH/DELETE /api/v1/resource/{uuid}`
|
||
|
||
`PATCH` همان فیلدهاست؛ `address_uuid` پذیرفته **نمیشود** (جفت محیط از آدرس مشتق شده
|
||
و write-once است) و `type_uuid` قابل تغییر است.
|
||
|
||
### `GET /api/v1/resource/{uuid}/services`
|
||
|
||
سرویسهایی که این منبع ارائه میدهد، با **مقدار مؤثر** و اینکه هر عدد از کدام سطح آمده.
|
||
|
||
`duration_minutes`/`price_rials` مقدارِ ثبتشده روی همین رابطهاند و `null` یعنی «ارث از
|
||
سطح بالاتر»، نه صفر. `effective_*` نتیجهٔ زنجیرهٔ حل است و `*_source` میگوید کدام سطح
|
||
برنده شده — بدون آن، پنل نمیتواند کنار خانهٔ خالی بنویسد عدد از کجا میآید.
|
||
|
||
زنجیره از خاص به عام: `resource_option` → `resource_service` → `branch` → `service_default`.
|
||
|
||
خروجی واقعی (۲۰۰):
|
||
|
||
```json
|
||
[
|
||
{
|
||
"service_uuid": "f49baba9-68e9-4d61-aa90-fc8c784607e0",
|
||
"service_name": "لیزر CO2",
|
||
"duration_minutes": null,
|
||
"price_rials": null,
|
||
"active": true,
|
||
"effective_duration_minutes": 40,
|
||
"effective_price_rials": 18000000,
|
||
"duration_source": "service_default",
|
||
"price_source": "branch"
|
||
},
|
||
{
|
||
"service_uuid": "7f13ab0d-2f64-4e8c-8e12-154172b6620a",
|
||
"service_name": "ویزیت عمومی",
|
||
"duration_minutes": null,
|
||
"price_rials": 1200000,
|
||
"active": true,
|
||
"effective_duration_minutes": 15,
|
||
"effective_price_rials": 1200000,
|
||
"duration_source": "service_default",
|
||
"price_source": "resource_option"
|
||
}
|
||
]
|
||
```
|
||
|
||
**دسترسی:** `appointment_settings.view` یا `appointments.view`. **۴۰۴:** منبع محیط دیگر.
|
||
|
||
### `PUT /api/v1/resource/{uuid}/services`
|
||
|
||
جایگزینی **کامل**، مثل مهارتها: سرویسی که در بدنه نیست از این منبع برداشته میشود و
|
||
`{"services":[]}` همه را پاک میکند.
|
||
|
||
```json
|
||
{
|
||
"services": [
|
||
{ "service_uuid": "f49baba9-…", "duration_minutes": 15, "price_rials": 9500000, "active": true },
|
||
{ "service_uuid": "7f13ab0d-…", "duration_minutes": "", "price_rials": null }
|
||
]
|
||
}
|
||
```
|
||
|
||
| فیلد | نوع | الزامی | توضیح |
|
||
|---|---|---|---|
|
||
| `service_uuid` | string (UUID) | ✅ | سرویس یا گزینهٔ سرویس؛ هر دو `ServiceItem` اند |
|
||
| `duration_minutes` | int \| null \| `""` | ❌ | مدت اختصاصی این منبع. `null` و رشتهٔ خالی یعنی **ارث**، نه صفر. مقدار ≤ ۰ ⇒ `422` |
|
||
| `price_rials` | int \| null \| `""` | ❌ | همان قاعده؛ منفی ⇒ `422`. صفرِ صریح یعنی رایگان و ارث نمیگیرد |
|
||
| `active` | boolean | ❌ | پیشفرض `true`. غیرفعال یعنی «فعلاً این را نمیدهد» ولی اعداد ذخیرهشده میمانند |
|
||
|
||
پاسخ ۲۰۰ همان فهرست `GET` است (با مقادیر تازه حلشده).
|
||
|
||
**۴۲۲:** `services` غایب یا غیرآرایه · `service_uuid` غایب یا ناموجود · سرویسِ محیط دیگر
|
||
(«سرویس انتخابشده به این محل نوبتدهی تعلق ندارد») · مدت یا قیمت نامعتبر.
|
||
|
||
> **این رابطه روی انتخاب منبع اثر میگذارد.** وقتی برای یک سرویس دستکم یک ردیف ثبت شده
|
||
> باشد، `appointment-availability` فقط منابعی از **همان نوع** را کاندید میکند که ردیف
|
||
> فعال دارند. تا وقتی هیچ ردیفی نیست، هیچ فیلتری اعمال نمیشود — محیطی که هنوز
|
||
> رابطهها را پر نکرده نباید یکشبه بیوقت شود.
|
||
|
||
### `PUT /api/v1/resource/{uuid}/skills`
|
||
|
||
جایگزینی **کامل**: مهارتی که در بدنه نیست، برداشته میشود. `{"skills":[]}` همه را
|
||
پاک میکند.
|
||
|
||
```json
|
||
{ "skills": [ { "skill_uuid": "48e60355-…", "level": 4 } ] }
|
||
```
|
||
|
||
`level` بین ۱ و ۵ (پیشفرض ۱). از روز اول هست چون استراتژی «حفظ متخصصها» در تسک ۰۶
|
||
رویش ساخته میشود و افزودنش بعداً یعنی backfill با حدس.
|
||
|
||
پاسخ ۲۰۰ کلِ منبع است؛ بخش `skills` آن (خروجی واقعی):
|
||
|
||
```json
|
||
"skills": [
|
||
{ "skill_uuid": "48e60355-6788-4202-a775-a07986658662", "skill_name": "لیزر آلکساندرایت", "level": 4 }
|
||
]
|
||
```
|
||
|
||
**۴۲۲:** `level` بیرون از ۱..۵ (field `level`) · مهارت تکراری در یک بدنه ·
|
||
`skill_uuid` غایب. **۴۰۴:** مهارت محیط دیگر.
|
||
|
||
> **اتمی است.** اعتبارسنجی کل فهرست پیش از هر حذفی انجام میشود، پس یک ردیف نامعتبر
|
||
> در انتهای فهرست، مهارتهای درستِ قبلی را پاک نمیکند و بعد ۴۲۲ برگرداند.
|
||
|
||
### `GET /api/v1/resources/timeline`
|
||
|
||
مجوز: `appointment_settings.view`. پشتِ نمای **منابع** در صفحهٔ نوبتها
|
||
(`/admin/appointments`) است.
|
||
|
||
| پارامتر | پیشفرض | توضیح |
|
||
|---|---|---|
|
||
| `date` | امروز | `YYYY-MM-DD` — هر قالب دیگری ۴۲۲ |
|
||
| `address_uuid` | همهٔ محلها | فقط منابع همان محل نوبتدهی |
|
||
| `only_bookable` | `0` | فقط منابعی که دستکم یک سرویسِ فعال ارائه میدهند |
|
||
|
||
فقط منابع **فعال** برمیگردند و منبعِ بیشیفت هم در فهرست میماند تا ردیفش در تایملاین
|
||
دیده شود.
|
||
|
||
بازهها از `resource_occupancy` میآیند نه از خودِ نوبت: بازهٔ اشغال، آمادهسازی و
|
||
تمیزکاری منبع را هم در بر دارد و همان بازهای است که موتور جستجو اشغال میبیند. ردیف
|
||
`released` نمیآید؛ آن تاریخچه است. یک نوبتِ چندبخشی روی چند منبع، چند ردیف دارد —
|
||
همان چیزی که نمای پزشکمحور نشان نمیدهد.
|
||
|
||
خروجی واقعی (سناریوی ۲، ۲۰۲۶-۰۸-۰۵):
|
||
|
||
```jsonc
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"date": 1785875400, // نیمهشب همان روز
|
||
"day_of_week": 4, // ۰ = شنبه
|
||
"resources": [
|
||
{
|
||
"uuid": "…", "name": "اتاق لیزر ۱", "type_name": "اتاق درمان",
|
||
"address_name": "درمانگاه سلامت", "capacity": 1,
|
||
"shifts": [{ "start_minute": 480, "end_minute": 1260 }],
|
||
"shift_minutes": 780, "busy_minutes": 55, "free_minutes": 725,
|
||
"items": [
|
||
{
|
||
"uuid": "6176e73b-df27-4cdf-815c-36f9bfbd68ca",
|
||
"starts_at": 1785931200, "ends_at": 1785931500,
|
||
"status": "booked", "segment_name": "بیحسی موضعی",
|
||
"appointment_id": 47, "patient_name": "زهرا احمدی",
|
||
"appointment_uuid": "17086c41-6cc4-4539-bb5b-4c94e8f373c6",
|
||
"appointment_status": "pending"
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
`shifts` فقط شیفتهای همان روزِ هفته است. **۴۲۲:** قالب `date` غلط
|
||
(`{"code":"ERR_VALIDATION_002","message":"تاریخ باید به شکل YYYY-MM-DD باشد","field":"date"}`).
|
||
|
||
**وقت آزاد با ظرفیت حساب میشود، نه پر/خالی.** دقیقهای «پر» است که تعداد بازههای
|
||
همپوشانش به `capacity` رسیده باشد؛ اتاق سهتخته با دو نوبت همزمان هنوز آزاد است. اگر
|
||
جز این بود، ظرفیت عملاً یک میشد. محاسبه در `ResourceFreeTimeCalculator` است و
|
||
`tests/Resource/ResourceFreeTimeCalculatorTest.php` نُه حالتش را قفل میکند.
|
||
|
||
خروجی واقعی `only_bookable=1` روی سناریوی ۲: از پنج منبع، فقط «لیزر الکساندرایت ۱» و
|
||
«لیزر دایود ۲» برمیگردند — سهتای دیگر هنوز به هیچ سرویسی وصل نشدهاند و ردیفِ
|
||
همیشهخالی میساختند.
|
||
|
||
> **پنل:** ردیفهای این اندپوینت زیر نمای «زمانبندی» صفحهٔ نوبتها میآیند، نه در یک نمای
|
||
> سوم — پرشدن یک ساعت را دستگاه و اتاق تعیین میکنند نه فقط برنامهٔ پزشک، و دو نمای جدا
|
||
> یعنی کاربر باید آنها را با چشم تطبیق دهد.
|
||
|
||
> تعداد کوئری ثابت است: یک کوئری اشغال، یک کوئری شیفت، یک کوئری نوبت — نه یکی بهازای
|
||
> هر منبع.
|
||
|
||
### `GET /api/v1/resource/{uuid}/day-slots` (2026-08)
|
||
|
||
مجوز: `appointment_settings.view` یا `appointments.view`. پارامتر: `date=Y-m-d` (الزامی).
|
||
|
||
بازههای **کاری** منبع در یک روز — ورودیِ تایملاینِ سرویسیِ صفحهٔ نوبتها. معادلِ
|
||
`appointment-slots` پزشک، ولی از تقویم خودِ منبع: ساعت شعبه ∩ شیفت منبع − تعطیلات −
|
||
استثناها.
|
||
|
||
نوبتها اینجا **کسر نمیشوند**: تایملاین نوبتهای همان روز را جدا دارد و کارتها را
|
||
داخل همین بازهها میچیند؛ کسرشان یعنی نوبتِ ثبتشده جایی برای نشستن ندارد.
|
||
|
||
```json
|
||
{ "success": true, "data": {
|
||
"resource_uuid": "ce07…", "date": "2026-08-04", "timezone": "Asia/Tehran",
|
||
"windows": [{ "start": 1785220200, "end": 1785249000, "start_time": "09:00", "end_time": "17:00" }],
|
||
"empty_reason": null
|
||
} }
|
||
```
|
||
|
||
`empty_reason` وقتی `windows` خالی است میگوید چرا: `no_shift`، `national_holiday`،
|
||
`tenant_holiday`، `exception`، `resource_inactive`. خالیبودن خطا
|
||
نیست و کلاینت نباید همه را «تعطیل» بنامد.
|
||
|
||
### `GET /api/v1/resource/{uuid}/service-slots` (2026-08)
|
||
|
||
مجوز: `appointment_settings.view` یا `appointments.view`.
|
||
|
||
| پارامتر | توضیح |
|
||
|---|---|
|
||
| `date` | `Y-m-d`، الزامی |
|
||
| `service_item_uuids[]` | یک یا چند سرویس؛ خالی ⇒ `422` |
|
||
| `durations[<uuid>]` | override مدت، فقط برای همین محاسبه |
|
||
|
||
زمانهای خالیِ کافی برای مجموعِ مدتِ سرویسهای انتخابشده — معادلِ
|
||
`appointment-service-slots` پزشک. مدت هر سرویس از زنجیرهٔ حلِ همان منبع میآید
|
||
(`ResourceServiceResolver`)، اشغال از نوبتهای `resource_id` **و** ردیفهای
|
||
`resource_occupancy` خوانده میشود، و ظرفیت منبع رعایت میشود. زمانها پشتسرهم
|
||
چیده میشوند (بدون بافر) و زمانِ گذشته پیشنهاد نمیشود.
|
||
|
||
```json
|
||
{ "success": true, "data": {
|
||
"resource_uuid": "ce07…", "date": "2026-08-04", "total_duration_minutes": 60,
|
||
"start_times": [{ "start": 1785220200, "end": 1785223800, "start_time": "09:00", "end_time": "10:00" }]
|
||
} }
|
||
```
|
||
|
||
**۴۲۲:** سرویسِ ناموجود · سرویسی که این منبع ارائه نمیدهد (یا offeringش غیرفعال است)
|
||
· سرویسِ بیمدت · تاریخ بدفرم. **۴۰۴:** منبعِ محیط دیگر.
|
||
|
||
ثبتِ خودِ نوبت با همین زمانها از `POST /api/v1/my/appointment` با `resource_uuid`
|
||
انجام میشود ([`appointment.md`](appointment.md)).
|
||
|
||
## نوبتدهی عمومیِ منبعمحور (سایت) (2026-08)
|
||
|
||
سه اندپوینتِ **بدون احراز هویت** که سایت عمومی (`nobat724_front`) با آنها منبع را کشف
|
||
میکند و روی تقویم خودِ منبع نوبت میگیرد. کنترلرشان
|
||
`src/Resource/Controller/PublicResourceBookingController.php` است — عمداً جدا از
|
||
`ResourceBookingSlotController` که منبع را از محیطِ کاربرِ احرازشده حل میکند.
|
||
|
||
**گیتِ عمومیشدن** یک قاعده است و در `PublicResourceBookingService` یکجا تعریف شده:
|
||
منبع فعال باشد، ردیفِ ارائه (`resource_service_offerings`) فعال باشد، و سرویسِ آن ردیف
|
||
هم `bookable` (توگل «نمایش در نوبتدهی آنلاین») و هم `active` باشد. منبعی که هیچ سرویسِ
|
||
روشنی ندارد اصلاً در پاسخ نمیآید.
|
||
|
||
### `GET /api/v1/appointment-booking-resources/{doctorUuid}` (2026-08)
|
||
|
||
عمومی — بدون توکن.
|
||
|
||
| پارامتر | توضیح |
|
||
|---|---|
|
||
| `clinic_uuid` | اختیاری. **نبودش یعنی همهٔ محیطهای این پزشک** — مطب شخصی بهعلاوهٔ هر کلینیکی که عضوش است |
|
||
|
||
فقط منابعی برمیگردند که **پزشکِ همین صفحه** یا ناظرشان است (`supervisor_id`) یا خودشان
|
||
پلِ همان پزشکاند (`doctor_id`). `duration_minutes` و `price_rials` هر سرویس از زنجیرهٔ
|
||
حلِ همان منبع میآیند (`ResourceServiceResolver`)، نه از پیشفرضِ خامِ سرویس.
|
||
|
||
هر منبع `clinic_uuid`ِ محیطِ خودش را همراه دارد (تهی = مطب شخصی) و سایت نوبت را با همان
|
||
ثبت میکند. این عمدی است: منبع تقویم و شعبهٔ خودش را دارد و به برنامهٔ هفتگیِ پزشک وابسته
|
||
نیست، پس پزشکی که خودش نوبت آنلاین نمیدهد هیچ «محل نوبتدهی»ای ندارد که سایت
|
||
`clinic_uuid` را از آن بردارد. اگر پاسخِ بدون پارامتر فقط مطب شخصی را میداد، دستگاهِ
|
||
قابلِ رزروِ چنین پزشکی هرگز در سایت پیدا نمیشد.
|
||
|
||
خروجی واقعی (اجرای محلی، بدون هدر `Authorization`):
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"doctor_uuid": "f9c746ba-3b59-4e5f-a96a-986f5198c173",
|
||
"clinic_uuid": "279859e9-be78-4ce9-aebd-e68f6f12126c",
|
||
"resources": [
|
||
{
|
||
"uuid": "9bb129ac-6650-4078-8942-bef8d1ce844d",
|
||
"name": "کندلا2021",
|
||
"clinic_uuid": "279859e9-be78-4ce9-aebd-e68f6f12126c",
|
||
"type": { "code": "laser_device", "name": "دستگاه لیزر" },
|
||
"location": {
|
||
"uuid": "6cafca59-8261-47f6-93d2-2d6e16f6aeb3",
|
||
"title": "کلنیک مدیسا",
|
||
"address": ""
|
||
},
|
||
"supervisor": {
|
||
"uuid": "f9c746ba-3b59-4e5f-a96a-986f5198c173",
|
||
"full_name": "پزشک دعوتشده"
|
||
},
|
||
"services": [
|
||
{
|
||
"uuid": "f3e8f166-8ec0-479b-a82a-b5133bb06698",
|
||
"name": "لیزیر دست",
|
||
"duration_minutes": 20,
|
||
"price_rials": 2000000,
|
||
"service_section": {
|
||
"uuid": "fff74b7f-da59-4928-a549-e3ba96b43119",
|
||
"name": "لیزیر"
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**۲۰۰ با `resources: []`** — پزشکِ بدون منبع، منبعِ غیرفعال، سرویسِ خاموش، یا ردیفِ ارائهٔ
|
||
غیرفعال. هیچکدام خطا نیستند.
|
||
**۴۰۴:** پزشک یافت نشد (`ERR_VALIDATION_002`) · `clinic_uuid`ی که پزشک عضوش نیست
|
||
(«محل نوبتدهی یافت نشد»).
|
||
|
||
`capacity` عمداً در پاسخ نیست: عددِ عملیاتیِ داخلِ کلینیک است و سایت مصرفی برایش ندارد.
|
||
|
||
### `GET /api/v1/appointment-resource-slots` (2026-08)
|
||
|
||
عمومی — بدون توکن. نسخهٔ عمومیِ `GET /api/v1/resource/{uuid}/service-slots`.
|
||
|
||
| پارامتر | توضیح |
|
||
|---|---|
|
||
| `resource_uuid` | الزامی |
|
||
| `date` | `Y-m-d`، الزامی. تاریخِ تقویمیِ واقعی — «2026-13-99» رد میشود |
|
||
| `service_item_uuids[]` | یک یا چند سرویسِ روشن؛ خالی ⇒ `422` |
|
||
|
||
`durations[]` که نسخهٔ پنلی میپذیرد اینجا **پشتیبانی نمیشود**: override مدت ابزار منشی
|
||
است و در دست بازدیدکننده یعنی ساختنِ ظرفیتِ ساختگی.
|
||
|
||
مدت، اشغال، ظرفیت و چیدمانِ پشتسرهم دقیقاً مثل نسخهٔ پنلی است
|
||
(`ResourceBookingSlotService`).
|
||
|
||
خروجی واقعی (بدون هدر `Authorization`؛ کوتاهشده):
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"resource_uuid": "9bb129ac-6650-4078-8942-bef8d1ce844d",
|
||
"date": "2026-08-10",
|
||
"timezone": "Asia/Tehran",
|
||
"total_duration_minutes": 20,
|
||
"start_times": [
|
||
{ "start": 1786339800, "end": 1786341000, "start_time": "09:00", "end_time": "09:20" },
|
||
{ "start": 1786341000, "end": 1786342200, "start_time": "09:20", "end_time": "09:40" }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**۴۲۲ — `field: resource_uuid`** (`ERR_VALIDATION_002`، «منبع یافت نشد»): منبعِ ناموجود،
|
||
غیرفعال، یا منبعی که هیچ سرویسِ روشنی ندارد. عمداً ۴۲۲ است نه ۴۰۴، چون همان کدی است که
|
||
`POST /api/v1/appointment` برای منبع برمیگرداند.
|
||
|
||
**۴۲۲ — `field: service_item_uuids`**: سرویسِ ناموجود (`ERR_VALIDATION_002`) · سرویسی که
|
||
توگلِ آنلاینش خاموش است یا این منبع ارائهاش نمیدهد · سرویسِ بیمدت · فهرست خالی.
|
||
|
||
```json
|
||
{
|
||
"success": false,
|
||
"data": null,
|
||
"errors": [
|
||
{ "code": "ERR_VALIDATION_001", "message": "این سرویس برای نوبتدهی آنلاین فعال نیست", "field": "service_item_uuids" }
|
||
]
|
||
}
|
||
```
|
||
|
||
**۴۲۲ — `field: date`**: فرمت یا تاریخِ ناموجود.
|
||
|
||
**۲۰۰ با `start_times: []`** — روزی که منبع شیفت ندارد یا کاملاً پر است. خطا نیست.
|
||
|
||
### `GET /api/v1/appointment-resource-month-availability/{resourceUuid}` (2026-08)
|
||
|
||
عمومی — بدون توکن. ورودیِ تقویمِ سایت؛ معادلِ منبعمحورِ
|
||
`appointment-settings/month-availability/{doctorUuid}`.
|
||
|
||
| پارامتر | توضیح |
|
||
|---|---|
|
||
| `year` · `month` | **میلادی**، همان قرارداد نسخهٔ پزشکمحور |
|
||
| `service_item_uuids[]` | الزامی |
|
||
|
||
سرویسها الزامیاند چون منبع اسلاتِ ثابت ندارد: «روز فعال» یعنی دستکم یک بازهٔ خالی به
|
||
اندازهٔ مجموعِ مدتِ همین سرویسها. بدون آن، تقویم روزی را سبز نشان میداد که برای سرویسِ
|
||
۹۰ دقیقهای جا ندارد.
|
||
|
||
`enabled_dates` و `disabled_dates` با هم **همهٔ** روزهای ماهاند؛ سایت روی همین دو فهرست
|
||
تصمیم میگیرد. برخلاف نسخهٔ پزشکمحور فیلد `online_booking_enabled` ندارد — آن پرچم روی
|
||
برنامهٔ هفتگیِ پزشک است و منبع همتایی برایش ندارد. مصرفکنندهٔ سایت نبودش را «روشن»
|
||
تفسیر میکند.
|
||
|
||
خروجی واقعی (بدون توکن؛ فهرستها کوتاهشده):
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"resource_uuid": "9bb129ac-6650-4078-8942-bef8d1ce844d",
|
||
"year": 2026,
|
||
"month": 9,
|
||
"total_duration_minutes": 20,
|
||
"enabled_dates": ["2026-09-01", "2026-09-02", "2026-09-05"],
|
||
"disabled_dates": ["2026-09-03", "2026-09-04", "2026-09-10"]
|
||
}
|
||
}
|
||
```
|
||
|
||
خطاها: همان `resource_uuid` و `service_item_uuids`ِ اندپوینت بالا، بهعلاوهٔ **۴۲۲ با
|
||
`field: month`** روی سال یا ماهِ نامعتبر.
|
||
|
||
**هزینه:** پیادهسازی همان الگوی حلقهٔ روزانهٔ نسخهٔ پزشکمحور است. اندازهگیری محلی روی
|
||
ماهی با ۳۰ روز: حدود ۲۷ میلیثانیه در فراخوانی گرم (اولین فراخوانی ۸۷ میلیثانیه). بهینهسازی
|
||
بازهای لازم نشد.
|
||
|
||
### `PUT /api/v1/resource/{uuid}/categories`
|
||
|
||
مجوز: `appointment_settings.update`.
|
||
|
||
> **از ۲۰۲۶-۰۸ در پنل ادمین سطحی ندارد.** تب «دستهبندیها»ی صفحهٔ منبع برداشته شد؛ اندپوینت و
|
||
> جدول و فیلد `categories` در پاسخ سرِ جایشاناند، ولی هیچ صفحهای آنها را نمینویسد و نمیخواند.
|
||
> تنها اثر رفتاریِ این داده، ترتیبِ `findEligible` است که مصرفکنندهاش (`AppointmentPlanBuilder`)
|
||
> فقط `count` و `max` میگیرد — یعنی امروز روی هیچ خروجیای اثر ندارد. «کدام منبع این سرویس را
|
||
> میدهد» را `ResourceServiceOffering` صریح و بهصورت فیلتر جواب میدهد.
|
||
|
||
دستهٔ منبع از **کاتالوگ سراسری** انتخاب میشود (`CatalogCategory`) — همان دستههایی که سرویس
|
||
هم از آنها استفاده میکند. ساخت دسته اینجا ممکن نیست؛ فقط در «تنظیمات ← دستهبندیها»
|
||
([`clinic-services.md`](clinic-services.md)).
|
||
|
||
جایگزینی **کامل**، مثل `skills`: `{"category_uuids":[]}` همه را پاک میکند.
|
||
|
||
```json
|
||
{ "category_uuids": ["8ae755b5-5f27-404e-9b63-1b29693a9039"] }
|
||
```
|
||
|
||
پاسخ ۲۰۰ کلِ منبع است؛ بخش `categories` آن (خروجی واقعی):
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"uuid": "ce070910-7038-4f50-9f7a-1b35ec1a67f7",
|
||
"name": "اتاق ۱",
|
||
"categories": [
|
||
{ "uuid": "8ae755b5-5f27-404e-9b63-1b29693a9039", "name": "دست (doc)" }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**۴۲۲:** نبودِ `category_uuids` (`{"code":"ERR_VALIDATION_002","message":"فیلد category_uuids الزامی است","field":"category_uuids"}`)
|
||
· دستهٔ محیط دیگر. uuid از بدنهٔ درخواست میآید و `TenantFilter` رویش اعمال نمیشود، پس محیطِ
|
||
هر دسته صریحاً با محیط منبع مقایسه میشود.
|
||
|
||
---
|
||
|
||
## استخر منابع
|
||
|
||
گروهی از منابع که **جایگزین کامل** یکدیگرند: «لیزرهای آلکساندرایت»، «اتاقهای معاینه».
|
||
|
||
### `GET/POST /api/v1/resource-pools`
|
||
|
||
بدنهٔ ساخت: `address_uuid` · `type_uuid` · `name`.
|
||
|
||
### `GET/PATCH/DELETE /api/v1/resource-pool/{uuid}`
|
||
|
||
`PATCH` فقط `name` و `active`. `DELETE` اعضا را با CASCADE میبرد ولی **خودِ منابع
|
||
دستنخورده میمانند**.
|
||
|
||
### `PUT /api/v1/resource-pool/{uuid}/members`
|
||
|
||
```json
|
||
{ "members": [ { "resource_uuid": "38bdd1e9-…", "priority": 0 } ] }
|
||
```
|
||
|
||
هر عضو باید **همان شعبه و همان نوعِ** استخر را داشته باشد. تسک ۰۶ فرض میکند هر عضو
|
||
جایگزین کامل دیگری است: عضوی از شعبهٔ دیگر یعنی بیمار در ساختمان اشتباه میایستد، و
|
||
عضوی از نوع دیگر یعنی صندلی بهجای دستگاه لیزر پیشنهاد میشود.
|
||
|
||
`priority` ترتیب ترجیح در استراتژی انتخاب است؛ کوچکتر زودتر.
|
||
|
||
**۲۰۰** (خروجی واقعی):
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"uuid": "9d50cf4a-bef6-423c-85e9-530bf3609964",
|
||
"name": "لیزرهای آلکساندرایت",
|
||
"address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
|
||
"address_name": "درمانگاه شبانه روزی صدرا ",
|
||
"type_uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e",
|
||
"type_code": "laser",
|
||
"type_name": "دستگاه لیزر",
|
||
"members": [
|
||
{ "resource_uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2", "resource_name": "لیزر آلکساندرایت ۱", "priority": 0, "active": true }
|
||
],
|
||
"active": true,
|
||
"created_at": 1785420708,
|
||
"updated_at": 1785420708
|
||
}
|
||
}
|
||
```
|
||
|
||
**۴۲۲:** «همهٔ اعضای استخر باید در یک شعبه باشند» · «… از یک نوع منبع باشند» · عضو
|
||
تکراری. مثل مهارتها، اعتبارسنجی پیش از حذف است.
|
||
|
||
استخر **بدون عضو** معتبر است (در حال ساخت)، ولی تسک ۰۶ آن را «هیچ منبعی» میبیند.
|
||
|
||
---
|
||
|
||
## `app:resource:backfill`
|
||
|
||
هر موجودیت قابلاشغالِ موجود را به یک منبع پل میزند.
|
||
|
||
```bash
|
||
ddev exec php bin/console app:resource:backfill # dry-run
|
||
ddev exec php bin/console app:resource:backfill --force
|
||
ddev exec php bin/console app:resource:backfill --force --pair=clinic:12
|
||
```
|
||
|
||
| مورد | رفتار |
|
||
|---|---|
|
||
| اتاق | آدرسش را خودش دارد → منبع با **همان `capacity`** و همان `active` |
|
||
| پزشک | یک منبع per آدرسی که در برنامهٔ هفتگی **شیفت فعال** دارد (`location_id`) |
|
||
| پرسنل | فقط اگر محیط دقیقاً **یک** شعبه دارد؛ وگرنه رد و **گزارش** میشود |
|
||
|
||
پرسنل تنها موردی است که قابل استنتاج نیست: هیچ ستونی نمیگوید در کدام شعبه کار میکند.
|
||
حدس زدنِ «اولین شعبه» او را در ساختمان اشتباه مینشاند، پس گزارش میشود تا کاربر خودش
|
||
تعیین کند.
|
||
|
||
`--pair` هم برای عملیات است (اجرای دوباره برای یک کلینیک) و هم دامنه را محدود میکند.
|
||
دستور **per محیط flush میکند**، پس یک ردیف خراب کل اجرای چندهزارمحیطی را با
|
||
`EntityManagerClosed` از پا نمیاندازد.
|
||
|
||
idempotent است: تکیهگاهش وجود یا نبودِ منبعِ متناظر است، نه یک پرچم جداگانه.
|
||
|
||
---
|
||
|
||
## طبقهبندی محیط
|
||
|
||
| جدول | وضعیت |
|
||
|---|---|
|
||
| `resource_types` · `clinic_resources` · `skills` · `resource_pools` | جفت `(entity_type, entity_id)` |
|
||
| `resource_skills` · `resource_pool_members` | `AGGREGATE_CHILDREN` — ریشههاشان خودشان جفت دارند |
|
||
|
||
---
|
||
|
||
## تستها
|
||
|
||
```bash
|
||
ddev exec php bin/phpunit tests/Resource # ۱۳۶ تست / ۳۵۴ assertion
|
||
ddev exec php vendor/bin/phpstan analyse src/Resource
|
||
npx vitest run assets/admin/pages/ResourcesPage.test.tsx
|
||
npx vitest run assets/admin/components/appointments/ResourceDayPanel.test.tsx \
|
||
assets/admin/components/appointments/ResourceBookingModal.test.tsx \
|
||
assets/admin/components/appointments/serviceTimeline.test.ts
|
||
```
|
||
|
||
---
|
||
|
||
## مسدودسازی موردی
|
||
|
||
«این بعدازظهر دستگاه سرویس دارد» — یک بازهٔ مشخص که منبع در دسترس نیست.
|
||
|
||
| | مسدودسازی موردی | استثنای تقویم |
|
||
|---|---|---|
|
||
| چیست | یک بازهٔ مشخص | تغییر الگوی تکرارشوندهٔ کاری |
|
||
| کجا | `resource_occupancy` | `resource_exceptions` |
|
||
| چقدر میماند | تا وقتی حذفش کنی | بخشی از تعریف تقویم |
|
||
|
||
ادغامشان یعنی یا تعطیلی یک بعدازظهر برای همیشه در تقویم بماند، یا تغییر ساعت کاری با
|
||
یک کلیک ناپدید شود.
|
||
|
||
### GET `/api/v1/resource/{uuid}/blocks`
|
||
|
||
| Query | Type | Description |
|
||
|---|---|---|
|
||
| `from` / `to` | int | پیشفرض: از حالا تا ۳۰ روز بعد |
|
||
|
||
فقط مسدودسازیهای **دستی** برمیگردند؛ اشغالِ نوبتها اینجا نمیآید.
|
||
|
||
### POST `/api/v1/resource/{uuid}/blocks`
|
||
|
||
```json
|
||
{ "starts_at": 1785600000, "ends_at": 1785614400, "reason": "سرویس دورهای دستگاه" }
|
||
```
|
||
|
||
| Code | HTTP | Description |
|
||
|---|---|---|
|
||
| `ERR_VALIDATION_002` | 422 | بازه غایب |
|
||
| `ERR_VALIDATION_001` | 422 | پایان قبل از شروع |
|
||
| `ERR_SLOT_TAKEN` | 409 | در این بازه نوبت یا رزرو موقت هست |
|
||
|
||
مسدودسازی روی بازهای که نوبت دارد **ظرفیت را پس نمیگیرد**: نوبت سرجایش میماند و
|
||
کاربر باید اول تکلیفش را روشن کند.
|
||
|
||
### شمار نوبتهای آینده
|
||
|
||
`GET /api/v1/resource/{uuid}` علاوه بر خودِ منبع، `upcoming_appointments` میدهد: تعداد
|
||
نوبتهای **آینده** که روی این منبع نشستهاند.
|
||
|
||
در فهرست منابع نمیآید — آنجا یک کوئری per ردیف میشد. غیرفعالکردن منبع نوبتهای
|
||
ثبتشده را **لغو نمیکند** و فقط از جستجوی وقتِ بعدی حذفش میکند، پس این عدد هشدار است نه
|
||
مانع؛ پنل هنگام برداشتن تیک «منبع فعال است» نشانش میدهد.
|
||
|
||
### DELETE `/api/v1/resource-block/{uuid}`
|
||
|
||
اشغالی که به نوبت یا رزرو موقت وصل است از این مسیر حذف **نمیشود** (`422`) — وگرنه
|
||
نوبت بیمار بیصدا منبعش را از دست میداد.
|
||
|
||
هر دو عمل رویداد دامنه ثبت میکنند: `ResourceBlocked` و `ResourceReleased`. ظرفیتی که
|
||
برمیگردد باید همانقدر شنیده شود که ظرفیتی که میرود؛ مصرفکنندهای که فقط اولی را
|
||
بشنود، منبع را برای همیشه اشغال میبیند.
|