feat(appointments): resources share the doctors' timeline

Three views become two. The resource lanes were a separate tab, which meant
reading a doctor's free hour on one screen and the laser's on another and
matching them by eye — while in the resource-first model it is the device and
the room that decide whether that hour is really free. They now sit under the
same "زمانبندی" view, below the doctor's slots.

Each lane says how much of its shift is still free, and that number respects
capacity: a minute counts as busy only once the overlapping bookings reach
the resource's capacity, so a three-bed room with two appointments is still
open. Treating it otherwise would silently turn every multi-capacity resource
into a single-capacity one. ResourceFreeTimeCalculator does the sweep and
carries nine cases of its own.

only_bookable=1 keeps resources with no service offering out of the view;
they could only ever render an empty lane. On the seeded clinic that is five
resources down to two.

Two ruler defects the screenshot caught: hours rendered in Latin digits, and
the last label was half-clipped by the container so 21 read as 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 16:14:04 +03:30
co-authored by Claude Opus 5
parent b8e8580867
commit 5e87bbc18b
12 changed files with 455 additions and 46 deletions
+3 -4
View File
@@ -477,10 +477,9 @@ Get all practice addresses for a doctor, including addresses of clinics the doct
> `DoctorAddress::toArray()` می‌آیند. هر دو ستون `NOT NULL DEFAULT` دارند، پس ردیف‌های
> قدیمی هم `active: true` و `timezone: "Asia/Tehran"` می‌دهند؛ تغییر additive است.
>
> `active` در فاز فعلی **فقط ذخیره می‌شود** و هیچ اثری بر محاسبهٔ اسلات ندارد. نوشتن
> این دو فیلد از راه اندپوینت‌های همین سند انجام نمی‌شود؛ برای آن
> `PATCH /api/v1/branch/{addressUuid}` است — همراه با ساعت کاری هفتگی و اتاق‌ها در
> [branch.md](branch.md).
> `active` **فقط ذخیره می‌شود** و اثرش بر دسترس‌پذیری فقط همین است که منبعِ روی یک
> آدرسِ غیرفعال، روزش با دلیل `address_inactive` خالی برمی‌گردد. اندپوینت اختصاصیِ
> نوشتنِ این دو فیلد با حذف دامنهٔ شعبه برداشته شد.
---
+23 -8
View File
@@ -11,19 +11,19 @@
قانون طلایی اول مستند: **«تقویم مال منبع است، نه مال پزشک.»** منبع هر چیزی است که
ممکن است اشغال باشد: پزشک، اپراتور، دستیار، دستگاه، اتاق، تخت، یونیت.
هر منبع مال یک **شعبه** است و شعبه همان رکورد آدرس محل نوبت‌دهی است
(`doctor_addresses` — رجوع به [branch.md](branch.md)). پس همه‌جا `address_uuid` است،
نه `branch_id`.
هر منبع مال یک **محل نوبت‌دهی** است همان رکورد `doctor_addresses`. پس همه‌جا
`address_uuid` است، نه `branch_id`. مفهوم «شعبه» از محصول حذف شد و خودِ آدرس فقط لنگرِ
نامرئیِ محیط ماند؛ فهرستش در [doctor.md](doctor.md#get-apiv1addresses).
### پل، نه ادغام
`Doctor`، `ClinicStaff` و `Room` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند
`Doctor` و `ClinicStaff` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند
(`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیلشان به زیرکلاسِ منبع
یعنی مهاجرت هم‌زمان همهٔ آن مسیرها. به‌جایش هر منبع **حداکثر یک** پل دارد:
| `subject_kind` | یعنی |
|---|---|
| `doctor` / `staff` / `room` | منبع همان موجودیت است |
| `doctor` / `staff` | منبع همان موجودیت است |
| `null` | دستگاه یا تجهیزات — منبعی که پشتش موجودیت دیگری نیست |
قید «حداکثر یکی» در خودِ entity اجبار می‌شود، نه با `CHECK` دیتابیس: MariaDB قید
@@ -163,7 +163,7 @@
| Query | توضیح |
|---|---|
| `address_uuid` | فقط منابع این شعبه |
| `address_uuid` | فقط منابع این محل نوبت‌دهی |
| `type_uuid` | فقط این نوع |
| `skill_uuid` | فقط منابعی که این مهارت را دارند |
| `active` | `1` / `0` |
@@ -176,7 +176,7 @@
| فیلد | نوع | الزامی | قاعده |
|---|---|---|---|
| `address_uuid` | string | ✅ | شعبه؛ جفت محیطِ منبع **از همین** مشتق می‌شود، نه از بدنه |
| `address_uuid` | string | ✅ | محل نوبت‌دهی؛ جفت محیطِ منبع **از همین** مشتق می‌شود، نه از بدنه |
| `type_uuid` | string | ✅ | |
| `name` | string | ✅ | حداکثر ۱۵۰ نویسه |
| `capacity` | int | — | پیش‌فرض ۱، حداقل ۱؛ روی منبعِ شخص حداکثر ۱ |
@@ -343,7 +343,8 @@
| پارامتر | پیش‌فرض | توضیح |
|---|---|---|
| `date` | امروز | `YYYY-MM-DD` — هر قالب دیگری ۴۲۲ |
| `address_uuid` | همهٔ شعبه‌ها | فقط منابع همان شعبه |
| `address_uuid` | همهٔ محل‌ها | فقط منابع همان محل نوبت‌دهی |
| `only_bookable` | `0` | فقط منابعی که دست‌کم یک سرویسِ فعال ارائه می‌دهند |
فقط منابع **فعال** برمی‌گردند و منبعِ بی‌شیفت هم در فهرست می‌ماند تا ردیفش در تایم‌لاین
دیده شود.
@@ -366,6 +367,7 @@
"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",
@@ -385,6 +387,19 @@
`shifts` فقط شیفت‌های همان روزِ هفته است. **۴۲۲:** قالب `date` غلط
(`{"code":"ERR_VALIDATION_002","message":"تاریخ باید به شکل YYYY-MM-DD باشد","field":"date"}`).
**وقت آزاد با ظرفیت حساب می‌شود، نه پر/خالی.** دقیقه‌ای «پر» است که تعداد بازه‌های
هم‌پوشانش به `capacity` رسیده باشد؛ اتاق سه‌تخته با دو نوبت هم‌زمان هنوز آزاد است. اگر
جز این بود، ظرفیت عملاً یک می‌شد. محاسبه در `ResourceFreeTimeCalculator` است و
`tests/Resource/ResourceFreeTimeCalculatorTest.php` نُه حالتش را قفل می‌کند.
خروجی واقعی `only_bookable=1` روی سناریوی ۲: از پنج منبع، فقط «لیزر الکساندرایت ۱» و
«لیزر دایود ۲» برمی‌گردند — سه‌تای دیگر هنوز به هیچ سرویسی وصل نشده‌اند و ردیفِ
همیشه‌خالی می‌ساختند.
> **پنل:** ردیف‌های این اندپوینت زیر نمای «زمانبندی» صفحهٔ نوبت‌ها می‌آیند، نه در یک نمای
> سوم — پرشدن یک ساعت را دستگاه و اتاق تعیین می‌کنند نه فقط برنامهٔ پزشک، و دو نمای جدا
> یعنی کاربر باید آن‌ها را با چشم تطبیق دهد.
> تعداد کوئری ثابت است: یک کوئری اشغال، یک کوئری شیفت، یک کوئری نوبت — نه یکی به‌ازای
> هر منبع.