# Resource API — منابع، نوع منبع، مهارت و استخر > **Base:** `/api/v1` · **Auth:** JWT روی همهٔ اندپوینت‌ها > **مجوز:** `appointment_settings` (`view` خواندن، `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 | ✅ | نام نمایشی فارسی | **۲۰۱** (خروجی واقعی): ```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`. **`code` تغییر نمی‌کند** حتی روی نوع غیرسیستمی: منابع موجود و پل خودکار با همان کد پیدا می‌شوند و عوض کردنش نگاشت را بی‌صدا می‌شکند. فرستادنش خطا نمی‌دهد، نادیده گرفته می‌شود. ### `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) هر منبع **الزاماً** زیر نظر یک پزشک است؛ صفحهٔ نوبت‌ها تب منابع را زیر همان پزشک می‌چیند، پس منبعِ بی‌ناظر جایی برای دیده‌شدن ندارد. - `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`. **۴۰۴:** منبع محیط دیگر. ### `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` روی سناریوی ۲: از پنج منبع، فقط «لیزر الکساندرایت ۱» و «لیزر دایود ۲» برمی‌گردند — سه‌تای دیگر هنوز به هیچ سرویسی وصل نشده‌اند و ردیفِ همیشه‌خالی می‌ساختند. > **پنل:** ردیف‌های این اندپوینت زیر نمای «زمانبندی» صفحهٔ نوبت‌ها می‌آیند، نه در یک نمای > سوم — پرشدن یک ساعت را دستگاه و اتاق تعیین می‌کنند نه فقط برنامهٔ پزشک، و دو نمای جدا > یعنی کاربر باید آن‌ها را با چشم تطبیق دهد. > تعداد کوئری ثابت است: یک کوئری اشغال، یک کوئری شیفت، یک کوئری نوبت — نه یکی به‌ازای > هر منبع. ### `PUT /api/v1/resource/{uuid}/categories` مجوز: `appointment_settings.update`. دستهٔ منبع از **کاتالوگ سراسری** انتخاب می‌شود (`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 ``` --- ## مسدودسازی موردی «این بعدازظهر دستگاه سرویس دارد» — یک بازهٔ مشخص که منبع در دسترس نیست. | | مسدودسازی موردی | استثنای تقویم | |---|---|---| | چیست | یک بازهٔ مشخص | تغییر الگوی تکرارشوندهٔ کاری | | کجا | `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`. ظرفیتی که برمی‌گردد باید همان‌قدر شنیده شود که ظرفیتی که می‌رود؛ مصرف‌کننده‌ای که فقط اولی را بشنود، منبع را برای همیشه اشغال می‌بیند.