# 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[]` | 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)). ### `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`. ظرفیتی که برمی‌گردد باید همان‌قدر شنیده شود که ظرفیتی که می‌رود؛ مصرف‌کننده‌ای که فقط اولی را بشنود، منبع را برای همیشه اشغال می‌بیند.