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>
443 lines
24 KiB
Markdown
443 lines
24 KiB
Markdown
# طول درمان و پرونده درمان چندجلسهای (فاز اول: لیزر)
|
||
|
||
## پروژه
|
||
|
||
`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 جدید نوشته شود.
|