Files
clinicpro/.claude/prompt/laser-treatment-plan.md
T
hamedandClaude Opus 5 85985b04a0 feat(practice-domain): add practice domains and let a clinic select one
A practice domain is the field a clinic operates in — beauty, dentistry —
and unlike Specialty it is configuration, not a label: treatment workflows
will bind to its code, so the code is immutable once created and only a
platform admin can mint one. A clinic that has not chosen a domain keeps
behaving exactly as it does today.

Assignment reuses PATCH /api/v1/clinic/{uuid} rather than adding a second
endpoint. An unknown domain uuid is rejected instead of silently dropped,
because a lost selection would only surface at the first protocol-driven
booking.

Also corrects ADR-0003: resource occupancy does not in fact guard the panel
booking path, which writes appointments.resource_id and no occupancy row at
all, so the doctor slot key cannot simply be dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:06:29 +03:30

443 lines
24 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.
# طول درمان و پرونده درمان چندجلسه‌ای (فاز اول: لیزر)
## پروژه
`clinicpro` (backend + پنل ادمین). هیچ تغییری در `nobat724_front` و `clinic-pro-tauri` لازم نیست.
## زمینه
این سند خروجی یک جلسه grilling است. ۲۳ تصمیم قفل شد، شش ADR و یک glossary نوشته شد.
پیش از شروع این‌ها را بخوان:
- `clinicpro/CONTEXT.md` — واژگان رسمی این دامنه
- `clinicpro/docs/adr/0001-treatment-sessions-are-not-appointments.md`
- `clinicpro/docs/adr/0002-treatment-areas-are-snapshotted.md`
- `clinicpro/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md`
- `clinicpro/docs/adr/0004-session-parameters-are-json-keyed-by-resource-type.md`
- `clinicpro/docs/adr/0005-treatment-workflows-are-tagged-services.md`
- `clinicpro/docs/adr/0006-clinical-record-is-separate-from-the-visit-record.md`
- `clinicpro/docs/architecture/resource-first-model.md`
از واژگان `CONTEXT.md` استفاده کن. مترادف نساز.
## هدف
کلینیک بتواند سرویسی تعریف کند که درمانش چند جلسه طول می‌کشد، و سیستم برای هر بیمار
پرونده درمان بسازد، جلسات را بشمارد، سررسید جلسه بعد را حساب کند، و اپراتور بتواند
برای هر ناحیه بدن، دستگاه و پارامترهایش را ثبت کند.
فاز اول فقط لیزر. ولی هیچ‌جای کد نباید کلمه «لیزر» را بداند، جز پیاده‌سازی workflow.
## آنچه از قبل هست و باید استفاده شود
| چیز | کجا | نکته |
|---|---|---|
| دسته‌بندی گرافی خدمات | `CatalogCategory` + `CatalogCategoryInclude` | نواحی بدن همین‌جا تعریف می‌شوند |
| بستار گذرا و تشخیص تعارض | `CategoryClosureResolver` | `descendants()` و `overlaps()` |
| سرویس | `ServiceItem` با `catalogCategory` | قیمت و مدت اینجاست |
| نوع منبع داینامیک | `ResourceType` | کدهای سیستمی `doctor` / `staff` / `room` |
| منبع با پزشک ناظر | `ClinicResource.supervisor` | تعریف شده ولی در مسیر رزرو خوانده نمی‌شود |
| اشغال منبع در سطح دیتابیس | `OccupancyBucket` | `uniq_bucket_resource_seat(resource_id, bucket_at, seat)` |
| پرسنل و نقش | `ClinicStaff` + `ROLE_STAFF` | `ClinicStaff.user` اختیاری است |
| داشبورد پرسنل | `GET /api/v1/dashboard/staff` | فیلتر روی `a.staff` |
| صفحه پرسنل | `assets/admin/pages/StaffMyServicesPage.tsx` | فقط سرویس‌های تخصیص‌یافته |
| مراجعه مالی | `PatientSession` | در `AppointmentConfirmationService::onConfirmed` ساخته می‌شود |
## آنچه نیست
هیچ موجودیتی برای پرونده درمان، جلسه درمان، ثبت ناحیه، پروتکل، یا حوزه فعالیت.
`ServiceItem.sessionCount` هست ولی هیچ منطقی از آن استفاده نمی‌کند — عملاً مرده.
---
## تصمیم‌های قفل‌شده
این‌ها در جلسه grilling تصمیم‌گیری شده‌اند. اجرایشان کن، دوباره طراحی نکن.
اگر جایی از کد با یکی از این‌ها تناقض داشت، **متوقف شو و بپرس**؛ خودت تصمیم را عوض نکن.
1. **حوزه فعالیت از `Specialty` جداست.** `Specialty` قرارداد سایت عمومی است و دست نمی‌خورد.
2. **هر کلینیک یک حوزه فعالیت دارد.** نال یعنی تنظیم‌نشده و رفتار امروز.
3. **جلسه درمان موجودیت مستقل است**، نه `Appointment`.
4. **زمان‌بندی با لیست صریح گام‌ها.** فاصله ثابت نداریم.
5. **دوره پایان مشخص دارد.** دوره بی‌پایان وجود ندارد.
6. **همه جلسات از ابتدا ساخته می‌شوند**، ولی فقط جلسه بعدی نوبت می‌گیرد.
7. **ناحیه درمان همان `CatalogCategory` است.** هیچ entity جدیدی برای ناحیه ساخته نشود.
8. **نواحی از دسته سرویس مشتق می‌شوند**، هنگام رزرو انتخاب نمی‌شوند.
9. **فهرست نواحی هنگام ساخت پرونده قفل می‌شود** (snapshot).
10. **پزشک ناظرِ منبع روی نوبت می‌نشیند.** پرسنل انجام می‌دهد.
11. **نوبتی که منبع دارد `active_slot_key` ندارد.** حفاظت فقط با `OccupancyBucket`.
12. **`TreatmentProtocol` موجودیت جداست**، یک‌به‌یک با `ServiceItem`.
13. **پارامترهای دستگاه در JSON**، تعریفشان روی `ResourceType`.
14. **Workflow با tagged service.** موتور داده‌محور نداریم.
15. **حوزه فعالیت را فقط ادمین پلتفرم می‌سازد.**
16. **جلسه وضعیت مستقل دارد** و نوبت را هم‌گام می‌کند. بستن جلسه با ناحیه ناتمام مجاز است.
17. **`TreatmentSession` بالینی است، `PatientSession` مالی.** هیچ فیلد پولی روی جلسه درمان.
18. **قیمت هر جلسه با قیمت روز.** پکیج قیمت نداریم. قیمت هیچ‌وقت قفل نمی‌شود.
19. **جلسه بعد پیشنهاد می‌شود، منشی تأیید می‌کند.** رزرو خودکار بدون انسان نداریم.
20. **حوزه فعالیت فقط workflow را انتخاب می‌کند.** داشبورد اختصاصی به‌ازای تخصص نداریم.
21. **سررسید هر جلسه نسبی به تاریخ واقعی جلسه قبل است.**
22. **no-show جلسه را نمی‌سوزاند.** تعداد جلسات ثابت می‌ماند.
23. **زمان واقعی جدا ثبت می‌شود.** `slotStart` و `slotEnd` هرگز بازنویسی نمی‌شوند.
---
## وظایف
هر وظیفه را جداگانه پیاده کن، تست بنویس، و بعد سراغ بعدی برو.
### ۱. حوزه فعالیت کلینیک
موجودیت جدید در `src/PracticeDomain/Entity/PracticeDomain.php`:
```php
// جدول سراسری (global) — نه per-tenant. در GlobalTables ثبت شود.
id, uuid, code (unique), name, sort_order, active, created_at, updated_at
```
`code` پایدار است و workflow به آن bind می‌شود. بعد از ساخت قابل ویرایش نیست.
روی `Clinic` یک `ManyToOne` نال‌پذیر اضافه کن:
```php
#[ORM\ManyToOne(targetEntity: PracticeDomain::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?PracticeDomain $practiceDomain = null;
```
migration نباید مقدار پیش‌فرض برای کلینیک‌های موجود بگذارد. نال بماند.
اندپوینت‌ها:
- `GET /api/v1/practice-domains` — فهرست فعال‌ها. برای همه نقش‌های پنلی.
- `POST /api/v1/admin/practice-domains` — فقط `ROLE_ADMIN`.
- `PATCH /api/v1/admin/practice-domains/{uuid}` — فقط `name` و `active` و `sort_order`.
- `PATCH /api/v1/clinic/{uuid}/practice-domain` — مدیر کلینیک انتخاب می‌کند.
در پاسخ هر حوزه یک فیلد `has_workflow` بگذار که از `TreatmentWorkflowRegistry` می‌آید.
پنل ادمین پلتفرم باید بتواند نشان دهد کدام حوزه هنوز workflow ندارد.
seed اولیه با migration: `beauty` = «کلینیک زیبایی».
### ۲. پروتکل درمان
سه موجودیت در `src/Treatment/Entity/`:
```php
// TreatmentProtocol — وجود این ردیف یعنی سوییچ «طول درمان» روشن است
id, uuid, service_item_id (unique, ON DELETE CASCADE),
supervisor_doctor_id (nullable), active, created_at, updated_at
// TreatmentProtocolStep — گام‌های دوره
id, protocol_id, step_number, offset_days, created_at
// UNIQUE(protocol_id, step_number)
// offset_days یعنی «فاصله از جلسهٔ قبل»، نه از شروع دوره. گام ۱ همیشه offset_days = 0.
// TreatmentProtocolStaff — پرسنل مجاز به انجام
id, protocol_id, staff_id
// UNIQUE(protocol_id, staff_id)
```
قواعد اعتبارسنجی:
- حداقل دو گام. پروتکل یک‌جلسه‌ای معنی ندارد؛ آن یعنی سوییچ خاموش.
- `step_number` پیوسته از ۱.
- گام اول `offset_days = 0`. بقیه بزرگ‌تر از صفر.
- حداقل یک پرسنل مجاز.
- `supervisor_doctor_id` باید پزشکِ همان محیط باشد.
`ServiceItem::$sessionCount` را `@deprecated` علامت بزن، از `toArray()` بیرون **نبر**
(پنل و تایپ TS به آن وابسته‌اند) ولی هیچ منطق جدیدی از آن نخوان. تعداد جلسات
همیشه `count(protocol.steps)` است.
اندپوینت‌ها زیر `/api/v1/clinic-services/{serviceUuid}/treatment-protocol`:
`GET`، `PUT` (کل پروتکل با گام‌ها و پرسنل یکجا)، `DELETE` (خاموش کردن سوییچ).
### ۳. پرونده و جلسه درمان
```php
// TreatmentCase
id, uuid, entity_type, entity_id, // tenant
patient_record_id, service_item_id, protocol_id,
supervisor_doctor_id (nullable),
status, // active | completed | abandoned
total_sessions, // snapshot از تعداد گام‌ها
opened_at, closed_at (nullable),
created_at, updated_at
// TreatmentCaseArea — snapshot نواحی (تصمیم ۹)
id, case_id, catalog_category_id, name_snapshot, sort_order
// UNIQUE(case_id, catalog_category_id)
// TreatmentSession
id, uuid, case_id, session_number,
appointment_id (nullable, ON DELETE SET NULL),
performed_by_staff_id (nullable), // تصمیم ۲۳
status, // planned | booked | in_progress | done | cancelled | no_show
due_at (nullable), // تخمینی، بعد از هر جلسه بازمحاسبه می‌شود
started_at (nullable), finished_at (nullable),
note (nullable),
created_at, updated_at
// UNIQUE(case_id, session_number)
// SessionAreaRecord
id, uuid, session_id, case_area_id,
resource_id (nullable), // دستگاه — در سطح ناحیه
status, // pending | in_progress | completed | skipped
parameters JSON (nullable), // { "energy": 18, "pulse": 3, "shots": 212 }
started_at (nullable), finished_at (nullable),
note (nullable),
created_at, updated_at
// UNIQUE(session_id, case_area_id)
```
**هیچ ستون پولی روی این جدول‌ها نگذار.** ADR-0006.
`name_snapshot` روی `TreatmentCaseArea` عمدی است: اگر مدیر بعداً اسم دسته را عوض کند،
سابقه درمان نباید تغییر کند.
نواحی هنگام ساخت پرونده اینطور حساب می‌شوند:
```
leaves = برگ‌های CategoryClosureResolver::descendants(service.catalogCategory)
اگر descendants خالی بود → خودِ service.catalogCategory تنها ناحیه است
```
«برگ» یعنی دسته‌ای که خودش `descendants` ندارد. دسته‌های میانی فقط گروه‌بندی‌اند و
ناحیه درمان نیستند.
### ۴. اتصال پزشک ناظر به مسیر رزرو
در `AppointmentController` بلوکی که پزشک را از منبع استنتاج می‌کند (حدود خط ۴۷۱)
یک fallback اضافه کن:
```php
if ($doctorUuid === '' && $resource->subject() instanceof Doctor) {
$doctorUuid = $resource->subject()->getUuid();
}
// جدید:
if ($doctorUuid === '' && $resource->getSupervisor() !== null) {
$doctorUuid = $resource->getSupervisor()->getUuid();
}
```
اگر منبعی نه پزشک است نه ناظر دارد، خطای واضح بده:
«این منبع پزشک ناظر ندارد؛ ابتدا در تنظیمات منابع پزشک ناظر را مشخص کنید».
پیام فعلی (`doctor_uuid یا resource_uuid ...`) گمراه‌کننده است.
### ۵. رزرو منبع مستقل از پزشک
**این وظیفه بعد از بررسی داده واقعی بازنویسی شد. ADR-0003 را بخوان.**
آنچه با داده تأیید شد:
- دو مسیر رزرو داریم و هیچ‌کدام ردیف دیگری نمی‌سازد.
مسیر پنل روی `appointments.resource_id` می‌نشیند، مسیر hold روی `resource_occupancy`.
- `bookAtomically` روی **پزشک** قفل می‌گیرد و `isSlotTaken` تداخل بازه‌ای را فقط روی پزشک
می‌سنجد. منبع در آن کوئری نیست.
- در دیتابیس فعلی هر محیط چند منبع با یک پزشک ناظر مشترک دارد. کلینیک ۲ شش منبع با پزشک ۶،
کلینیک ۳ سه منبع با پزشک ۹. پس این باگ همین حالا فعال است.
- برنامه هفتگی پزشک مانع نیست؛ `resolveSlotLocationId` فقط `null` برمی‌گرداند.
پنج تغییر:
۱. مسیر پنل هنگام رزرو `ResourceOccupancy` بسازد، همان‌طور که `HoldService` می‌سازد.
منطق مشترک در یک سرویس باشد، در دو جا کپی نشود.
۲. لغو یا انقضای نوبت، ردیف اشغال را `released` کند.
۳. در `Appointment::refreshActiveSlotKey()` وقتی منبع هست کلید `null` بماند:
```php
$this->activeSlotKey = (!$this->isReserve
&& $this->resource === null
&& in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true))
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
```
`setResource()` باید `refreshActiveSlotKey()` را صدا بزند.
۴. `bookAtomically` وقتی نوبت منبع دارد روی پزشک قفل نگیرد و `isSlotTaken` را صدا نزند.
تضمین یکتایی از `uniq_bucket_resource_seat` می‌آید که ظرفیت و `seat` را می‌فهمد.
۵. شعبه نوبتِ منبع‌دار از `ClinicResource.getAddress()` بیاید، نه از برنامه پزشک.
**قبل از شروع:** همه مسیرهای ساخت نوبت را فهرست کن و بنویس کدام‌ها منبع ست نمی‌کنند.
آن‌ها کلید پزشک و قفل پزشک را نگه می‌دارند. این فهرست باید در گزارش بیاید.
migration برای `active_slot_key` لازم نیست. برای ردیف‌های اشغالِ گذشتهٔ مسیر پنل یک
migration داده‌ای لازم است تا نوبت‌های فعالِ منبع‌دار موجود ردیف اشغال بگیرند.
### ۶. تعریف فیلد روی نوع منبع
ستون JSON روی `ResourceType`:
```php
#[ORM\Column(name: 'field_schema', type: 'json', nullable: true)]
private ?array $fieldSchema = null;
```
قالب هر فیلد:
```json
{ "key": "energy", "label": "انرژی", "type": "select",
"options": [7, 8, 9, 10, 12, 14, 16, 18], "required": true, "sort_order": 1 }
```
`type` مجاز: `select` و `number` و `text`. همین سه تا، نه بیشتر.
اعتبارسنجی مقادیر `SessionAreaRecord.parameters` از همین schema می‌آید. یک سرویس
`FieldSchemaValidator` بنویس که هم در ذخیره اندپوینت استفاده شود هم در تست.
کلیدهای ناشناخته که در schema نیستند رد شوند، نه اینکه بی‌صدا ذخیره شوند.
seed اولیه: یک `ResourceType` با کد `laser_device` و نام «دستگاه لیزر» و همان سه فیلد
بالا به‌علاوه `shots` از نوع `number`. `is_system = false` تا مدیر بتواند ویرایشش کند.
### ۷. Workflow قابل توسعه
```php
// src/Treatment/Workflow/TreatmentWorkflow.php
#[AutoconfigureTag('app.treatment_workflow')]
interface TreatmentWorkflow
{
public function supports(?string $practiceDomainCode): bool;
/** بعد از تأیید اولین نوبتِ یک سرویسِ پروتکل‌دار */
public function openCase(Appointment $appointment, TreatmentProtocol $protocol): TreatmentCase;
/** بعد از بسته شدن یک جلسه — سررسید جلسه بعد را حساب می‌کند */
public function onSessionFinished(TreatmentSession $session): void;
}
```
`TreatmentWorkflowRegistry` با `#[TaggedIterator('app.treatment_workflow')]` ساخته شود و
اولین workflow ای که `supports()` بدهد را برگرداند. اگر هیچ‌کدام، `DefaultTreatmentWorkflow`
که رفتار عمومی دارد.
**هسته نباید بداند لیزر چیست.** `AppointmentConfirmationService` فقط این را می‌کند:
```
اگر سرویسِ نوبت پروتکل فعال دارد و بیمار پروندهٔ باز برای همان سرویس ندارد
→ registry->for(clinic.practiceDomain?.code)->openCase(...)
```
`LaserTreatmentWorkflow` در فاز اول تقریباً همان `DefaultTreatmentWorkflow` است.
جدا نگهش دار حتی اگر خالی باشد — نقطه اتصال آینده است.
### ۸. سررسید و جلسه بعد
قاعده محاسبه (تصمیم ۲۱):
```
due_at(session n) = finished_at(session n-1) + protocol.step[n].offset_days
جلسه ۱ سررسید ندارد؛ تاریخش همان نوبت اول است.
```
بعد از `finished_at` شدن هر جلسه، فقط `due_at` **جلسه بعدی** بازمحاسبه شود، نه کل دوره.
جلسات دورتر تخمین قبلی‌شان را نگه می‌دارند تا نوبتشان برسد.
no-show (تصمیم ۲۲): جلسه به `no_show` می‌رود، `session_number` عوض نمی‌شود،
`total_sessions` عوض نمی‌شود. یک جلسه جایگزین با همان شماره **ساخته نمی‌شود**؛ همان جلسه
دوباره به `planned` برمی‌گردد و `due_at` از تاریخ جلسه قبلِ **انجام‌شده** حساب می‌شود.
رزرو جلسه بعد (تصمیم ۱۹) **خودکار نیست**. اندپوینت پیشنهاد بده:
```
GET /api/v1/treatment-sessions/{uuid}/slot-suggestions
→ اسلات‌های آزاد منبع، از due_at به بعد، با استفاده از ResourceFreeTimeCalculator
```
منشی یکی را انتخاب می‌کند و مسیر عادی ساخت نوبت اجرا می‌شود، سپس نوبت به جلسه وصل می‌شود.
### ۹. اندپوینت‌های اجرای جلسه
همه زیر `ROLE_STAFF` یا بالاتر. علاوه بر نقش، بررسی کن پرسنل واقعاً به این جلسه دسترسی
دارد — الگویش در `DashboardController::staff` هست (`findActiveByUserAndEntity`).
```
GET /api/v1/dashboard/staff/treatment-sessions جلسات امروز پرسنل
GET /api/v1/treatment-sessions/{uuid} جزئیات + نواحی + فیلدهای دستگاه
POST /api/v1/treatment-sessions/{uuid}/start → in_progress، started_at
POST /api/v1/treatment-sessions/{uuid}/finish → done، finished_at، note
POST /api/v1/session-areas/{uuid}/start → in_progress، started_at
POST /api/v1/session-areas/{uuid}/complete → completed، parameters، finished_at
POST /api/v1/session-areas/{uuid}/skip → skipped
GET /api/v1/treatment-cases فهرست پرونده‌ها با فیلتر
GET /api/v1/treatment-cases/{uuid} پرونده + همه جلسات
```
هم‌گام‌سازی وضعیت نوبت (تصمیم ۱۶):
```
session start → Appointment::STATUS_SALON
session finish → Appointment::STATUS_COMPLETED
```
از `ALLOWED_TRANSITIONS` عبور کن، مستقیم `setStatus` نزن. اگر گذار مجاز نبود، جلسه را
ببند ولی نوبت را دست نزن و لاگ بگذار — خطای ۵۰۰ نده.
بستن جلسه با ناحیه ناتمام **مجاز است** (تصمیم ۱۶). فقط در پاسخ تعداد ناتمام‌ها را برگردان
تا پنل هشدار نشان دهد.
`started_at` و `finished_at` هرگز روی `Appointment` نوشته نشوند (تصمیم ۲۳).
### ۱۰. پنل ادمین
قواعد اجباری این پروژه:
- هر `select` باید `components/ui/SearchableSelect` باشد. `<select>` بومی ممنوع.
- هر بولین باید `components/ui/Switch` باشد. checkbox بومی ممنوع.
- صفحه جدید با تم و Layout و کامپوننت‌های موجود ساخته شود. طراحی جدید نکن.
- تاریخ‌ها شمسی. متن‌ها فارسی. RTL.
صفحه‌ها و تغییرها:
1. **فرم سرویس** — سوییچ «طول درمان». روشن که شد: جدول گام‌ها (شماره و فاصله از جلسه قبل)،
`SearchableSelect` چندتایی پرسنل مجاز، `SearchableSelect` پزشک ناظر.
2. **تنظیمات کلینیک**`SearchableSelect` حوزه فعالیت.
3. **صفحه نوع منابع** — ویرایشگر `fieldSchema`. افزودن و حذف فیلد، انتخاب نوع، گزینه‌ها.
4. **پنل ادمین پلتفرم** — CRUD حوزه‌های فعالیت با نشان «workflow دارد / ندارد».
5. **فهرست پرونده‌های درمان** — نام بیمار، سرویس، جلسه چندم از چند، وضعیت، سررسید بعدی، اپراتور.
6. **صفحه اجرای جلسه** — کارت هر ناحیه با دکمه شروع، تایمر زنده، فرم داینامیک از `fieldSchema`،
دکمه اتمام ناحیه، دکمه لغو ناحیه. پایین صفحه یادداشت کلی و دکمه اتمام جلسه با هشدار
نواحی ناتمام. اسکرین‌شات‌های مرجع در تیکت این کار هست.
7. **صف «جلسات بدون نوبت»** — جلساتی که `status = planned` و `due_at` گذشته یا نزدیک است.
از هر ردیف مستقیم به انتخاب اسلات پیشنهادی.
تایمر فقط نمایشی است. زمان معتبر همان `started_at` و `finished_at` سرور است.
---
## ترتیب اجرا
```
۱ → ۲ → ۳ → ۶ → ۷ → ۴ → ۵ → ۸ → ۹ → ۱۰
```
وظیفه ۵ (کلید اسلات) روی پرترافیک‌ترین جدول سیستم است. بعد از وظیفه ۴ انجامش بده و
قبل و بعدش تست رگرسیون رزرو را کامل اجرا کن.
## تست
- unit برای `CategoryClosureResolver` در حالت برگ‌یابی نواحی
- unit برای محاسبه `due_at` با گام‌های نامساوی (سناریوی بوتاکس: ۰، ۱۵، ۳۰، ۳۰)
- unit برای `FieldSchemaValidator` با کلید ناشناخته و مقدار خارج از `options`
- functional: پزشک ناظر مشترک بین دو دستگاه، دو رزرو هم‌ساعت، هر دو باید موفق شوند
- functional: اتاق با `capacity = 3`، سه رزرو هم‌ساعت، هر سه باید موفق شوند
- functional: چرخه کامل یک دوره سه‌جلسه‌ای شامل یک no-show
- رگرسیون: رزرو بدون منبع همچنان با کلید پزشک از دوبار رزرو جلوگیری کند
## خارج از دامنه فاز اول
- seeder هوشمند با AI برای پیشنهاد دسته‌بندی و منابع
- گزارش‌های تحلیلی روی `parameters` (کوئری JSON بدون ایندکس)
- حوزه فعالیت دوم غیر از زیبایی
- داشبورد اختصاصی به‌ازای هر تخصص
- قیمت پکیج و قفل قیمت
## مستندسازی
طبق قانون ثابت این پروژه، `docs/api/*` در همان session به‌روز شود.
اگر تصمیمی در حین اجرا عوض شد، ADR مربوطه به‌روز شود یا ADR جدید نوشته شود.