# Resource API — منابع، نوع منبع، مهارت و استخر

> **Base:** `/api/v1` · **Auth:** JWT روی همهٔ اندپوینت‌ها
> **مجوز:** `appointment_settings` (`view` خواندن، `update` نوشتن) — همان مجوز تنظیمات
> نوبت‌دهی؛ مجوز تازه‌ای ساخته نشده.

---

## منبع چیست

قانون طلایی اول مستند: **«تقویم مال منبع است، نه مال پزشک.»** منبع هر چیزی است که
ممکن است اشغال باشد: پزشک، اپراتور، دستیار، دستگاه، اتاق، تخت، یونیت.

هر منبع مال یک **شعبه** است و شعبه همان رکورد آدرس محل نوبت‌دهی است
(`doctor_addresses` — رجوع به [branch.md](branch.md)). پس همه‌جا `address_uuid` است،
نه `branch_id`.

### پل، نه ادغام

`Doctor`، `ClinicStaff` و `Room` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند
(`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیلشان به زیرکلاسِ منبع
یعنی مهاجرت هم‌زمان همهٔ آن مسیرها. به‌جایش هر منبع **حداکثر یک** پل دارد:

| `subject_kind` | یعنی |
|---|---|
| `doctor` / `staff` / `room` | منبع همان موجودیت است |
| `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 | ✅ | |
| `name` | string | ✅ | حداکثر ۱۵۰ نویسه |
| `capacity` | int | — | پیش‌فرض ۱، حداقل ۱؛ روی منبعِ شخص حداکثر ۱ |
| `setup_minutes` | int | — | پیش‌فرض ۰، بازهٔ ۰..۴۸۰ |
| `cleanup_minutes` | int | — | پیش‌فرض ۰، بازهٔ ۰..۴۸۰ |
| `attributes` | object | — | حداکثر ۲۰ کلید · کلید `[a-z_]{1,40}` · مقدار فقط اسکالر |
| `active` | bool | — | پیش‌فرض `true` |

`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` قابل تغییر است.

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