The appointments page only ever showed one doctor's row, but in the resource-first model a single appointment can hold a room and a device at the same time, and that — not the doctor's schedule — is what runs the capacity out. An hour could look free on the doctor's lane while the only alexandrite laser was already taken. A third view, "منابع", draws one lane per resource for the selected day. Blocks come from resource_occupancy rather than the appointment: that range includes the device's setup and cleanup minutes and is the same range the availability engine treats as busy. A multi-segment appointment therefore shows up on every resource it holds, and each block links to the appointment it belongs to. GET /api/v1/resources/timeline keeps a fixed query count — one for occupancy, one for shifts, one for the patient names — instead of one per resource. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
581 lines
26 KiB
Markdown
581 lines
26 KiB
Markdown
# 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` قابل تغییر است.
|
||
|
||
### `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` | همهٔ شعبهها | فقط منابع همان شعبه |
|
||
|
||
فقط منابع **فعال** برمیگردند و منبعِ بیشیفت هم در فهرست میماند تا ردیفش در تایملاین
|
||
دیده شود.
|
||
|
||
بازهها از `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 }],
|
||
"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"}`).
|
||
|
||
> تعداد کوئری ثابت است: یک کوئری اشغال، یک کوئری شیفت، یک کوئری نوبت — نه یکی بهازای
|
||
> هر منبع.
|
||
|
||
### `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`. ظرفیتی که
|
||
برمیگردد باید همانقدر شنیده شود که ظرفیتی که میرود؛ مصرفکنندهای که فقط اولی را
|
||
بشنود، منبع را برای همیشه اشغال میبیند.
|