# گزارش وضعیت فعلی سیستم در برابر مستند «موتور نوبت‌دهی Clinic Pro» مرجع: [clinic-pro-mostanad-sade.md](../clinic-pro-mostanad-sade.md) دامنه بررسی: `clinicpro/src/**` (Symfony 7.4) + `clinicpro/assets/admin/**` (React SPA) --- ## ۱. خلاصه اجرایی سیستم فعلی یک موتور نوبت‌دهی **تک‌منبعی (فقط پزشک)** است که اخیراً یک حالت «نوبت‌دهی سرویسی» هم گرفته: `WeeklySchedule.meta.booking_mode = service` باعث می‌شود طول نوبت از `ServiceItem.duration_minutes` گرفته شود به‌جای اسلات ثابت. این با مستند در جهت درست است، ولی فقط **یک لایه از هفت لایه** مستند را پوشش می‌دهد. سه ستون اصلی مستند اصلاً وجود ندارند: | ستون مستند | وضعیت | |---|---| | تقویم مال منبع است نه پزشک (بند ۲-۱، ۶) | ❌ وجود ندارد — تقویم فقط `(doctor, clinic)` است | | نوبت از چند بخش تشکیل شده (بند ۷) | ❌ وجود ندارد — نوبت یک `slot_start/slot_end` پیوسته است | | موتور قوانین شش‌دسته‌ای (بند ۸) | ⚠️ فقط یک دسته (قیمت) به شکل `DiscountRule` | نکته مثبت و مهم: **قانون سوم مستند («جلوگیری از رزرو تکراری کار دیتابیس است») از قبل رعایت شده** — `Appointment.active_slot_key` یک ستون `UNIQUE` است که فقط در وضعیت‌های اشغال‌کننده مقدار می‌گیرد. همان الگو باید به `resource_occupancy` تعمیم پیدا کند. --- ## ۲. آنچه امروز داریم (کد واقعی) ### ۲-۱ محیط (tenant) `(entity_type, entity_id)` روی ۲۰ جدول، با `entity_type ∈ {doctor, clinic}` — [src/Shared/Tenant/TenantOwnedTrait.php](../../../src/Shared/Tenant/TenantOwnedTrait.php)، سند کامل: [docs/architecture/tenancy.md](../../architecture/tenancy.md). | سطح مستند | معادل امروز | |---|---| | کلینیک (Tenant) | ✅ `clinic` یا `doctor` (مطب شخصی) | | شعبه (Branch) | ⚠️ نیم‌بند — `DoctorAddress` نقش «محل» را بازی می‌کند و در `location_id` هر شیفت می‌نشیند | | اتاق (Room) | ❌ وجود ندارد | `Clinic` هیچ فیلد شعبه‌ای ندارد ([src/Clinic/Entity/Clinic.php](../../../src/Clinic/Entity/Clinic.php)). ساعت کاری شعبه هم وجود ندارد؛ ساعت کاری فقط روی برنامهٔ پزشک است. ### ۲-۲ تعریف خدمات `ServiceSection` (بخش) → `ServiceItem` (سرویس)، هر دو tenant-دار. [src/ClinicService/Entity/ServiceItem.php](../../../src/ClinicService/Entity/ServiceItem.php): ```php private int $priceRials = 0; private ?int $durationMinutes = null; // مدت، تخت — بدون تفکیک بخش private bool $bookable = false; // نمایش در نوبت‌دهی private Collection $staffMembers; // ManyToMany به ClinicStaff private ?int $inventoryPackageId = null; private Collection $consumables; // ServiceItemConsumable ``` + `Tariff` (قیمت سالانه per سرویس) و `TenantServiceCoverage` (پوشش بیمه). | مستند | وضعیت | |---|---| | دسته‌بندی درختی خدمات | ⚠️ `ServiceSection` تک‌سطحی است، درختی نیست | | گروه آیتم با حداقل/حداکثر انتخاب | ❌ | | آیتم با «زمان تنها» و «زمان اضافه» | ❌ — فقط یک `duration_minutes` | | ناسازگاری / پیش‌نیاز بین آیتم‌ها | ❌ | | الگوی بخش‌های نوبت (segment template) | ❌ | | قیمت اختصاصی شعبه | ❌ | | تک‌جلسه یا دوره‌ای | ❌ | ### ۲-۳ منابع تنها «منبع» مدل‌شده، پرسنل است: [src/Staff/Entity/ClinicStaff.php](../../../src/Staff/Entity/ClinicStaff.php) — نام، سمت، فعال/غیرفعال، اتصال اختیاری به `User`. تقویم ندارد، ظرفیت ندارد، مهارت ندارد. | مستند | وضعیت | |---|---| | `resource_type` تعریف‌شده توسط کلینیک | ❌ | | منبع با ظرفیت همزمان | ❌ | | مهارت‌ها (`skill` / `resource_skill`) | ❌ | | استخر منابع | ❌ | | ویژگی آزاد (جنسیت، مدل دستگاه، طبقه) | ❌ | | زمان آماده‌سازی/تمیزکاری per منبع | ❌ (فقط `buffer_minutes` سراسری روی برنامه) | | نیازمندی منبع per بخش | ❌ | | قید هم‌جنس بودن | ❌ | ### ۲-۴ تقویم و اسلات [src/Appointment/Entity/WeeklySchedule.php](../../../src/Appointment/Entity/WeeklySchedule.php): JSON هفتگی per `(doctor, clinic)`، هر روز چند `session` با `start_time/end_time/duration_per_patient/has_rest/patient_limit/location_id`. `meta`: `online_booking_enabled`, `booking_window_value|unit`, `booking_mode`, `buffer_minutes`. `DateOverride` (روز خاص)، `Holiday` (بازه تعطیلی per پزشک/محیط). کسر لایه‌ها در [SlotCalculatorService](../../../src/Appointment/Service/SlotCalculatorService.php) انجام می‌شود و از هفت لایهٔ مستند، چهار لایه را دارد: ``` ساعت کاری شعبه ❌ (ساعت کاری فقط روی برنامه پزشک است) – شیفت منبع ❌ – تعطیلات رسمی کشور ❌ (جدول تعطیلات ملی نداریم؛ Holiday دستی است) – مرخصی/غیبت ⚠️ فقط از راه Holiday و DateOverride پزشک – سرویس دوره‌ای دستگاه ❌ – نوبت‌های ثبت‌شده ✅ isSlotTaken / findBusyIntervals – رزروهای موقت ✅ pending با expires_at – آماده‌سازی و تمیزکاری ⚠️ فقط buffer_minutes ثابت ``` ### ۲-۵ نوبت‌دهی سرویسی که امروز داریم جریان فعلی (همانی که کاربر اشاره کرد): 1. `GET /api/v1/appointment-booking-services/{doctorUuid}` → `booking_mode` + سرویس‌های `bookable` 2. `GET /api/v1/appointment-service-slots?doctor_uuid&date&service_item_uuids[]&durations[]` → `SlotCalculatorService::getServiceStartTimes()` — **جمع سادهٔ مدت سرویس‌ها**، سپس پر کردن فضای خالی هر شیفت با `duration + buffer` 3. `POST /api/v1/appointment` → یک ردیف `appointments` با `slot_start/slot_end` و `appointment_service_items` (ManyToMany چند سرویس) محدودیت‌های ساختاری این جریان نسبت به مستند: - **`$totalMinutes += $duration` برای هر سرویس** ([AppointmentController.php:236](../../../src/Appointment/Controller/AppointmentController.php)) — دقیقاً همان «فرمول قدیمی» که مستند بند ۵ ردش می‌کند: آماده‌سازی چند بار حساب می‌شود. - زمان اشغال یک بلوک پیوسته است؛ اپراتور در زمان انتظار آزاد نمی‌شود (بند ۷). - تنها منبعی که تداخلش بررسی می‌شود پزشک است؛ اگر دو سرویس هم‌زمان به یک پرسنل یا یک دستگاه نیاز داشته باشند، سیستم متوجه نمی‌شود. ### ۲-۶ ثبت نوبت و همزمانی [src/Appointment/Entity/Appointment.php](../../../src/Appointment/Entity/Appointment.php): ```php public const PAYMENT_TTL = 900; // رزرو موقت ۱۵ دقیقه‌ای #[ORM\Column(name:'active_slot_key', unique:true, nullable:true)] private ?string $activeSlotKey = null; // "{doctorId}:{slotStart}" یا NULL #[ORM\Version] private int $version = 1; // optimistic locking ``` ✅ سه مرحله جستجو → رزرو موقت (`pending` + `expires_at`) → ثبت نهایی (`confirmed`) از قبل هست، و یکتایی در سطح دیتابیس تضمین می‌شود — نه در کد. ❌ ولی کلید فقط `doctor + slot_start` است. با چند منبع، به یک جدول `resource_occupancy` با محدودیت بازه‌ای نیاز است. وضعیت‌ها: `pending, confirmed, completed, cancelled_by_doctor, cancelled_by_user, expired, no_show, following_up, salon` + `AppointmentEvent` برای تاریخچه. تقریباً کامل؛ `rescheduled` ندارد. ### ۲-۷ قیمت `ServiceItem.price_rials` → `Tariff` (سالانه) → `TenantServiceCoverage`/`TenantInsurance` (بیمه) → `DiscountRule` + `DiscountEngine` → `Invoice`/`InvoiceItem` → `Payment`. بیعانه هم روی نوبت هست (`deposit_required`, `deposit_amount_rials`). | مستند | وضعیت | |---|---| | لیست قیمت با بازهٔ تاریخ | ⚠️ `Tariff` فقط «سال» دارد، بازهٔ دقیق ندارد | | قیمت per شعبه | ❌ | | snapshot فاکتور روی نوبت | ⚠️ `visit_price_rials` تک‌عدد است، تفکیک‌شده نیست | | پکیج و دفتر اعتبار جلسات | ❌ | | بیعانه | ✅ | ### ۲-۸ قوانین تنها موتور قانونِ موجود `DiscountRule` است ([src/Discount/Entity/DiscountRule.php](../../../src/Discount/Entity/DiscountRule.php)): `type` از یک enum بسته، `priority`، `combinable`، `valid_from/valid_to`، tenant-دار. این دقیقاً الگوی درستی است که مستند می‌خواهد (شرط از فهرست بسته، نه کد دلخواه) — ولی فقط برای دستهٔ «قیمت». پنج دستهٔ دیگر (انتخاب، صلاحیت بیمار، منبع، زمان، فاصله زمانی) و همچنین نسخه‌بندی و محیط آزمایش وجود ندارند. ### ۲-۹ دوره درمان ❌ کامل غایب. نه `course_protocol`، نه `treatment_course`، نه `course_session`. `PatientSession` وجود دارد ولی «مراجعهٔ انجام‌شده» است، نه جلسهٔ برنامه‌ریزی‌شدهٔ یک دوره. --- ## ۳. جدول شکاف (خلاصه) | بخش مستند | دارد | ندارد | تسک | |---|---|---|---| | ۴ کلینیک/شعبه/اتاق | tenant دوسطحی | Branch، Room، ساعت کاری شعبه | ۰۱ | | ۵ تعریف خدمات | سرویس، قیمت، مدت، بیمه | گروه آیتم، دو نوع زمان، ناسازگاری، override شعبه | ۰۴ | | ۶ منابع | پرسنل بدون تقویم | نوع منبع، ظرفیت، مهارت، استخر، نیازمندی | ۰۲، ۰۳ | | ۷ بخش‌های نوبت | — | کل بخش | ۰۵ | | ۸ قوانین | فقط تخفیف | ۵ دستهٔ دیگر، نسخه‌بندی، sandbox | ۰۹، ۱۰ | | ۹ تقویم | برنامهٔ پزشک، override، تعطیلی | تقویم منبع، تعطیلات ملی، سرویس دستگاه | ۰۳ | | ۱۰ جستجوی وقت | تک‌منبعی و پیوسته | چندمنبعی، چندبخشی، کش، استراتژی انتخاب | ۰۶ | | ۱۱ ثبت نوبت | سه‌مرحله‌ای + یکتایی DB | resource_occupancy، قفل چندمنبعی | ۰۷ | | ۱۲ قیمت | تعرفه، بیمه، تخفیف، بیعانه | price_list بازه‌دار، snapshot تفکیک‌شده، پکیج، دفتر اعتبار | ۰۸، ۱۱ | | ۱۳ دوره درمان | — | کل بخش | ۱۲ | | ۱۶ رویدادها | AppointmentEvent | bus عمومی دامنه | ۱۴ | --- ## ۴. تصمیم معماری پیشنهادی: توسعه، نه بازنویسی مستند PostgreSQL و یک سیستم نو فرض کرده. پروژه روی **MariaDB 11.8 + Doctrine ORM 3.6** است و یک جریان نوبت‌دهی زنده دارد (سایت عمومی `nobat724_front` و اپ `clinic-pro-tauri` هر دو مصرف‌کنندهٔ `/api/v1/appointment*` هستند). پس: 1. **`Doctor` را به یک `Resource` تبدیل نمی‌کنیم، بلکه کنارش می‌گذاریم.** پزشک منبعی با `resource_type = doctor` می‌شود که به رکورد `Doctor` لینک دارد. `appointments.doctor_id` سر جایش می‌ماند تا API عمومی نشکند. 2. **حالت سوم نوبت‌دهی اضافه می‌شود:** `WeeklySchedule.meta.booking_mode = resource` کنار `slot` و `service` موجود. دو حالت قبلی دست‌نخورده کار می‌کنند و مسیر مهاجرت داوطلبانه است، نه اجباری. 3. **`resource_occupancy` تنها مرجع اشغال می‌شود** ولی `active_slot_key` فعلی هم تا حذف کامل حالت `slot` می‌ماند (دو تور ایمنی، نه صفر). 4. **قوانین روی الگوی `DiscountRule` ساخته می‌شوند** — enum بسته + priority + بازهٔ اعتبار، نه DSL آزاد. همان‌طور که مستند بند ۸ اصرار دارد. 5. **همهٔ جدول‌های جدید از روز اول `TenantOwnedTrait` می‌گیرند**، وگرنه `TenantSchemaCoverageTest` قرمز می‌شود. 6. **MariaDB محدودیت بازه‌ای (`EXCLUDE`) ندارد.** جلوگیری از تداخل با کلید یکتای «سطل زمانی» (`resource_id + slot_bucket`) انجام می‌شود — جزئیات در تسک ۰۷. --- ## ۵. ترتیب اجرا ``` ۰۱ شعبه/اتاق ─┬─ ۰۲ منابع و مهارت ── ۰۳ تقویم منبع ─┐ └─ ۰۴ کاتالوگ خدمات v2 ── ۰۵ بخش‌های نوبت ─┴─ ۰۶ جستجوی وقت ── ۰۷ رزرو و ثبت │ ۰۸ قیمت‌گذاری و snapshot ────────────────┘ │ ۰۹ موتور قوانین ── ۱۰ فرم و sandbox قانون │ ۱۱ پکیج و دفتر اعتبار ── ۱۲ دوره درمان ── ۱۳ لغو/عدم‌حضور/لیست انتظار │ ۱۴ رویدادها و گزارش بهره‌وری ```