Files
clinicpro/docs/api/appointment-availability.md
T
hamedandClaude Opus 5 aa6ea45a57 feat(availability): resource ordering strategies, and a real fix for the flaky suite
Strategies (task 06 debt, task 12 dependency)
- ResourcePicker orders candidates; it deliberately does not choose. Only the
  engine knows which resource actually fits this slot and which was already
  taken by another role, and a strategy that picked would have to duplicate
  both checks
- Four implementations behind a tagged iterator: first_available (name order,
  the previous behaviour and still the default because it is predictable),
  least_gap, least_loaded, same_as_previous
- least_gap and least_loaded are deliberate opposites and both are correct;
  choosing between them is a business decision, so it lives in settings
- same_as_previous lifts a course's preferred resource to the front and keeps
  everyone else behind it. A preference, not a filter: forcing the same
  operator would make the patient wait two weeks, which is worse than a
  different operator
- Availability accepts course_uuid to supply that preference, closing the
  dependency task 12 recorded against task 06
- An unknown strategy falls back at search time but is rejected at save time.
  Stale settings must not stop bookings; a user typing a wrong value must not
  believe it took effect

Test suite flake
createUser() retries on a mobile-number collision — db_test is never reset and
holds tens of thousands of users, so the random draw does collide. The failed
INSERT closes the EntityManager, and the retry asked the container for it
again, which hands back the *same closed instance*. So the retry threw, and
every later test in that process inherited a dead manager.

That is the intermittent "EntityManager is closed" on an unrelated,
always-different test that made roughly half of full runs red and never
reproduced in a subset. Resetting the registry gives a live manager back.
UserCollisionRetryTest pins it by closing the manager on purpose.

Two consecutive full runs are green: 1334 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:21:16 +03:30

192 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Appointment Availability API — جستجوی وقت چندمنبعی
> **Base:** `/api/v1` · **Auth:** JWT
> وابسته به [appointment-plan.md](appointment-plan.md) و [resource-calendar.md](resource-calendar.md).
---
## چه چیزی را حل می‌کند
بند ۱۰ مستند: برنامهٔ چندبخشی نوبت را روی تقویم منابع بلغزان و بگو چه ساعت‌هایی
**واقعاً** ممکن‌اند، با پیشنهاد اینکه کدام منبع استفاده شود.
مسیر قبلی فقط تداخل **پزشک** را می‌سنجید؛ اتاق، دستگاه و اپراتور اصلاً وجود نداشتند.
### چرا این ظرفیت آزاد می‌کند
تخصیص **per نقش** است، نه per بخش. اپراتوری که در بخش «انتظار اثر کرم» نیازمندی
ندارد، در آن دقایق بررسی نمی‌شود و برای بیمار دیگری آزاد است.
نمونهٔ عینی (و تستِ مرجعِ این تسک): بیمار الف ۱۰:۰۰–۱۱:۰۰ نوبت دارد ولی اپراتور فقط
۱۰:۰۰–۱۰:۰۵ و ۱۰:۳۵–۱۱:۰۰ درگیر است. اگر اتاق دومی آزاد باشد، بیمار ب در بازهٔ
۱۰:۰۵–۱۰:۳۵ جا می‌شود. با مدل تک‌بازه‌ای، آن نیم‌ساعت هدر می‌رفت.
### چرا همان منبع در بخش‌های غیرمجاور
یک منبع برای **همهٔ** بخش‌هایی که آن نقش را می‌خواهند انتخاب می‌شود. اپراتور بخش ۱ و
بخش ۳ باید یک نفر باشد؛ انتخاب مستقل per بخش، دو نفر می‌داد و بیمار وسط کار تحویل
شخص دیگری می‌شد.
---
## `POST /api/v1/appointment-availability`
```json
{
"service_uuid": "…",
"branch_uuid": "…",
"from": 1785529800,
"to": 1785616200,
"item_uuids": ["…"],
"patient_gender": "female",
"doctor_uuid": "…",
"step_minutes": 15
}
```
`from`/`to` هر دو شامل‌اند، سقف **۹۰ روز**. `step_minutes` گام تولید کاندید است
(پیش‌فرض ۱۵، حداقل ۵).
**۲۰۰:**
```json
{
"success": true,
"data": {
"plan": { "total_minutes": 60, "segments": [ ] },
"slots": [
{
"start": 1785562200,
"end": 1785565800,
"assignment": {
"room": [{ "uuid": "…", "name": "اتاق ۲" }],
"operator": [{ "uuid": "…", "name": "اپراتور ۱" }],
"device": [{ "uuid": "…", "name": "لیزر ۳" }]
}
}
],
"reason": null
}
}
```
`plan` هم برمی‌گردد تا کلاینت مجبور نباشد جدا `preview` بزند.
**فهرست خالی خطا نیست و ۴۰۴ هم نیست.** `reason: "no_capacity_in_range"` می‌آید تا
کلاینت مجبور نباشد از خالی بودن حدس بزند — ممکن است واقعاً ظرفیتی نباشد.
پاسخ حداکثر **۵۰۰** زمان دارد؛ جستجوی یک‌ماهه نباید هزاران ردیف برگرداند.
### خطاها
| کد | HTTP | کِی |
|---|---|---|
| `ERR_WRONG_BOOKING_MODE` | ۴۲۲ | این محل روی `booking_mode = resource` نیست |
| `ERR_NO_ELIGIBLE_RESOURCE` | ۴۲۲ | هیچ منبعی شرایط یک بخش را ندارد (از برنامه‌ساز) |
| `ERR_VALIDATION_001` | ۴۲۲ | بازهٔ بیش از ۹۰ روز · `to < from` |
| — | ۴۰۴ | سرویس یا شعبهٔ محیط دیگر |
## `GET /api/v1/appointment-availability/month`
`?service_uuid=&branch_uuid=&from=&to=` → فقط `{ "days": [نیمه‌شبِ روزهای دارای ظرفیت] }`.
عمداً سبک است: تقویم ماهانه نباید تخصیص منبع هر زمان را بسازد.
---
## حالت `booking_mode = resource`
مقدار سومِ کنار `slot` و `service`. **افزودنی محض**: پیش‌فرض همچنان `slot` است و هیچ
محیطی خودبه‌خود به این حالت نمی‌رود — ارتقا داوطلبانه و صریح است. محلی که روی این
حالت نرفته باشد، همان `appointment-slots` / `appointment-service-slots` را دارد و
مسیر قدیمی **دست‌نخورده** است.
---
## قواعدی که موتور رعایت می‌کند
| مورد | رفتار |
|---|---|
| `setup/cleanup` منبع | بازهٔ اشغال **گسترده‌تر** از بازهٔ بخش است و در تداخل لحاظ می‌شود |
| ظرفیت منبع | شمارش است نه حضور: اتاق سه‌تخته سه نوبت هم‌زمان می‌گیرد |
| زمان گذشته | حذف می‌شود |
| یک منبع، دو نقش هم‌زمان | مجاز نیست |
### کارایی
هدف مستند: جستجوی یک‌ماهه **زیر نیم ثانیه**.
همهٔ ورودی‌ها یک بار خوانده می‌شوند (تقویم منابع، اشغال‌ها) و بقیه در حافظه است؛ هیچ
کوئری داخل حلقهٔ کاندید یا حلقهٔ روز نیست. کاندیدها هم فقط از پنجره‌های آزادِ
**محدودکننده‌ترین نقش** ساخته می‌شوند — هرس زودهنگام، جستجوی یک‌ماهه را از ده‌ها هزار
کاندید به چند صد می‌رساند.
`tests/Appointment/AvailabilityPerformanceTest.php` این را با ۲۰ منبع و ۵۰۰ نوبت
ثبت‌شده در ۳۰ روز می‌سنجد و بخشی از تسک است، نه اختیاری.
---
## `resource_occupancy`
یک ردیف به‌ازای هر **(بخشِ نوبت × منبع)** — نه یکی به‌ازای کل نوبت. همین ریزدانگی است
که ظرفیت آزاد می‌کند.
`status``booked` | `hold`. نوشتن در این جدول کارِ تسک بعدی (رزرو و ثبت) است؛ این
تسک فقط می‌خواندش.
## تست‌ها
```bash
ddev exec php bin/phpunit tests/Appointment/AvailabilityEngineTest.php # ۹ تست
ddev exec php bin/phpunit tests/Appointment/AvailabilityPerformanceTest.php
```
---
## استراتژی ترتیب منابع
وقتی چند منبع برای یک نقش واجد شرایط‌اند، **ترتیب امتحان‌کردنشان** از تنظیمات محیط
می‌آید (`meta.resource_strategy`). استراتژی فقط مرتب می‌کند؛ تصمیم نهایی همچنان با
موتور است، چون فقط موتور می‌داند کدام منبع در این زمان جا دارد و کدام برای نقش دیگری
برداشته شده.
| کلید | رفتار | کِی مناسب است |
|---|---|---|
| `first_available` | ترتیب نام (پیش‌فرض) | خروجی کاملاً قابل پیش‌بینی |
| `least_gap` | کمترین وقت مردهٔ باقی‌مانده | تقویم کمتر تکه‌تکه شود |
| `least_loaded` | منبعِ آزادتر زودتر | بار بین چند اپراتور پخش شود |
| `same_as_previous` | منبع ترجیحی جلو، بقیه پشت آن | دورهٔ درمان با همان اپراتور |
`least_gap` و `least_loaded` عکس هم عمل می‌کنند و **هر دو درست‌اند**؛ انتخاب بینشان
تصمیم کسب‌وکاری است نه فنی.
### ترجیح منبع دوره
`POST /api/v1/appointment-availability` یک فیلد اختیاری `course_uuid` می‌گیرد. با آن،
`preferred_resource` همان دوره به بالای فهرست می‌رود.
**ترجیح است نه فیلتر:** اگر آن منبع آزاد نباشد، رزرو رد نمی‌شود و به ترتیب پایه
برمی‌گردد — اجبار یعنی بیمار دو هفته منتظر بماند، و آن بدتر از عوض شدن اپراتور است.
### فهرست استراتژی‌ها
`GET /api/v1/appointment-settings/resource-strategies`
```json
{
"success": true,
"data": [
{ "code": "first_available", "label": "به ترتیب نام — ساده و قابل پیش‌بینی" },
{ "code": "least_gap", "label": "کمترین وقت مرده — تقویم کمتر تکه‌تکه می‌شود" }
]
}
```
انتخابگر پنل از همین ساخته می‌شود؛ افزودن استراتژی تازه یعنی افزودن **یک کلاس** با تگ
`app.resource_picker` — نه تغییر موتور، نه تغییر فرانت.
### رفتار با کلید ناشناخته
هنگام **جستجو** به پیش‌فرض برمی‌گردد (تنظیماتِ قدیمی نباید نوبت‌دهی را بخواباند)، ولی
هنگام **ذخیرهٔ تنظیمات** `422` می‌گیرد — وگرنه کاربر فکر می‌کند استراتژی‌اش اعمال
می‌شود در حالی که نمی‌شود.