- Add service timeline builder for appointments to manage available slots. - Create a hook to fetch resource booking services with effective durations. - Develop ResourceBookingSlotController to handle API requests for resource booking slots. - Implement ResourceBookingSlotService to calculate available time slots based on resource occupancy and service durations. - Add tests for resource appointment creation and booking slot functionality to ensure correct behavior and edge cases.
699 lines
33 KiB
Markdown
699 lines
33 KiB
Markdown
# 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` |
|
||
|
||
### `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`. **۴۰۴:** منبع محیط دیگر.
|
||
|
||
### `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`. پارامتر: `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`، `address_inactive`. خالیبودن خطا
|
||
نیست و کلاینت نباید همه را «تعطیل» بنامد.
|
||
|
||
### `GET /api/v1/resource/{uuid}/service-slots` (2026-08)
|
||
|
||
مجوز: `appointment_settings.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)).
|
||
|
||
### `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
|
||
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`. ظرفیتی که
|
||
برمیگردد باید همانقدر شنیده شود که ظرفیتی که میرود؛ مصرفکنندهای که فقط اولی را
|
||
بشنود، منبع را برای همیشه اشغال میبیند.
|