From 021d0eb6b2d7ac368af76c44e63d31815de0ea1d Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 30 Jul 2026 11:43:58 +0330 Subject: [PATCH] feat: implement cancellation policy, no-show tracking, and waitlist management - Add implementation notes for cancellation and waitlist features. - Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting. - Establish architecture for domain events and outbox pattern to ensure reliable event publishing. - Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports. - Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements. --- .../taskes/00-current-state-report.md | 235 ++++++++++++++++++ docs/new_feture/taskes/README.md | 93 +++++++ .../task-01-branch-room/architecture.md | 125 ++++++++++ .../taskes/task-01-branch-room/database.md | 108 ++++++++ .../implementation_notes.md | 95 +++++++ .../taskes/task-01-branch-room/task.md | 61 +++++ .../task-02-resource-model/architecture.md | 140 +++++++++++ .../taskes/task-02-resource-model/database.md | 143 +++++++++++ .../implementation_notes.md | 111 +++++++++ .../taskes/task-02-resource-model/task.md | 75 ++++++ .../task-03-resource-calendar/architecture.md | 139 +++++++++++ .../task-03-resource-calendar/database.md | 112 +++++++++ .../implementation_notes.md | 133 ++++++++++ .../taskes/task-03-resource-calendar/task.md | 76 ++++++ .../architecture.md | 170 +++++++++++++ .../task-04-service-catalog-v2/database.md | 149 +++++++++++ .../implementation_notes.md | 133 ++++++++++ .../taskes/task-04-service-catalog-v2/task.md | 89 +++++++ .../task-05-appointment-plan/architecture.md | 196 +++++++++++++++ .../task-05-appointment-plan/database.md | 95 +++++++ .../implementation_notes.md | 131 ++++++++++ .../taskes/task-05-appointment-plan/task.md | 102 ++++++++ .../task-05-appointment-plan/user_flow.md | 123 +++++++++ .../architecture.md | 186 ++++++++++++++ .../task-06-availability-engine/database.md | 95 +++++++ .../implementation_notes.md | 158 ++++++++++++ .../task-06-availability-engine/task.md | 78 ++++++ .../task-06-availability-engine/user_flow.md | 134 ++++++++++ .../task-07-hold-and-book/architecture.md | 182 ++++++++++++++ .../taskes/task-07-hold-and-book/database.md | 145 +++++++++++ .../implementation_notes.md | 183 ++++++++++++++ .../taskes/task-07-hold-and-book/task.md | 81 ++++++ .../taskes/task-07-hold-and-book/user_flow.md | 150 +++++++++++ .../task-08-pricing-snapshot/architecture.md | 145 +++++++++++ .../task-08-pricing-snapshot/database.md | 143 +++++++++++ .../implementation_notes.md | 136 ++++++++++ .../taskes/task-08-pricing-snapshot/task.md | 81 ++++++ .../task-09-policy-engine/architecture.md | 202 +++++++++++++++ .../taskes/task-09-policy-engine/database.md | 126 ++++++++++ .../implementation_notes.md | 187 ++++++++++++++ .../taskes/task-09-policy-engine/task.md | 97 ++++++++ .../architecture.md | 161 ++++++++++++ .../task-10-policy-admin-sandbox/database.md | 71 ++++++ .../implementation_notes.md | 130 ++++++++++ .../task-10-policy-admin-sandbox/task.md | 61 +++++ .../architecture.md | 138 ++++++++++ .../task-11-package-credit-ledger/database.md | 133 ++++++++++ .../implementation_notes.md | 156 ++++++++++++ .../task-11-package-credit-ledger/task.md | 72 ++++++ .../task-12-treatment-course/architecture.md | 213 ++++++++++++++++ .../task-12-treatment-course/database.md | 138 ++++++++++ .../implementation_notes.md | 173 +++++++++++++ .../taskes/task-12-treatment-course/task.md | 78 ++++++ .../task-12-treatment-course/user_flow.md | 149 +++++++++++ .../architecture.md | 191 ++++++++++++++ .../task-13-cancellation-waitlist/database.md | 141 +++++++++++ .../implementation_notes.md | 157 ++++++++++++ .../task-13-cancellation-waitlist/task.md | 75 ++++++ .../architecture.md | 151 +++++++++++ .../task-14-events-utilization/database.md | 126 ++++++++++ .../implementation_notes.md | 158 ++++++++++++ .../taskes/task-14-events-utilization/task.md | 83 +++++++ 62 files changed, 8098 insertions(+) create mode 100644 docs/new_feture/taskes/00-current-state-report.md create mode 100644 docs/new_feture/taskes/README.md create mode 100644 docs/new_feture/taskes/task-01-branch-room/architecture.md create mode 100644 docs/new_feture/taskes/task-01-branch-room/database.md create mode 100644 docs/new_feture/taskes/task-01-branch-room/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-01-branch-room/task.md create mode 100644 docs/new_feture/taskes/task-02-resource-model/architecture.md create mode 100644 docs/new_feture/taskes/task-02-resource-model/database.md create mode 100644 docs/new_feture/taskes/task-02-resource-model/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-02-resource-model/task.md create mode 100644 docs/new_feture/taskes/task-03-resource-calendar/architecture.md create mode 100644 docs/new_feture/taskes/task-03-resource-calendar/database.md create mode 100644 docs/new_feture/taskes/task-03-resource-calendar/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-03-resource-calendar/task.md create mode 100644 docs/new_feture/taskes/task-04-service-catalog-v2/architecture.md create mode 100644 docs/new_feture/taskes/task-04-service-catalog-v2/database.md create mode 100644 docs/new_feture/taskes/task-04-service-catalog-v2/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-04-service-catalog-v2/task.md create mode 100644 docs/new_feture/taskes/task-05-appointment-plan/architecture.md create mode 100644 docs/new_feture/taskes/task-05-appointment-plan/database.md create mode 100644 docs/new_feture/taskes/task-05-appointment-plan/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-05-appointment-plan/task.md create mode 100644 docs/new_feture/taskes/task-05-appointment-plan/user_flow.md create mode 100644 docs/new_feture/taskes/task-06-availability-engine/architecture.md create mode 100644 docs/new_feture/taskes/task-06-availability-engine/database.md create mode 100644 docs/new_feture/taskes/task-06-availability-engine/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-06-availability-engine/task.md create mode 100644 docs/new_feture/taskes/task-06-availability-engine/user_flow.md create mode 100644 docs/new_feture/taskes/task-07-hold-and-book/architecture.md create mode 100644 docs/new_feture/taskes/task-07-hold-and-book/database.md create mode 100644 docs/new_feture/taskes/task-07-hold-and-book/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-07-hold-and-book/task.md create mode 100644 docs/new_feture/taskes/task-07-hold-and-book/user_flow.md create mode 100644 docs/new_feture/taskes/task-08-pricing-snapshot/architecture.md create mode 100644 docs/new_feture/taskes/task-08-pricing-snapshot/database.md create mode 100644 docs/new_feture/taskes/task-08-pricing-snapshot/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-08-pricing-snapshot/task.md create mode 100644 docs/new_feture/taskes/task-09-policy-engine/architecture.md create mode 100644 docs/new_feture/taskes/task-09-policy-engine/database.md create mode 100644 docs/new_feture/taskes/task-09-policy-engine/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-09-policy-engine/task.md create mode 100644 docs/new_feture/taskes/task-10-policy-admin-sandbox/architecture.md create mode 100644 docs/new_feture/taskes/task-10-policy-admin-sandbox/database.md create mode 100644 docs/new_feture/taskes/task-10-policy-admin-sandbox/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-10-policy-admin-sandbox/task.md create mode 100644 docs/new_feture/taskes/task-11-package-credit-ledger/architecture.md create mode 100644 docs/new_feture/taskes/task-11-package-credit-ledger/database.md create mode 100644 docs/new_feture/taskes/task-11-package-credit-ledger/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-11-package-credit-ledger/task.md create mode 100644 docs/new_feture/taskes/task-12-treatment-course/architecture.md create mode 100644 docs/new_feture/taskes/task-12-treatment-course/database.md create mode 100644 docs/new_feture/taskes/task-12-treatment-course/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-12-treatment-course/task.md create mode 100644 docs/new_feture/taskes/task-12-treatment-course/user_flow.md create mode 100644 docs/new_feture/taskes/task-13-cancellation-waitlist/architecture.md create mode 100644 docs/new_feture/taskes/task-13-cancellation-waitlist/database.md create mode 100644 docs/new_feture/taskes/task-13-cancellation-waitlist/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-13-cancellation-waitlist/task.md create mode 100644 docs/new_feture/taskes/task-14-events-utilization/architecture.md create mode 100644 docs/new_feture/taskes/task-14-events-utilization/database.md create mode 100644 docs/new_feture/taskes/task-14-events-utilization/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-14-events-utilization/task.md diff --git a/docs/new_feture/taskes/00-current-state-report.md b/docs/new_feture/taskes/00-current-state-report.md new file mode 100644 index 00000000..1a35f924 --- /dev/null +++ b/docs/new_feture/taskes/00-current-state-report.md @@ -0,0 +1,235 @@ +# گزارش وضعیت فعلی سیستم در برابر مستند «موتور نوبت‌دهی 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 قانون + │ + ۱۱ پکیج و دفتر اعتبار ── ۱۲ دوره درمان ── ۱۳ لغو/عدم‌حضور/لیست انتظار + │ + ۱۴ رویدادها و گزارش بهره‌وری +``` diff --git a/docs/new_feture/taskes/README.md b/docs/new_feture/taskes/README.md new file mode 100644 index 00000000..c6eecb68 --- /dev/null +++ b/docs/new_feture/taskes/README.md @@ -0,0 +1,93 @@ +# تسک‌های موتور نوبت‌دهی چندمنبعی Clinic Pro + +پیاده‌سازی تدریجی [clinic-pro-mostanad-sade.md](../clinic-pro-mostanad-sade.md) روی کد موجود. +گزارش وضعیت فعلی و تحلیل شکاف: [00-current-state-report.md](00-current-state-report.md) + +> **پیش‌فرض کلیدی:** بازنویسی نداریم. نوبت‌دهی اسلاتی (`booking_mode=slot`) و نوبت‌دهی +> سرویسیِ فعلی (`booking_mode=service`) تا آخر این مسیر بدون تغییر رفتار کار می‌کنند. +> حالت جدید `booking_mode=resource` کنارشان اضافه می‌شود. + +--- + +## لیست تسک‌ها + +| تسک | ماژول | Endpoint جدید | وابستگی | زمان | +|-----|-------|--------------|---------|------| +| [۰۱](task-01-branch-room/) | شعبه و اتاق | ۸ | — | ۱۰-۱۲h | +| [۰۲](task-02-resource-model/) | منبع، نوع منبع، مهارت، استخر | ۱۴ | ۰۱ | ۱۴-۱۸h | +| [۰۳](task-03-resource-calendar/) | تقویم منبع، مرخصی، تعطیلات ملی | ۹ | ۰۱، ۰۲ | ۱۲-۱۴h | +| [۰۴](task-04-service-catalog-v2/) | کاتالوگ خدمات v2 (گروه آیتم، دو نوع زمان) | ۱۰ | ۰۱ | ۱۴-۱۶h | +| [۰۵](task-05-appointment-plan/) | بخش‌های نوبت و سازندهٔ برنامه | ۳ | ۰۲، ۰۴ | ۱۶-۲۰h | +| [۰۶](task-06-availability-engine/) | موتور جستجوی وقت چندمنبعی | ۲ | ۰۳، ۰۵ | ۲۰-۲۴h | +| [۰۷](task-07-hold-and-book/) | رزرو موقت و ثبت نهایی چندمنبعی | ۴ | ۰۶ | ۱۶-۲۰h | +| [۰۸](task-08-pricing-snapshot/) | لیست قیمت بازه‌دار و snapshot فاکتور | ۷ | ۰۴، ۰۷ | ۱۲-۱۴h | +| [۰۹](task-09-policy-engine/) | موتور قوانین شش‌دسته‌ای | ۶ | ۰۵، ۰۶، ۰۸ | ۲۰-۲۴h | +| [۱۰](task-10-policy-admin-sandbox/) | فرم ساخت قانون + محیط آزمایش | ۲ | ۰۹ | ۱۰-۱۲h | +| [۱۱](task-11-package-credit-ledger/) | پکیج و دفتر اعتبار جلسات | ۸ | ۰۸ | ۱۰-۱۲h | +| [۱۲](task-12-treatment-course/) | دوره درمان | ۹ | ۰۷، ۱۱ | ۱۶-۲۰h | +| [۱۳](task-13-cancellation-waitlist/) | سیاست لغو، عدم حضور، لیست انتظار | ۷ | ۰۷ | ۱۰-۱۲h | +| [۱۴](task-14-events-utilization/) | رویدادهای دامنه و گزارش بهره‌وری | ۳ | ۰۷ | ۸-۱۰h | + +**مجموع endpoint جدید: ~۹۲ · مجموع زمان: ۱۹۰ تا ۲۲۸ ساعت** + +--- + +## ساختار هر تسک + +``` +task-XX-name/ +├── task.md ← شرح، دامنه، endpoint ها، معیار پذیرش، زمان +├── architecture.md ← فایل‌ها، entity ها، سرویس‌ها، لایه‌ها +├── database.md ← جداول، ستون‌ها، ایندکس‌ها، migration +├── implementation_notes.md ← نکات فنی، edge case، سازگاری عقب‌رو، تست +└── user_flow.md ← (تسک‌های پیچیده) جریان کاربری +``` + +--- + +## ترتیب پیشنهادی اجرا + +``` +۰۱ ─┬─ ۰۲ ── ۰۳ ─┐ + └─ ۰۴ ── ۰۵ ─┴─ ۰۶ ── ۰۷ ─┬─ ۰۸ ─┬─ ۰۹ ── ۱۰ + │ └─ ۱۱ ── ۱۲ + ├─ ۱۳ + └─ ۱۴ +``` + +فاز اول (هستهٔ قابل عرضه): ۰۱ تا ۰۸ — بعد از آن یک کلینیک زیبایی با اتاق، دستگاه و +اپراتور می‌تواند واقعاً نوبت بگیرد. + +--- + +## قواعد مشترک همهٔ تسک‌ها + +قواعد پروژه در [CLAUDE.md](../../../CLAUDE.md) و +[docs/architecture/tenancy.md](../../architecture/tenancy.md) بر همهٔ این تسک‌ها حاکم‌اند. +مواردی که در هر تسک باید رعایت شوند: + +1. هر entity جدید یا `TenantOwnedTrait` می‌گیرد یا در `GlobalTables` با دلیل ثبت می‌شود؛ + `TenantSchemaCoverageTest` را اجرا کن. +2. `entity_type, entity_id` ستون‌های **اول** هر ایندکس ترکیبی لیست. +3. هر uuid که از request می‌آید باید با `TenantOwnershipChecker` سنجیده شود؛ + `TenantLookupInventoryTest` شمارنده دارد. +4. timestamp ها `int` (Unix)، نه `DateTime`. نمایش شمسی فقط در UI. +5. کنترلر نازک، `extends BaseController`، پاسخ با `success()/paginated()/error()`. +6. هر endpoint جدید یا تغییر یافته → به‌روزرسانی `docs/api/*.md` در همان نشست. +7. تست موفق + خطا + مرزی برای هر تسک، وگرنه تسک تمام نیست. +8. رشته‌های UI فارسی، کد و کامیت انگلیسی. +9. سازگاری عقب‌رو: `nobat724_front` و `clinic-pro-tauri` مصرف‌کنندهٔ همین APIها هستند + و در build خطا نمی‌دهند — هر تغییر قرارداد باید دستی بررسی شود. + +--- + +## سازگاری با نوبت‌دهی فعلی + +| حالت | منبع تنظیم | چه زمانی | +|---|---|---| +| `slot` | `WeeklySchedule.meta.booking_mode` | اسلات ثابت `duration_per_patient` — رفتار پیش‌فرض امروز | +| `service` | همان | طول = جمع مدت سرویس‌ها + buffer — پیاده‌شده، تک‌منبعی | +| `resource` | همان | **جدید** — برنامهٔ چندبخشی + چند منبع (تسک ۰۵ به بعد) | + +`booking_mode` پس از اولین ثبت قفل می‌شود (`WeeklySchedule::getStoredBookingMode()`). +تسک ۰۶ باید مسیر ارتقای داوطلبانهٔ `service → resource` را باز کند، بدون اجبار. diff --git a/docs/new_feture/taskes/task-01-branch-room/architecture.md b/docs/new_feture/taskes/task-01-branch-room/architecture.md new file mode 100644 index 00000000..a68e4807 --- /dev/null +++ b/docs/new_feture/taskes/task-01-branch-room/architecture.md @@ -0,0 +1,125 @@ +# معماری — تسک ۰۱ + +## ساختار فایل + +``` +src/Branch/ +├── Controller/ +│ ├── BranchController.php # CRUD شعبه + ساعت کاری +│ └── RoomController.php # CRUD اتاق +├── Entity/ +│ ├── Branch.php +│ ├── BranchWorkingHours.php +│ └── Room.php +├── Repository/ +│ ├── BranchRepository.php +│ ├── BranchWorkingHoursRepository.php +│ └── RoomRepository.php +├── Service/ +│ ├── BranchService.php # ساخت/ویرایش/حذف + قواعد حذف +│ └── WorkingHoursService.php # اعتبارسنجی و ذخیرهٔ هفت روز +└── Command/ + └── BackfillBranchCommand.php # app:branch:backfill + +assets/admin/pages/ +├── BranchesPage.tsx +├── BranchFormPage.tsx # شامل تب ساعت کاری +└── RoomsPage.tsx +``` + +## لایه‌بندی + +`BranchController` نازک است: اعتبارسنجی ورودی + `EntityContextResolver` + صدا زدن سرویس. +همهٔ قواعد (حذف امن، یکتایی نام در محیط، نرمال‌سازی ساعت) در `BranchService` و +`WorkingHoursService`. + +```php +final class BranchService +{ + public function __construct( + private readonly BranchRepository $branches, + private readonly RoomRepository $rooms, + private readonly EntityManagerInterface $em, + ) {} + + public function create(EntityContext $ctx, BranchInput $input): Branch + { + $branch = new Branch($input->name); + $branch->assignTenant($ctx); // ← اجباری، وگرنه flush می‌شکند + // ... + } + + /** حذف فقط وقتی هیچ اتاق یا منبعِ فعالی به شعبه وصل نیست. */ + public function delete(Branch $branch): void + { + if ($this->rooms->countActiveByBranch($branch) > 0) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شعبه دارای اتاق فعال است', 422); + } + // ... + } +} +``` + +## رابطهٔ Branch با DoctorAddress + +`DoctorAddress` حذف نمی‌شود. یک ستون `branch_id` تهی‌پذیر می‌گیرد: + +``` +DoctorAddress.branch_id ──▶ branches.id (nullable, ON DELETE SET NULL) +``` + +دلیل: `location_id` در JSON برنامهٔ هفتگی به `doctor_addresses.id` اشاره دارد و در +`SlotCalculatorService` و `AppointmentController::bookingLocations()` و سایت عمومی مصرف می‌شود. +تغییر آن قرارداد یعنی شکستن سه کلاینت. پس شعبه یک **لایهٔ بالاتر** می‌نشیند و آدرس به آن +لینک می‌شود، نه برعکس. + +`BackfillBranchCommand` برای هر محیطی که آدرس دارد یک شعبه با نام آدرس می‌سازد و +`branch_id` را پر می‌کند. dry-run پیش‌فرض، `--force` برای اجرا. + +## ساعت کاری شعبه + +مثل `WeeklySchedule` یک JSON نیست — جدول جداست، چون تسک ۰۳ باید بتواند +`WHERE branch_id = ? AND day = ?` بزند بدون خواندن و decode کردن JSON برای هر روز از ۹۰ روز. + +```php +#[ORM\Entity] +#[ORM\Table(name: 'branch_working_hours')] +#[ORM\UniqueConstraint(name: 'uniq_branch_day_seq', columns: ['branch_id', 'day_of_week', 'sequence'])] +class BranchWorkingHours +{ + private int $dayOfWeek; // 0=شنبه … 6=جمعه — همان قرارداد SlotCalculatorService + private int $startMinute; // دقیقه از نیمه‌شب، 0..1440 + private int $endMinute; + private int $sequence; // چند بازه در روز (صبح/عصر) +} +``` + +`startMinute`/`endMinute` به‌جای رشتهٔ `"08:30"` ذخیره می‌شوند تا مقایسه و تقاطع در تسک ۰۶ +حسابی باشد نه رشته‌ای. تبدیل به `H:i` فقط در `toArray()`. + +## اتاق + +```php +class Room +{ + use TenantOwnedTrait; + private Branch $branch; + private string $name; + private ?string $roomType = null; // متن آزاد — نوعِ اتاق را کلینیک تعریف می‌کند + private int $capacity = 1; // چند بیمار هم‌زمان (اتاق تزریق سه‌تخته = 3) + private bool $active = true; +} +``` + +`capacity` از همین‌جا شروع می‌شود چون مستند بند ۶ صریح می‌گوید سه تخت = **یک منبع با +ظرفیت سه**، نه سه منبع. تسک ۰۲ همین معنا را روی `Resource` تکرار می‌کند و اتاق را +به‌عنوان یک `Resource` با `resource_type=room` منعکس می‌کند. + +## پنل ادمین + +- `BranchesPage.tsx` — `DataTable` + `PageHeader` با `backTo`، وضعیت لیست در URL با `useUrlState` +- `BranchFormPage.tsx` — دو تب: مشخصات / ساعت کاری. `SearchableSelect` برای شهر + (هرگز `` بومی — `SearchableSelect` طبق قاعدهٔ پروژه. + +## گزارش شبیه‌سازی — UI + +``` +PolicySimulationPage.tsx + +┌─────────────────────────────────────────────────────┐ +│ آزمایش قانون: حداقل ۲۱ روز فاصله بین جلسات لیزر │ +│ │ +│ نمونه: ۵۰ نوبت اخیر · تحت تأثیر: ۷ نوبت (۱۴٪) │ +│ ⚠️ شدت: متوسط │ +├─────────────────────────────────────────────────────┤ +│ بیمار تاریخ نوبت وضعیت فعلی → با این قانون │ +│ ز. احمدی ۱۴۰۵/۰۴/۱۲ مجاز → رد می‌شد │ +│ م. کریمی ۱۴۰۵/۰۴/۱۵ مجاز → رد می‌شد │ +│ … │ +└─────────────────────────────────────────────────────┘ + [بازگشت به ویرایش] [فعال‌سازی قانون] +``` + +ستون «وضعیت فعلی → با این قانون» تنها چیزی است که کاربر غیرفنی می‌فهمد. درصد و شدت هم +لازم است: قانونی که ۹۸٪ نوبت‌ها را رد می‌کند تقریباً همیشه اشتباه نوشته شده. + +سطح شدت: + +| تحت تأثیر | شدت | رنگ | +|---|---|---| +| ۰٪ | `none` | خاکستری + هشدار «این قانون روی هیچ نوبتی اثر نداشت» | +| ۱-۲۰٪ | `low` | سبز | +| ۲۱-۶۰٪ | `medium` | نارنجی | +| > ۶۰٪ | `high` | قرمز + متن «مطمئنید؟» روی دکمهٔ فعال‌سازی | + +شدت `none` هم هشدار است: یعنی شرط احتمالاً هرگز true نمی‌شود. + +## `activate` با شرط آزمایش + +```php +// PolicyService::activate() +$run = $this->simulationRepo->latestFor($policy); +if ($run === null || $run->getPolicyVersion() !== $policy->getVersion()) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'ابتدا قانون را آزمایش کنید و نتیجه را ببینید', + 422 + ); +} +``` + +`getPolicyVersion() !== $policy->getVersion()` مهم است: آزمایش نسخهٔ ۱ اجازهٔ فعال‌سازی +نسخهٔ ۲ را نمی‌دهد. diff --git a/docs/new_feture/taskes/task-10-policy-admin-sandbox/database.md b/docs/new_feture/taskes/task-10-policy-admin-sandbox/database.md new file mode 100644 index 00000000..b802dcb3 --- /dev/null +++ b/docs/new_feture/taskes/task-10-policy-admin-sandbox/database.md @@ -0,0 +1,71 @@ +# دیتابیس — تسک ۱۰ + +## `policy_simulation_runs` + +تنها جدول جدید این تسک — و تنها چیزی که شبیه‌سازی می‌نویسد. + +| ستون | نوع | توضیح | +|---|---|---| +| `id` | INT PK AI | | +| `uuid` | VARCHAR(36) UNIQUE | | +| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | +| `policy_id` | INT NOT NULL | FK → `policies.id` ON DELETE CASCADE | +| `policy_version` | SMALLINT NOT NULL | نسخهٔ آزمایش‌شده | +| `sample_size` | SMALLINT NOT NULL | تعداد نوبت نمونه | +| `affected_count` | SMALLINT NOT NULL | تعداد تحت تأثیر | +| `severity` | VARCHAR(10) NOT NULL | `none`\|`low`\|`medium`\|`high` | +| `report` | JSON NOT NULL | ردیف‌های تفصیلی (حداکثر ۵۰) | +| `run_by` | INT NULL | FK → `users.id` ON DELETE SET NULL | +| `created_at` | INT NOT NULL | | + +```sql +KEY idx_psr_policy (policy_id, policy_version, created_at) +KEY idx_psr_tenant (entity_type, entity_id, created_at) +``` + +`report` سقف حجم دارد: ۵۰ ردیف × چند فیلد ≈ چند کیلوبایت. بیشتر ذخیره نکن — گزارش +تفصیلی‌تر با اجرای دوباره به دست می‌آید. + +## هیچ تغییری در جدول‌های دیگر + +`policies.active` از قبل هست. شرط آزمایش در سطح سرویس اعمال می‌شود، نه schema. + +## نمونه‌گیری — `SimulationSampler` + +```sql +-- نوبت‌های واقعی، مرتبط با دامنهٔ قانون، جدیدترین اول +SELECT a.* FROM appointments a +WHERE a.entity_type = :type AND a.entity_id = :id + AND a.status IN ('confirmed','completed') + AND (:branchId IS NULL OR a.branch_id = :branchId) + AND (:serviceId IS NULL OR a.service_item_id = :serviceId) +ORDER BY a.slot_start DESC +LIMIT 50 +``` + +فیلتر `service_item_id` از خودِ شرط قانون استخراج می‌شود (اگر قانون سرویس مشخصی را +هدف گرفته). بدون آن، شبیه‌سازی قانون لیزر روی ۵۰ نوبت دندانپزشکی اجرا می‌شود و +«۰٪ تحت تأثیر» می‌دهد — که گمراه‌کننده است. + +## Migration + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +## پاکسازی + +اجراهای آزمایشی قدیمی ارزشی ندارند: + +```bash +ddev exec php bin/console app:policy:prune-simulations --older-than=30d --force +``` + +آخرین اجرا per (policy, version) **هرگز** حذف نمی‌شود — چون شرط `activate` به آن وابسته است. + +## طبقه‌بندی tenant + +| جدول | وضعیت | +|---|---| +| `policy_simulation_runs` | جفت tenant | diff --git a/docs/new_feture/taskes/task-10-policy-admin-sandbox/implementation_notes.md b/docs/new_feture/taskes/task-10-policy-admin-sandbox/implementation_notes.md new file mode 100644 index 00000000..7688e3f1 --- /dev/null +++ b/docs/new_feture/taskes/task-10-policy-admin-sandbox/implementation_notes.md @@ -0,0 +1,130 @@ +# نکات پیاده‌سازی — تسک ۱۰ + +## ۱. rollback اجباری، سه لایه + +```php +public function simulate(Policy $policy, int $size): SimulationReport +{ + $this->em->beginTransaction(); + try { + return $this->runInternal($policy, $size); + } finally { + $this->em->rollback(); // ← حتی اگر استثنا پرت شود + $this->em->clear(); // ← identity map پاک شود + } +} +``` + +`finally` نه `catch`: اگر شبیه‌سازی استثنا داد، هنوز باید rollback شود. +`clear()` بدون آن، entity های تغییرکردهٔ درون تراکنش در حافظه می‌مانند و اولین `flush` +در ادامهٔ همان request آن‌ها را ثبت می‌کند — یک باگ که پیدا کردنش روزها می‌برد. + +ثبت `PolicySimulationRun` **بعد** از این بلوک و در تراکنش خودش. + +## ۲. تست «هیچ چیزی ننوشت» — با شمارش، نه با اعتماد + +```php +$before = $this->countRows(['appointments','price_snapshots','resource_occupancy', + 'resource_occupancy_slot','appointment_segments']); +$this->simulator->simulate($policy, 50); +$after = $this->countRows([...]); +self::assertSame($before, $after, 'شبیه‌سازی نباید هیچ ردیفی بنویسد'); +``` + +این تست ارزشمندترین تست این تسک است. هر بار که کسی `PolicySimulator` را تغییر دهد، +همین تست جلوی فاجعه را می‌گیرد. + +## ۳. `evaluateIsolated` روی همهٔ شش موتور + +اضافه کردن این متد به شش موتور تسک ۰۹، تغییر اینترفیس است. پس **در تسک ۰۹ اضافه شود**، +نه اینجا — وگرنه شش کلاس دوباره ویرایش می‌شوند. + +اگر تسک ۰۹ تمام شده و این متد نیست، اضافه‌اش کن ولی به‌عنوان یک متد در همان اینترفیس +موجود، نه یک اینترفیس جدید. + +## ۴. فرم از schema — بدون استثنا + +```tsx +// ❌ اولین وسوسه +const FIELDS = ['patient.age', 'patient.tags', 'service.category_path']; + +// ✅ +const { data: schema } = useQuery({ queryKey: ['policy-schema'], staleTime: 300_000 }); +``` + +اگر فیلدها را در فرانت hard-code کنی، هر فیلد جدید در `FieldRegistry` نیاز به تغییر +فرانت دارد و بعد از دو ماه دو فهرست ناهمگام داریم. `staleTime` بلند اشکالی ندارد — +schema تقریباً هرگز عوض نمی‌شود. + +## ۵. شدت `none` هم هشدار است + +قانونی که روی هیچ نوبتی اثر نداشت، دو حالت دارد: +- شرطش هرگز true نمی‌شود (اشتباه نوشته شده) +- نمونهٔ ۵۰ نوبتی آن حالت را نداشت (شاید درست است) + +پیام باید هر دو را بگوید: + +> «این قانون روی هیچ‌کدام از ۵۰ نوبت نمونه اثر نداشت. یا شرط آن هرگز برقرار نمی‌شود، +> یا این حالت در نوبت‌های اخیر پیش نیامده. فعال‌سازی مجاز است.» + +فعال‌سازی را نبند — کلینیک جدید هیچ نوبتی ندارد و باید بتواند قانون بسازد. + +## ۶. الگوها باید واقعاً کار کنند + +هر الگو در `PolicyTemplateRegistry` باید یک تست داشته باشد که آن را می‌سازد، +شبیه‌سازی می‌کند و فعال می‌کند. الگویی که `conditions` نامعتبر تولید کند، بدترین حالت است: +کاربر فرم آماده را پر می‌کند و `422` می‌گیرد. + +```php +// tests/Policy/PolicyTemplateTest.php +/** @dataProvider templates */ +public function testTemplateProducesValidPolicy(string $key): void +{ + $policy = $this->registry->build($key, $this->sampleInputs($key)); + $this->validator->assertValid($policy); // همان اعتبارسنجی POST /policy +} +``` + +## ۷. edge case ها + +| حالت | رفتار درست | +|---|---| +| محیط بدون هیچ نوبت | گزارش خالی، `severity=none`، `activate` مجاز | +| قانون `deny` که همه را رد می‌کند | `severity=high`، فعال‌سازی با تأیید دوباره | +| `simulate` نسخهٔ ۱، بعد نسخهٔ ۲ ساخته شد | `activate` نسخهٔ ۲ → `422` | +| `simulate` دو بار پشت‌سرهم | آخری معیار است؛ قبلی می‌ماند | +| قانون فعال که دوباره `simulate` می‌شود | مجاز — کاربر می‌خواهد اثرش را ببیند | +| نوبت نمونه‌ای که سرویسش حذف شده | از نمونه حذف شود، در `sample_size` نیاید | +| قانون `pricing` روی نوبتی بدون snapshot | آن ردیف رد شود با علت `no_snapshot` | +| `sample_size` بزرگ‌تر از ۵۰ | سقف ۵۰ — درخواست بیشتر `422` | + +## ۸. تست + +``` +tests/Policy/PolicySimulatorTest.php ← ⭐ + - هیچ ردیفی نوشته نمی‌شود (شمارش قبل/بعد) + - رفتار درست وقتی evaluateIsolated استثنا می‌دهد (rollback + clear) + - گزارش فقط نوبت‌های تحت تأثیر را دارد +tests/Policy/SimulationSamplerTest.php + - فیلتر شعبه و سرویس از شرط قانون استخراج می‌شود + - فقط confirmed/completed + - سقف ۵۰ +tests/Policy/PolicyActivationGuardTest.php ← ⭐ + - activate بدون simulate → 422 + - activate با simulate نسخهٔ قبلی → 422 + - activate با simulate نسخهٔ جاری → 200 + - محیط بدون نوبت: simulate خالی → activate مجاز +tests/Policy/PolicyTemplateTest.php + - هر الگو قانون معتبر تولید می‌کند (dataProvider روی همهٔ الگوها) +tests/Policy/SeverityTest.php + - ۰٪ → none · ۱۰٪ → low · ۴۰٪ → medium · ۸۰٪ → high +assets/admin/pages/PolicyFormPage.test.tsx + - فیلدها از schema می‌آیند (mock schema با فیلد ساختگی → در UI ظاهر شود) + - عملگرهای نامعتبر برای نوع فیلد نمایش داده نمی‌شوند +``` + +## ۹. مستندات + +`docs/api/policy.md` را با `simulate` و `policy-templates` و شرط جدید `activate` +به‌روز کن. در `docs/architecture/policy-engine.md` یک بخش «چرا آزمایش اجباری است» +اضافه کن با ارجاع به ریسک دوم مستند بند ۱۷. diff --git a/docs/new_feture/taskes/task-10-policy-admin-sandbox/task.md b/docs/new_feture/taskes/task-10-policy-admin-sandbox/task.md new file mode 100644 index 00000000..bc25c560 --- /dev/null +++ b/docs/new_feture/taskes/task-10-policy-admin-sandbox/task.md @@ -0,0 +1,61 @@ +# تسک ۱۰ — فرم ساخت قانون و محیط آزمایش + +**فاز:** ۲ (قوانین) · **وابستگی:** ۰۹ · **زمان:** ۱۰-۱۲ ساعت + +--- + +## هدف + +مستند بند ۱۷، ریسک دوم: «کاربر غیرفنی نمی‌تواند قانون درست تعریف کند → قانون‌های اشتباه، +رفتار عجیب». راه‌حل مستند: **فرم آماده، الگوهای از پیش تعریف‌شده، آزمایش اجباری قبل از +فعال شدن.** + +بدون این تسک، تسک ۰۹ یک API قدرتمند است که هیچ‌کس نمی‌تواند از آن استفادهٔ درست کند. + +## دامنه + +**هست:** +- فرم ساخت قانون که از `GET /api/v1/policy-schema` ساخته می‌شود (نه hard-code در فرانت) +- الگوهای آماده (`policy templates`) — کاربر الگو را انتخاب و مقدار پر می‌کند +- محیط آزمایش (`dry-run`): اجرای قانون روی داده واقعی بدون ثبت هیچ چیز +- **آزمایش اجباری**: `activate` تا وقتی یک اجرای آزمایشی موفق ثبت نشده، رد می‌شود +- نمایش تاریخچهٔ نسخه‌ها با diff + +**نیست:** موتور قانون (تسک ۰۹). + +## Endpoint ها + +| متد | مسیر | توضیح | +|---|---|---| +| POST | `/api/v1/policy/{uuid}/simulate` | اجرای آزمایشی روی نوبت‌های واقعی گذشته | +| GET | `/api/v1/policy-templates` | الگوهای آماده | + +`POST /policy/{uuid}/activate` (تسک ۰۹) یک شرط جدید می‌گیرد: وجود یک `simulate` موفق +برای نسخهٔ جاری. + +## معیار پذیرش + +- ✅ موفق: کاربر الگوی «حداقل فاصله بین جلسات» را انتخاب می‌کند، سرویس و تعداد روز را + پر می‌کند، `simulate` می‌زند → گزارشی از ۵۰ نوبت اخیر: چند تا تحت تأثیر قرار می‌گرفتند و + دقیقاً چه تغییری می‌کردند. +- ✅ موفق: `simulate` هیچ ردیفی در دیتابیس نمی‌نویسد (به‌جز `policy_simulation_runs`). + تست باید تعداد ردیف‌های `appointments`, `price_snapshots`, `resource_occupancy` را + قبل و بعد مقایسه کند. +- ✅ موفق: بعد از `simulate` موفق، `activate` کار می‌کند. +- ✅ موفق: فرم ساخت قانون بدون هیچ تغییر کد فرانت، فیلد جدیدی که به `FieldRegistry` + اضافه شود را نشان می‌دهد. +- ❌ خطا: `activate` بدون `simulate` → `422` با پیام «ابتدا قانون را آزمایش کنید». +- ❌ خطا: `activate` بعد از تغییر محتوای قانون (نسخهٔ جدید) → `simulate` قبلی معتبر نیست + → `422`. +- ⚠️ مرزی: محیطی که هیچ نوبت گذشته‌ای ندارد → `simulate` با گزارش خالی و + `warning: 'داده‌ای برای آزمایش نیست'` موفق شود (وگرنه کلینیک جدید هرگز نمی‌تواند + قانون فعال کند). +- ⚠️ مرزی: قانون `deny` که همهٔ ۵۰ نوبت را رد می‌کند → `simulate` موفق ولی با + `severity: 'high'` و پیام «این قانون همهٔ نوبت‌های نمونه را رد می‌کند». +- ⚠️ مرزی: `simulate` روی قانون دستهٔ `pricing` → تفاوت مبلغ per نوبت نمایش داده شود. + +## خروجی + +- `src/Policy/Simulation/` +- `assets/admin/pages/PoliciesPage.tsx` + `PolicyFormPage.tsx` + `PolicySimulationPage.tsx` +- `docs/api/policy.md` به‌روزرسانی diff --git a/docs/new_feture/taskes/task-11-package-credit-ledger/architecture.md b/docs/new_feture/taskes/task-11-package-credit-ledger/architecture.md new file mode 100644 index 00000000..e9732247 --- /dev/null +++ b/docs/new_feture/taskes/task-11-package-credit-ledger/architecture.md @@ -0,0 +1,138 @@ +# معماری — تسک ۱۱ + +## ساختار فایل + +``` +src/Package/ +├── Entity/ +│ ├── Package.php # تعریف +│ ├── PackageService.php # سرویس‌های پوشش‌داده‌شده (ManyToMany با تعداد) +│ ├── PatientPackage.php # نمونهٔ خریداری‌شده +│ └── SessionCreditLedger.php # دفتر +├── Service/ +│ ├── PackageSalesService.php # فروش +│ ├── CreditLedgerService.php # ← تنها نویسندهٔ دفتر +│ └── PackageConsumptionService.php # مصرف در زنجیرهٔ قیمت +├── Repository/… +└── Controller/{PackageController, PatientPackageController}.php +``` + +## دفتر، نه شمارنده + +```php +final class CreditLedgerService +{ + public const KIND_PURCHASE = 'purchase'; // + خرید + public const KIND_CONSUME = 'consume'; // − مصرف در نوبت + public const KIND_REFUND = 'refund'; // + بازگشت با لغو + public const KIND_ADJUSTMENT = 'adjustment'; // ± اصلاح دستی + public const KIND_EXPIRY = 'expiry'; // − ابطال + + /** مانده = جمع همهٔ delta ها. هیچ ستون ذخیره‌شده‌ای نیست. */ + public function balance(PatientPackage $pkg, ?ServiceItem $service = null): int + { + return $this->ledgerRepo->sumDelta($pkg, $service); + } + + /** هیچ‌جای دیگری نباید در session_credit_ledger بنویسد. */ + public function record(PatientPackage $pkg, string $kind, int $delta, LedgerMeta $meta): SessionCreditLedger; +} +``` + +مستند: «اگر فقط یک عدد نگه داریم، اولین اشتباه هرگز قابل ردیابی نیست.» پس: + +- **هیچ ستون `remaining` یا `used_count` در هیچ جدولی نیست** — تست schema این را اجبار کند +- هر تغییر یک ردیف است، با `reason` و `created_by` و ارجاع به نوبت +- تصحیح خطا = ردیف `adjustment` جدید، نه ویرایش ردیف قبلی + +### هزینهٔ کارایی و پاسخش + +`SUM(delta)` per بیمار per پکیج. تعداد ردیف‌ها کوچک است (پکیج ۸ جلسه‌ای ≤ ۲۰ ردیف). +اگر روزی لازم شد، **کش** بگذار، نه ستون: + +```php +// cp:pkg:{patientPackageId}:balance TTL 60s، ابطال روی هر record() +``` + +ستون denormalized یعنی دو منبع حقیقت و همان مشکلی که مستند هشدار داده. + +## جلوگیری از منفی شدن مانده + +دو نوبت هم‌زمان که هر دو آخرین اعتبار را می‌خواهند: + +```php +public function consume(PatientPackage $pkg, Appointment $appt): bool +{ + // قفل بدبینانه روی خودِ ردیف پکیج — تعداد رقابت‌ها ناچیز است + $locked = $this->em->find(PatientPackage::class, $pkg->getId(), LockMode::PESSIMISTIC_WRITE); + + if ($this->ledger->balance($locked) <= 0) { + return false; // ← خطا نیست؛ مبلغ کامل محاسبه می‌شود + } + $this->ledger->record($locked, KIND_CONSUME, -1, LedgerMeta::forAppointment($appt)); + return true; +} +``` + +اینجا **قفل بدبینانه درست است**، برخلاف تسک ۰۷: + +| | تسک ۰۷ (اسلات) | تسک ۱۱ (اعتبار) | +|---|---|---| +| نرخ رقابت | بالا — ساعت پرتقاضا | ناچیز — یک بیمار، یک پکیج | +| تعداد ردیف درگیر | ده‌ها سطل | یک ردیف | +| هزینهٔ قفل | صف‌شدن رزروها | ناچیز | + +پس راه‌حل متفاوت است و این تفاوت باید مستند شود، وگرنه کسی «برای یکدستی» یکی را +به دیگری تبدیل می‌کند. + +## اتصال به زنجیرهٔ قیمت + +قلاب مرحلهٔ ۴ تسک ۰۸ که تا حالا no-op بود: + +```php +// PackageConsumptionService::consume(array $lines, QuoteRequest $req): array +$pkg = $this->finder->firstUsable($req->patient, $req->service, $req->at); // FIFO +if ($pkg === null) return $lines; + +// در quote فقط نمایش می‌دهیم، در confirm واقعاً کسر می‌کنیم +$lines[] = PriceLine::package($pkg, -$this->coveredAmount($lines, $pkg)); +return $lines; +``` + +⚠️ **تفکیک حیاتی:** `quote` (پیش‌نمایش) هیچ‌وقت مصرف نمی‌کند. مصرف فقط در +`BookingService::confirm()` داخل همان تراکنش. اگر `quote` مصرف کند، هر بار که بیمار +صفحه را رفرش کند یک جلسه از دست می‌دهد. + +`PriceQuote` یک پرچم `packageWillBeConsumed` می‌گیرد تا UI بگوید «۱ جلسه از پکیج شما +کسر می‌شود». + +## FIFO + +```php +// PackageFinder::firstUsable() +// قدیمی‌ترین پکیج منقضی‌نشده با مانده > 0 +$qb->orderBy('pp.purchasedAt', 'ASC') + ->andWhere('pp.validTo IS NULL OR pp.validTo >= :now'); +``` + +قدیمی‌ترین اول، چون نزدیک‌تر به انقضا است. اگر LIFO بود، پکیج قدیمی منقضی می‌شد و +بیمار پولش را از دست می‌داد. + +## انقضا + +```bash +ddev exec php bin/console app:package:expire # روزانه با symfony/scheduler +``` + +برای هر `PatientPackage` با `valid_to` گذشته و مانده > ۰: +یک ردیف `expiry` با `delta = -balance` ثبت می‌شود. دفتر دست‌نخورده می‌ماند و +تاریخچه کامل است — بیمار می‌تواند بپرسد «۳ جلسه‌ام چه شد؟» و جواب در دفتر است. + +## پنل ادمین + +- `PackagesPage.tsx` — تعریف پکیج‌ها با `PriceInput` و انتخاب سرویس‌ها +- در `PatientDetailPage.tsx` کارت «پکیج‌ها»: هر پکیج با مانده، تاریخ انقضا و لینک دفتر +- `PatientPackageLedgerPage.tsx` — جدول دفتر با ستون‌های: تاریخ، نوع، تغییر، مانده تجمعی، + دلیل، ثبت‌کننده، نوبت مرتبط +- «مانده تجمعی» ستون محاسبه‌شده در UI است، نه ستون DB — و همین به کاربر ثابت می‌کند + عدد از کجا آمده diff --git a/docs/new_feture/taskes/task-11-package-credit-ledger/database.md b/docs/new_feture/taskes/task-11-package-credit-ledger/database.md new file mode 100644 index 00000000..e958e451 --- /dev/null +++ b/docs/new_feture/taskes/task-11-package-credit-ledger/database.md @@ -0,0 +1,133 @@ +# دیتابیس — تسک ۱۱ + +## `packages` — تعریف + +| ستون | نوع | توضیح | +|---|---|---| +| `id` | INT PK AI | | +| `uuid` | VARCHAR(36) UNIQUE | | +| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | +| `name` | VARCHAR(200) NOT NULL | «۶ جلسه لیزر فول‌بادی» | +| `session_count` | SMALLINT NOT NULL | تعداد جلسه | +| `price_rials` | BIGINT NOT NULL | **BIGINT** — پکیج بزرگ از سقف INT عبور می‌کند | +| `validity_days` | SMALLINT NULL | اعتبار از تاریخ خرید؛ NULL = بی‌پایان | +| `active` | TINYINT(1) NOT NULL DEFAULT 1 | | +| `created_at`/`updated_at` | INT NOT NULL | | + +```sql +KEY idx_packages_tenant (entity_type, entity_id, active) +``` + +## `package_services` + +```sql +CREATE TABLE package_services ( + id INT PRIMARY KEY AUTO_INCREMENT, + package_id INT NOT NULL, + service_item_id INT NOT NULL, + UNIQUE KEY uniq_pkg_service (package_id, service_item_id), + CONSTRAINT fk_pkgs_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE, + CONSTRAINT fk_pkgs_service FOREIGN KEY (service_item_id) REFERENCES service_items(id) ON DELETE RESTRICT +); +``` + +`ON DELETE RESTRICT` روی سرویس: حذف سرویسی که در پکیج فروخته‌شده هست، اعتبار بیماران را +بی‌معنا می‌کند. + +قید اپلیکیشنی: پکیج باید حداقل یک سرویس داشته باشد → `422` هنگام ساخت. + +## `patient_packages` — نمونهٔ خریداری‌شده + +| ستون | نوع | توضیح | +|---|---|---| +| `id` | INT PK AI | | +| `uuid` | VARCHAR(36) UNIQUE | | +| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | +| `package_id` | INT NOT NULL | FK ON DELETE RESTRICT | +| `patient_record_id` | INT NOT NULL | FK → `patient_records.id` ON DELETE RESTRICT | +| `session_count` | SMALLINT NOT NULL | snapshot تعداد لحظهٔ خرید | +| `price_paid_rials` | BIGINT NOT NULL | snapshot قیمت پرداختی | +| `payment_id` | INT NULL | FK → `payments.id` ON DELETE SET NULL | +| `purchased_at` | INT NOT NULL | مبنای FIFO | +| `valid_to` | INT NULL | محاسبه‌شده از `validity_days` لحظهٔ خرید | +| `created_at`/`updated_at` | INT NOT NULL | | + +```sql +KEY idx_pp_tenant (entity_type, entity_id, purchased_at) +KEY idx_pp_patient (patient_record_id, valid_to) +``` + +> ⛔ **هیچ ستون `remaining_sessions` یا `used_count` نیست و نباید باشد.** +> `session_count` فقط snapshot تعریف است، نه مانده. + +`session_count` و `price_paid_rials` کپی می‌شوند (قانون پنجم مستند): تغییر تعریف پکیج +فردا، پکیج فروخته‌شدهٔ دیروز را عوض نمی‌کند. + +## `session_credit_ledger` — دفتر + +| ستون | نوع | توضیح | +|---|---|---| +| `id` | BIGINT PK AI | | +| `uuid` | VARCHAR(36) UNIQUE | | +| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | +| `patient_package_id` | INT NOT NULL | FK ON DELETE RESTRICT | +| `kind` | VARCHAR(15) NOT NULL | `purchase`\|`consume`\|`refund`\|`adjustment`\|`expiry` | +| `delta` | SMALLINT NOT NULL | مثبت یا منفی — هرگز صفر | +| `appointment_id` | INT NULL | FK ON DELETE SET NULL | +| `service_item_id` | INT NULL | FK ON DELETE SET NULL — کدام سرویس مصرف کرد | +| `reason` | VARCHAR(255) NULL | اجباری برای `adjustment` | +| `created_by` | INT NULL | FK → `users.id` ON DELETE SET NULL | +| `created_at` | INT NOT NULL | | + +```sql +KEY idx_scl_package (patient_package_id, created_at) +KEY idx_scl_tenant (entity_type, entity_id, created_at) +KEY idx_scl_appt (appointment_id) +UNIQUE KEY uniq_scl_consume (appointment_id, kind) -- ← جلوگیری از مصرف دوباره +``` + +`uniq_scl_consume` مهم است: `confirm` تسک ۰۷ idempotent است و اگر دوبار اجرا شود، +دو ردیف `consume` نباید ثبت شود. `NULL` های `appointment_id` در UNIQUE مشکلی ندارند +(چند `purchase` بدون نوبت مجازند). + +**ردیف‌ها هرگز حذف یا ویرایش نمی‌شوند.** append-only. اصلاح = ردیف جدید. + +## هیچ تغییری در جدول‌های دیگر + +`price_snapshot_lines.kind` از قبل مقدار `package` را دارد (تسک ۰۸). + +## Migration + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +بدون backfill — هیچ پکیجی از قبل وجود ندارد. + +## تست schema + +```php +// tests/Package/LedgerSchemaTest.php +public function testNoStoredBalanceColumnExists(): void +{ + $columns = $this->schemaManager->listTableColumns('patient_packages'); + foreach (['remaining', 'remaining_sessions', 'used_count', 'balance'] as $forbidden) { + self::assertArrayNotHasKey($forbidden, $columns, + 'مانده باید از دفتر محاسبه شود، نه ذخیره'); + } +} +``` + +تست عجیبی به نظر می‌رسد ولی همان چیزی است که شش ماه بعد جلوی «بهینه‌سازی» می‌ایستد. + +## طبقه‌بندی tenant + +| جدول | وضعیت | +|---|---| +| `packages`, `patient_packages`, `session_credit_ledger` | جفت tenant | +| `package_services` | `AGGREGATE_CHILDREN` → ریشه `Package` | + +⚠️ برخلاف `wallet_transactions` (که `ENTITIES` است چون پول مال شخص است)، دفتر اعتبار +جفت tenant واقعی می‌گیرد: اعتبار جلسهٔ کلینیک الف در کلینیک ب معنا ندارد. +دلیلش را در `docs/architecture/tenancy.md` کنار توضیح کیف پول اضافه کن. diff --git a/docs/new_feture/taskes/task-11-package-credit-ledger/implementation_notes.md b/docs/new_feture/taskes/task-11-package-credit-ledger/implementation_notes.md new file mode 100644 index 00000000..09a886e8 --- /dev/null +++ b/docs/new_feture/taskes/task-11-package-credit-ledger/implementation_notes.md @@ -0,0 +1,156 @@ +# نکات پیاده‌سازی — تسک ۱۱ + +## ۱. `quote` نمایش می‌دهد، `confirm` مصرف می‌کند + +بدترین باگ ممکن در این تسک: + +```php +// ❌ بیمار صفحه را سه بار رفرش می‌کند، سه جلسه از دست می‌دهد +public function quote(QuoteRequest $req): PriceQuote { + $this->packages->consume(…); +} +``` + +```php +// ✅ +public function quote(…): PriceQuote { + $pkg = $this->finder->firstUsable(…); + return $quote->withPackagePreview($pkg); // فقط نمایش +} +// و در BookingService::confirm() داخل تراکنش: +$this->packages->consume($pkg, $appointment); +``` + +تست اجباری: ده بار `quote` → مانده بدون تغییر. + +## ۲. مانده صفر خطا نیست + +```php +if ($this->ledger->balance($pkg) <= 0) { + return false; // ✅ مبلغ کامل محاسبه می‌شود + // نه: throw new AppException(...) +} +``` + +بیمار با پکیج تمام‌شده باید بتواند نقدی نوبت بگیرد. `422` یعنی بن‌بست بی‌دلیل. +UI پیام بدهد: «اعتبار پکیج شما تمام شده؛ این نوبت نقدی محاسبه می‌شود.» + +## ۳. `uniq_scl_consume` و idempotency + +`confirm` تسک ۰۷ idempotent است. اگر دوبار صدا زده شود: + +```php +try { + $this->ledger->record($pkg, KIND_CONSUME, -1, $meta); +} catch (UniqueConstraintViolationException) { + // قبلاً مصرف شده — همان رفتار idempotent، نه خطا +} +``` + +با کلید یکتای `(appointment_id, kind)` این تضمین از دیتابیس می‌آید. همان الگوی تسک ۰۷. + +## ۴. لغو = ردیف `refund`، نه حذف `consume` + +```php +// ❌ تاریخ را پاک می‌کند +$this->em->remove($consumeRow); + +// ✅ +$this->ledger->record($pkg, KIND_REFUND, +1, LedgerMeta::forCancellation($appt)); +``` + +دفتر append-only است. بعد از سه ماه، سؤال «چند بار این بیمار نوبتش را لغو کرد؟» فقط از +دفتر جواب دارد. + +⚠️ بازگشت اعتبار **مشروط به سیاست لغو** است (تسک ۱۳). تا آن تسک نیامده، همیشه برگردان و +یک `TODO` با ارجاع به تسک ۱۳ بگذار — نه یک پرچم نیم‌کاره. + +## ۵. FIFO و انقضا + +```php +->orderBy('pp.purchasedAt', 'ASC') +``` + +قدیمی‌ترین اول. اگر LIFO باشد، پکیج قدیمی منقضی می‌شود و بیمار پولش را از دست می‌دهد — +و شکایتش درست است. + +`valid_to` هنگام **خرید** محاسبه و ذخیره می‌شود (`purchased_at + validity_days * 86400`)، +نه در زمان اجرا: تغییر `validity_days` تعریف پکیج نباید اعتبار خریدهای قبلی را عوض کند. + +## ۶. قفل بدبینانه اینجا درست است + +برخلاف تسک ۰۷ که قفل را رد کردیم: + +```php +$locked = $this->em->find(PatientPackage::class, $id, LockMode::PESSIMISTIC_WRITE); +``` + +نرخ رقابت اینجا ناچیز است (یک بیمار، یک پکیج) و یک ردیف قفل می‌شود، نه ده‌ها سطل. +جدول مقایسه در `architecture.md` را در `docs/api/package.md` هم بنویس، وگرنه کسی روزی +«برای یکدستی» یکی را به دیگری تبدیل می‌کند. + +## ۷. `adjustment` فقط با نقش مدیر و با دلیل + +```php +#[IsGranted('ROLE_CLINIC_OWNER')] // نه منشی، نه پرسنل +public function adjust(string $uuid, Request $request): JsonResponse +{ + $reason = trim((string) $data['reason'] ?? ''); + if ($reason === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ذکر دلیل اصلاح الزامی است', 422, 'reason'); + } +} +``` + +اصلاح دستی بدون دلیل، دفتر را به همان شمارندهٔ غیرقابل‌ردیابی تبدیل می‌کند که مستند +هشدار داده. + +## ۸. edge case ها + +| حالت | رفتار درست | +|---|---| +| بیمار دو پکیج معتبر برای یک سرویس | FIFO — قدیمی‌ترِ منقضی‌نشده | +| پکیج معتبر ولی سرویس نوبت پوشش داده نمی‌شود | اعمال نمی‌شود، مبلغ کامل | +| پکیج منقضی با مانده ۳ | ردیف `expiry -3` توسط cron؛ مانده صفر، دفتر کامل | +| `confirm` دوباره | `uniq_scl_consume` → idempotent | +| لغو نوبتی که پکیج نداشت | هیچ ردیفی ثبت نمی‌شود | +| `delta = 0` | `422` — ردیف بی‌اثر ننویس | +| حذف تعریف پکیجی که فروخته شده | `422` (FK RESTRICT) — `active=false` مسیر درست | +| پکیج بدون سرویس | `422` هنگام ساخت | +| مبلغ پکیج بزرگ‌تر از سقف INT | `BIGINT` — از قبل حل شده | +| بیمار مهمان بدون `patient_record` | پکیج فروش نمی‌رود — `422` با پیام «ابتدا پروندهٔ بیمار را ثبت کنید» | + +## ۹. تست + +``` +tests/Package/CreditLedgerTest.php ← ⭐ + - مانده = SUM(delta) در همهٔ سناریوها + - purchase → consume → refund → مانده اولیه + - append-only: هیچ remove/update روی ردیف‌ها +tests/Package/LedgerSchemaTest.php ← ⭐ + - هیچ ستون remaining/used_count در schema +tests/Package/QuoteDoesNotConsumeTest.php ← ⭐ + - ده بار quote → مانده بدون تغییر +tests/Package/ConcurrentConsumeTest.php + - دو نوبت هم‌زمان روی آخرین اعتبار → یکی می‌گیرد، مانده منفی نمی‌شود +tests/Package/IdempotentConsumeTest.php + - confirm دوبار → یک ردیف consume +tests/Package/FifoTest.php + - قدیمی‌ترین پکیج اول مصرف می‌شود +tests/Package/ExpiryTest.php + - cron ردیف expiry با delta = -balance می‌سازد + - پکیج منقضی در finder نمی‌آید +tests/Package/AdjustmentAuthTest.php + - منشی → 403 · مدیر بدون دلیل → 422 · مدیر با دلیل → 200 +tests/Package/PackageTenantTest.php + - پکیج محیط دیگر → 404 +tests/Package/PricingIntegrationTest.php + - ردیف package در price_snapshot_lines با مبلغ منفی + - جمع ردیف‌ها = مبلغ نهایی (invariant تسک ۰۸ حفظ شود) +``` + +## ۱۰. مستندات + +`docs/api/package.md` بساز. `docs/architecture/tenancy.md` را با دلیل تفاوت +«دفتر اعتبار (جفت tenant)» و «کیف پول (سراسری + انتساب)» به‌روز کن — +این دو شبیه‌اند و اشتباه گرفتنشان نشتی مالی می‌سازد. diff --git a/docs/new_feture/taskes/task-11-package-credit-ledger/task.md b/docs/new_feture/taskes/task-11-package-credit-ledger/task.md new file mode 100644 index 00000000..db59f887 --- /dev/null +++ b/docs/new_feture/taskes/task-11-package-credit-ledger/task.md @@ -0,0 +1,72 @@ +# تسک ۱۱ — پکیج و دفتر اعتبار جلسات + +**فاز:** ۳ (کسب‌وکار) · **وابستگی:** ۰۸ · **زمان:** ۱۰-۱۲ ساعت + +--- + +## هدف + +مستند بند ۱۲: «پکیج شش جلسه لیزر» حالت رایج کلینیک زیبایی است. بیمار یکجا پول می‌دهد و +بعداً جلساتش را رزرو می‌کند. + +نکتهٔ فنی مستند: **اعتبار را به صورت دفتر حساب نگه می‌داریم، نه یک عدد شمارنده.** + +## وضعیت فعلی + +هیچ مفهومی از پکیج وجود ندارد. ولی الگوی «دفتر حساب» از قبل در پروژه هست و **درست +پیاده شده**: `WalletTransaction` + `getWalletBalance(user)` — موجودی از جمع تراکنش‌ها +محاسبه می‌شود، نه از یک ستون شمارنده. همان الگو اینجا تکرار می‌شود. + +⚠️ نکتهٔ tenancy: `wallet_transactions` عمداً `ENTITIES` است (پول مال شخص است) ولی هر +ردیف `recorded_entity_*` دارد. دفتر اعتبار جلسه **متفاوت** است: اعتبار جلسهٔ لیزر در +کلینیک الف در کلینیک ب معنا ندارد. پس جفت tenant واقعی می‌گیرد، نه انتساب. + +## دامنه + +**هست:** +- `Package` — تعریف پکیج (سرویس، تعداد جلسه، قیمت، اعتبار زمانی) +- `PatientPackage` — پکیج خریداری‌شدهٔ یک بیمار +- `SessionCreditLedger` — دفتر اعتبار: هر تراکنش یک ردیف +- مصرف اعتبار در `confirm` نوبت، بازگشت در لغو +- اتصال به `PricingEngine` مرحلهٔ ۴ (قلاب تسک ۰۸) + +**نیست:** پروتکل دوره و فاصلهٔ جلسات (تسک ۱۲)، سیاست لغو (تسک ۱۳). + +## Endpoint ها + +| متد | مسیر | توضیح | +|---|---|---| +| GET/POST | `/api/v1/packages` | تعریف پکیج | +| GET/PATCH/DELETE | `/api/v1/package/{uuid}` | | +| POST | `/api/v1/patient/{uuid}/package` | فروش پکیج به بیمار | +| GET | `/api/v1/patient/{uuid}/packages` | پکیج‌های بیمار + مانده | +| GET | `/api/v1/patient-package/{uuid}/ledger` | دفتر تراکنش‌های اعتبار | +| POST | `/api/v1/patient-package/{uuid}/adjust` | اصلاح دستی با دلیل (فقط مدیر) | +| POST | `/api/v1/patient-package/{uuid}/expire` | ابطال دستی | + +## معیار پذیرش + +- ✅ موفق: پکیج «۶ جلسه لیزر فول‌بادی» با قیمت تعریف می‌شود، به بیمار فروخته می‌شود → + `GET /patient/{uuid}/packages` مانده `6` می‌دهد و دفتر یک ردیف `purchase +6` دارد. +- ✅ موفق: ثبت نوبت لیزر برای همان بیمار → `PricingEngine` مرحلهٔ ۴ یک واحد کسر می‌کند، + مبلغ نهایی صفر می‌شود، دفتر ردیف `consume -1` می‌گیرد، مانده `5`. +- ✅ موفق: لغو همان نوبت → ردیف `refund +1`، مانده `6`. **ردیف `consume` حذف نمی‌شود.** +- ✅ موفق (**دفتر، نه شمارنده**): مانده همیشه `SUM(delta)` است. یک تست باید ثابت کند + هیچ ستون `remaining` یا `used_count` در schema وجود ندارد. +- ✅ موفق: `POST /adjust` با دلیل → ردیف `adjustment` با `reason` و `created_by`. +- ❌ خطا: ثبت نوبت با پکیجی که مانده‌اش صفر است → پکیج اعمال نمی‌شود، مبلغ کامل + محاسبه می‌شود (نه خطا — بیمار می‌تواند نقدی بپردازد). +- ❌ خطا: پکیج محیط الف روی نوبت محیط ب → `404`. +- ❌ خطا: `adjust` با نقش منشی → `403`. +- ⚠️ مرزی: پکیج منقضی‌شده (`valid_to` گذشته) → مانده در نمایش صفر می‌شود ولی دفتر + دست‌نخورده می‌ماند؛ ردیف `expiry` با delta منفی برابر مانده ثبت می‌شود. +- ⚠️ مرزی: دو نوبت هم‌زمان که هر دو آخرین اعتبار را می‌خواهند → یکی می‌گیرد، دیگری + مبلغ کامل. **بدون منفی شدن مانده.** +- ⚠️ مرزی: بیمار دو پکیج معتبر برای یک سرویس دارد → قدیمی‌ترِ منقضی‌نشده اول مصرف شود (FIFO). +- ⚠️ مرزی: پکیجی که هیچ سرویسی به آن وصل نیست → `422` هنگام ساخت. + +## خروجی + +- `src/Package/` +- `assets/admin/pages/PackagesPage.tsx` + کارت پکیج در `PatientDetailPage.tsx` +- `docs/api/package.md` diff --git a/docs/new_feture/taskes/task-12-treatment-course/architecture.md b/docs/new_feture/taskes/task-12-treatment-course/architecture.md new file mode 100644 index 00000000..1d0695e5 --- /dev/null +++ b/docs/new_feture/taskes/task-12-treatment-course/architecture.md @@ -0,0 +1,213 @@ +# معماری — تسک ۱۲ + +## ساختار فایل + +``` +src/Course/ +├── Entity/ +│ ├── CourseProtocol.php +│ ├── CourseProtocolStep.php # پارامتر هر جلسه +│ ├── TreatmentCourse.php +│ └── CourseSession.php +├── Service/ +│ ├── CourseStarter.php # شروع دوره از پروتکل +│ ├── CourseScheduler.php # رزرو یکجا + پیشنهاد جلسهٔ بعدی +│ ├── CourseProgressCalculator.php +│ └── CourseSessionLinker.php # اتصال نوبت ↔ جلسهٔ دوره +├── Controller/{CourseProtocolController, TreatmentCourseController}.php +└── Repository/… +``` + +## `CourseProtocol` و `CourseProtocolStep` + +```php +class CourseProtocol +{ + use TenantOwnedTrait; + private ServiceItem $service; + private int $sessionCount; // ۸ + private int $minDays; // ۲۱ + private int $idealDays; // ۲۸ + private int $maxDays; // ۴۵ + private bool $preferSameResource = true; + private Collection $steps; // CourseProtocolStep +} + +class CourseProtocolStep +{ + private int $sessionNumber; // ۱..۸ + private array $params = []; // {"energy": 12} — اسکالر، فهرست آزاد + private ?int $overrideDurationMinutes = null; // جلسهٔ اول طولانی‌تر است +} +``` + +`params` آزاد است چون هر تخصص پارامتر خودش را دارد (سطح انرژی، ضخامت، دوز). ولی مثل +`ClinicResource.attributes` فقط اسکالر — و هیچ منطقی به مقدارش وابسته نیست، فقط نمایش و +ثبت می‌شود. + +`minDays <= idealDays <= maxDays` قید اجباری. + +## `TreatmentCourse` و `CourseSession` + +```php +class TreatmentCourse +{ + use TenantOwnedTrait; + public const STATUS_ACTIVE = 'active'; + public const STATUS_COMPLETED = 'completed'; + public const STATUS_ABANDONED = 'abandoned'; + + private PatientRecord $patient; + private ServiceItem $service; + private CourseProtocol $protocol; + + // ── snapshot پروتکل در لحظهٔ شروع (قانون پنجم مستند) ── + private int $sessionCount; + private int $minDays; + private int $idealDays; + private int $maxDays; + + private ?PatientPackage $package = null; // تسک ۱۱ — اختیاری + private ?ClinicResource $preferredResource = null; // منبع جلسهٔ اول + private string $status = self::STATUS_ACTIVE; + private int $startedAt; +} + +class CourseSession +{ + public const STATUS_PLANNED = 'planned'; + public const STATUS_BOOKED = 'booked'; + public const STATUS_COMPLETED = 'completed'; + public const STATUS_SKIPPED = 'skipped'; + + private TreatmentCourse $course; + private int $sessionNumber; + private array $params = []; // snapshot از CourseProtocolStep + private ?Appointment $appointment = null; + private string $status = self::STATUS_PLANNED; + private ?int $completedAt = null; +} +``` + +چهار فیلد فاصله و `params` **کپی** می‌شوند نه FK: تغییر پروتکل فردا نباید دورهٔ در جریان +را عوض کند. این همان تصمیمی است که در `appointment_segments` و `patient_packages` گرفته شد. + +## `CourseScheduler` — رزرو یکجا + +```php +public function bookAll(TreatmentCourse $course): BookAllResult +{ + return $this->em->wrapInTransaction(function () use ($course) { + $anchor = $this->lastCompletedAt($course) ?? time(); + $planned = $course->plannedSessions(); // مرتب بر اساس sessionNumber + $holds = []; + + foreach ($planned as $session) { + $target = $anchor + $course->getIdealDays() * 86400; + + if ($target > time() + 90 * 86400) { + // بیرون از بازهٔ مجاز جستجو — این و بقیه planned می‌مانند + break; + } + + $slot = $this->findNearestInRange( + $course, $session, + min: $anchor + $course->getMinDays() * 86400, + ideal: $target, + max: $anchor + $course->getMaxDays() * 86400, + ); + + if ($slot === null) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, sprintf( + 'برای جلسهٔ %d هیچ وقت مناسبی در بازهٔ مجاز پیدا نشد', $session->getSessionNumber() + ), 422); + } + + $holds[] = $this->holdService->hold($this->holdRequestFor($course, $session, $slot)); + $anchor = $slot->start; // ← لنگر جلسهٔ بعدی، همین جلسه + } + + foreach ($holds as $hold) { $this->bookingService->confirm($hold->uuid, $course->owner()); } + return new BookAllResult(count($holds), count($planned) - count($holds)); + }); +} +``` + +سه نکتهٔ حیاتی: + +1. **همه یا هیچ** — کل حلقه در یک تراکنش. استثنا در جلسهٔ ۵ یعنی rollback جلسات ۱ تا ۴. + رزرو نیمه‌کاره بدترین حالت است: بیمار فکر می‌کند دوره‌اش رزرو شده. +2. **لنگر متحرک** — فاصله از جلسهٔ **قبلی** حساب می‌شود، نه از شروع دوره. اگر جلسهٔ ۲ + سه روز دیرتر افتاد، جلسهٔ ۳ هم جابه‌جا می‌شود. +3. **سقف ۹۰ روز** — محدودیت جستجوی تسک ۰۶. جلسات بیرون بازه `planned` می‌مانند و بیمار + بعداً رزرو می‌کند. پیام روشن اجباری است. + +## `findNearestInRange` — نزدیک‌ترین به ایده‌آل + +```php +$slots = $this->availability->search($req->withRange($min, $max)); +if ($slots === []) return null; + +usort($slots, fn($a, $b) => abs($a->start - $ideal) <=> abs($b->start - $ideal)); +return $slots[0]; +``` + +نزدیک‌ترین به ایده‌آل، نه اولین موجود. ۲۸ روز ایده‌آل است؛ روز ۲۱ (حداقل) از نظر +درمانی بدتر از روز ۲۷ است. + +## `same_as_previous` — اتصال به تسک ۰۶ + +```php +// SameAsPreviousPicker (تسک ۰۶) به یک ورودی نیاز دارد که تا حالا نداشت +public function pick(array $freeIds, PlannedRequirement $req, OccupancyIndex $idx, AppointmentPlan $plan): int +{ + $preferred = $plan->context()->preferredResourceIds ?? []; + foreach ($preferred as $id) { + if (in_array($id, $freeIds, true)) return $id; + } + return $this->fallback->pick($freeIds, $req, $idx, $plan); // least_gap +} +``` + +`preferredResourceIds` از `TreatmentCourse.preferredResource` می‌آید و در `PlanRequest` +حمل می‌شود. اگر منبع ترجیحی آزاد نبود، **رزرو رد نمی‌شود** — به `least_gap` برمی‌گردد. +اجبار به همان منبع یعنی بیمار دو هفته منتظر بماند. + +## پیشنهاد جلسهٔ بعدی + +``` +GET /treatment-course/{uuid}/next-slot-suggestion + ▼ +{ + "session_number": 4, + "params": { "energy": 18 }, + "ideal_date": "1405-06-01", + "range": { "min": "1405-05-25", "max": "1405-06-18" }, + "suggested_slots": [ … سه وقت نزدیک به ایده‌آل … ], + "warning": null +} +``` + +`warning` وقتی پر می‌شود که `now > lastCompleted + maxDays`: +«از حداکثر فاصلهٔ مجاز (۴۵ روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید.» + +## اتصال به `spacing` تسک ۰۹ + +قانون `spacing` و پروتکل دوره هر دو فاصله را محدود می‌کنند. **قانون برنده است** اگر +سخت‌گیرانه‌تر باشد: + +```php +$effectiveMin = max($course->getMinDays(), $policyMinDays ?? 0); +``` + +دلیل: پروتکل پیشنهاد بالینی است، قانون سیاست کلینیک. سیاست کلینیک نمی‌تواند شل‌تر شود. +این را در `docs/api/course.md` بنویس. + +## پنل ادمین + +- `CourseProtocolsPage.tsx` — پروتکل per سرویس + جدول پارامتر جلسات +- `TreatmentCoursePage.tsx` — نوار پیشرفت («۳ از ۸»)، جدول جلسات با وضعیت و تاریخ، + دکمهٔ «رزرو جلسهٔ بعدی» و «رزرو همهٔ جلسات» +- کارت دوره‌ها در `PatientDetailPage.tsx` +- نوار پیشرفت باید فاصلهٔ واقعی بین جلسات را هم نشان دهد (۲۸ · ۳۱ · ۲۶ روز) — کلینیک از + همان می‌فهمد بیمار منظم است یا نه diff --git a/docs/new_feture/taskes/task-12-treatment-course/database.md b/docs/new_feture/taskes/task-12-treatment-course/database.md new file mode 100644 index 00000000..bec0a479 --- /dev/null +++ b/docs/new_feture/taskes/task-12-treatment-course/database.md @@ -0,0 +1,138 @@ +# دیتابیس — تسک ۱۲ + +## `course_protocols` + +| ستون | نوع | توضیح | +|---|---|---| +| `id` | INT PK AI | | +| `uuid` | VARCHAR(36) UNIQUE | | +| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | +| `service_item_id` | INT NOT NULL | FK ON DELETE CASCADE | +| `session_count` | SMALLINT NOT NULL | | +| `min_days` | SMALLINT NOT NULL | | +| `ideal_days` | SMALLINT NOT NULL | | +| `max_days` | SMALLINT NOT NULL | | +| `prefer_same_resource` | TINYINT(1) NOT NULL DEFAULT 1 | | +| `active` | TINYINT(1) NOT NULL DEFAULT 1 | | +| `created_at`/`updated_at` | INT NOT NULL | | + +```sql +UNIQUE KEY uniq_protocol_service (service_item_id) -- یک پروتکل فعال per سرویس +KEY idx_protocols_tenant (entity_type, entity_id, active) +``` + +قید اپلیکیشنی: `min_days <= ideal_days <= max_days` و `session_count >= 2` +(دورهٔ یک‌جلسه‌ای همان نوبت تکی است). + +## `course_protocol_steps` + +```sql +CREATE TABLE course_protocol_steps ( + id INT PRIMARY KEY AUTO_INCREMENT, + protocol_id INT NOT NULL, + session_number SMALLINT NOT NULL, + params JSON NULL, -- {"energy": 12} — اسکالر + override_duration_minutes SMALLINT NULL, + UNIQUE KEY uniq_step (protocol_id, session_number), + CONSTRAINT fk_step_protocol FOREIGN KEY (protocol_id) REFERENCES course_protocols(id) ON DELETE CASCADE +); +``` + +فرزند aggregate با ریشهٔ `CourseProtocol`. + +## `treatment_courses` + +| ستون | نوع | توضیح | +|---|---|---| +| `id` | INT PK AI | | +| `uuid` | VARCHAR(36) UNIQUE | | +| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | +| `patient_record_id` | INT NOT NULL | FK ON DELETE RESTRICT | +| `service_item_id` | INT NOT NULL | FK ON DELETE RESTRICT | +| `protocol_id` | INT NOT NULL | FK ON DELETE RESTRICT | +| `session_count` | SMALLINT NOT NULL | **snapshot** | +| `min_days` | SMALLINT NOT NULL | **snapshot** | +| `ideal_days` | SMALLINT NOT NULL | **snapshot** | +| `max_days` | SMALLINT NOT NULL | **snapshot** | +| `patient_package_id` | INT NULL | FK → `patient_packages.id` ON DELETE SET NULL | +| `preferred_resource_id` | INT NULL | FK → `clinic_resources.id` ON DELETE SET NULL | +| `status` | VARCHAR(12) NOT NULL DEFAULT 'active' | `active`\|`completed`\|`abandoned` | +| `abandon_reason` | VARCHAR(255) NULL | | +| `started_at` | INT NOT NULL | | +| `completed_at` | INT NULL | | +| `created_at`/`updated_at` | INT NOT NULL | | + +```sql +KEY idx_courses_tenant (entity_type, entity_id, status, started_at) +KEY idx_courses_patient (patient_record_id, status) +UNIQUE KEY uniq_active_course (patient_record_id, service_item_id, status) +``` + +⚠️ `uniq_active_course` با MariaDB روی مقدار `status` کار نمی‌کند به شکلی که فقط +`active` را یکتا کند (چند ردیف `completed` مجازند). راه درست: **قید اپلیکیشنی** در +`CourseStarter` + کلید یکتای جزئی که MariaDB ندارد. + +جایگزین: یک ستون `active_course_key VARCHAR(64) NULL UNIQUE` با همان الگوی +`Appointment.active_slot_key`: + +```php +$this->activeCourseKey = $this->status === self::STATUS_ACTIVE + ? sprintf('%d:%d', $this->patient->getId(), $this->service->getId()) + : null; +``` + +الگوی اثبات‌شدهٔ همین کدبیس — استفاده‌اش کن، دوباره اختراع نکن. + +## `course_sessions` + +| ستون | نوع | توضیح | +|---|---|---| +| `id` | INT PK AI | | +| `uuid` | VARCHAR(36) UNIQUE | | +| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | +| `course_id` | INT NOT NULL | FK ON DELETE CASCADE | +| `session_number` | SMALLINT NOT NULL | | +| `params` | JSON NULL | **snapshot** از `course_protocol_steps` | +| `appointment_id` | INT NULL UNIQUE | FK ON DELETE SET NULL | +| `status` | VARCHAR(12) NOT NULL DEFAULT 'planned' | `planned`\|`booked`\|`completed`\|`skipped` | +| `completed_at` | INT NULL | | +| `created_at`/`updated_at` | INT NOT NULL | | + +```sql +UNIQUE KEY uniq_course_session (course_id, session_number) +UNIQUE KEY uniq_session_appointment (appointment_id) +KEY idx_sessions_tenant (entity_type, entity_id, status) +KEY idx_sessions_course (course_id, session_number) +``` + +`uniq_session_appointment`: یک نوبت به بیش از یک جلسهٔ دوره وصل نمی‌شود. + +## تغییر `appointments` + +```sql +ALTER TABLE appointments + ADD COLUMN course_session_id INT NULL, + ADD CONSTRAINT fk_appointments_course_session + FOREIGN KEY (course_session_id) REFERENCES course_sessions(id) ON DELETE SET NULL, + ADD KEY idx_appointments_course_session (course_session_id); +``` + +دو طرفه است (`course_sessions.appointment_id` هم وجود دارد) — عمدی: لیست نوبت‌های پنل +باید بدون JOIN بفهمد نوبت جزو دوره است، و صفحهٔ دوره باید بدون JOIN نوبت را پیدا کند. +هر دو در `CourseSessionLinker` **هم‌زمان** ست می‌شوند؛ هیچ جای دیگری ننویسد. + +## Migration + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +بدون backfill. + +## طبقه‌بندی tenant + +| جدول | وضعیت | +|---|---| +| `course_protocols`, `treatment_courses`, `course_sessions` | جفت tenant | +| `course_protocol_steps` | `AGGREGATE_CHILDREN` → ریشه `CourseProtocol` | diff --git a/docs/new_feture/taskes/task-12-treatment-course/implementation_notes.md b/docs/new_feture/taskes/task-12-treatment-course/implementation_notes.md new file mode 100644 index 00000000..d4e3c9be --- /dev/null +++ b/docs/new_feture/taskes/task-12-treatment-course/implementation_notes.md @@ -0,0 +1,173 @@ +# نکات پیاده‌سازی — تسک ۱۲ + +## ۱. `book-all` همه یا هیچ + +```php +$this->em->wrapInTransaction(function () { /* همهٔ hold ها و confirm ها */ }); +``` + +اگر جلسهٔ ۵ وقت نداشت، جلسات ۱ تا ۴ هم rollback می‌شوند. رزرو نیمه‌کاره یعنی بیمار +پیامک چهار نوبت می‌گیرد، فکر می‌کند دوره‌اش کامل رزرو شده، و چهار ماه بعد می‌فهمد نه. + +⚠️ ولی رویدادها (پیامک) با `DispatchAfterCurrentBusStamp` بعد از commit می‌روند (تسک ۰۷)، +پس در حالت rollback هیچ پیامکی نرفته. این وابستگی را جدی بگیر: اگر کسی در تسک ۰۷ +`dispatch` را قبل از commit گذاشته باشد، اینجا هشت پیامک اشتباه می‌رود. + +## ۲. لنگر متحرک، نه تاریخ ثابت + +```php +// ❌ فاصله از شروع دوره +$target = $course->getStartedAt() + $n * $idealDays * 86400; + +// ✅ فاصله از جلسهٔ قبلی +$anchor = $slot->start; // در هر تکرار حلقه به‌روز می‌شود +``` + +اگر جلسهٔ ۲ چهار روز دیرتر افتاد، جلسهٔ ۳ هم باید چهار روز جابه‌جا شود — وگرنه فاصلهٔ +۲ به ۳ می‌شود ۲۴ روز و از حداقل ۲۱ رد نمی‌شود ولی از نظر درمانی غلط است. + +## ۳. لنگر پیشنهاد بعدی: آخرین جلسهٔ **انجام‌شده** + +```php +private function lastCompletedAt(TreatmentCourse $course): ?int +{ + // status = completed، نه booked + return $this->sessionRepo->maxCompletedAt($course); +} +``` + +اگر از آخرین جلسهٔ `booked` حساب کنی، بیمار که نوبتش را لغو کرد یا نیامد، پیشنهاد بعدی +غلط می‌شود. فقط جلسهٔ واقعاً انجام‌شده لنگر است. + +جلسهٔ اول دوره: لنگر `time()` است، یا `started_at`. + +## ۴. snapshot پروتکل + +چهار فیلد فاصله و `params` هر جلسه کپی می‌شوند. تست: + +```php +// tests/Course/ProtocolSnapshotTest.php +$course = $this->starter->start($patient, $service); // protocol: 8 جلسه، 28 روز +$protocol->setIdealDays(14)->setSessionCount(4); +$this->em->flush(); + +self::assertSame(28, $course->getIdealDays()); +self::assertCount(8, $course->getSessions()); +``` + +قانون پنجم مستند. بدون این، کلینیک که پروتکل را عوض کند، دوره‌های در جریان ۵۰ بیمار +یک‌شبه بی‌معنا می‌شوند. + +## ۵. سقف ۹۰ روز و پیام روشن + +۸ جلسه × ۲۸ روز = ۲۲۴ روز. جستجوی تسک ۰۶ فقط ۹۰ روز است. پس `book-all` معمولاً +۳ تا ۴ جلسه رزرو می‌کند و بقیه `planned` می‌مانند. + +پاسخ باید صریح بگوید: + +```json +{ + "booked_count": 3, + "remaining_planned": 5, + "message": "۳ جلسهٔ نخست رزرو شد. بقیهٔ جلسات خارج از بازهٔ مجاز رزرو (۹۰ روز) هستند و بعداً قابل رزروند." +} +``` + +بدون این پیام، کاربر فکر می‌کند سیستم خراب است. + +## ۶. `same_as_previous` اجباری نیست + +```php +foreach ($preferred as $id) { + if (in_array($id, $freeIds, true)) return $id; +} +return $this->fallback->pick(…); // ← نه throw +``` + +اگر اپراتور جلسهٔ اول مرخصی است، بیمار نباید دو هفته منتظر بماند. ترجیح، نه الزام. +اگر کلینیکی الزام واقعی داشت، آن یک قانون `resource` با `specific_resource` است (تسک ۰۹). + +## ۷. تعامل با `spacing` تسک ۰۹ + +```php +$effectiveMin = max($course->getMinDays(), $this->policies->minDaysFor($ctx) ?? 0); +$effectiveMax = min($course->getMaxDays(), $this->policies->maxDaysFor($ctx) ?? PHP_INT_MAX); +if ($effectiveMin > $effectiveMax) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, + 'قوانین کلینیک با پروتکل این دوره سازگار نیستند', 422); +} +``` + +سخت‌گیرانه‌تر برنده. و اگر ترکیبشان بازهٔ تهی ساخت، خطای روشن — نه جستجوی بی‌نتیجه. + +## ۸. اتصال به پکیج + +اگر `TreatmentCourse.package` پر باشد، هر `confirm` جلسه یک واحد اعتبار مصرف می‌کند +(تسک ۱۱). `book-all` هشت جلسه یعنی هشت مصرف — پس پیش از شروع: + +```php +if ($course->getPackage() !== null) { + $balance = $this->ledger->balance($course->getPackage()); + if ($balance < count($plannedSessions)) { + // خطا نیست — هشدار + $result->addWarning(sprintf('اعتبار پکیج (%d) کمتر از جلسات باقی‌مانده (%d) است', $balance, $count)); + } +} +``` + +هشدار نه خطا: بیمار می‌تواند بقیه را نقدی بپردازد. + +## ۹. edge case ها + +| حالت | رفتار درست | +|---|---| +| دورهٔ فعال دوم برای همان سرویس | `422` با uuid دورهٔ موجود در `meta` | +| لغو جلسهٔ وسط دوره | `CourseSession` → `planned`، `appointment_id` → NULL، بقیه دست‌نخورده | +| عدم حضور (`no_show`) در جلسه | `CourseSession` → `skipped`؛ لنگر همان جلسهٔ قبلی می‌ماند | +| جلسهٔ آخر `completed` | دوره → `completed` خودکار + رویداد `CourseCompleted` | +| `abandon` دورهٔ نیمه‌کاره | جلسات `booked` **لغو نمی‌شوند** خودکار — پاسخ شامل تعدادشان و لینک | +| بیمار ۶۰ روز غیبت (> max) | `warning` در پیشنهاد؛ رزرو **مسدود نمی‌شود** | +| پروتکل با `session_count = 1` | `422` — همان نوبت تکی است | +| `params` با مقدار آرایه | `422` — فقط اسکالر | +| حذف پروتکلی که دورهٔ فعال دارد | `422` (FK RESTRICT) — `active=false` مسیر درست | +| دوره روی سرویسی که `bookable=false` شد | جلسات موجود می‌مانند؛ جلسهٔ جدید رزرو نمی‌شود، پیام روشن | + +سطر «abandon» عمدی است: لغو خودکار هشت نوبت آیندهٔ بیمار بدون تأیید صریح، عملی +برگشت‌ناپذیر روی داده و ظرفیت کلینیک است. کاربر باید خودش تصمیم بگیرد. + +## ۱۰. تست + +``` +tests/Course/CourseStarterTest.php + - ۸ جلسهٔ planned با params درست + - سرویس بدون پروتکل → 422 + - دورهٔ فعال دوم → 422 با meta +tests/Course/ProtocolSnapshotTest.php ← ⭐ قانون پنجم +tests/Course/CourseSchedulerTest.php ← ⭐ + - book-all: لنگر متحرک (فاصله از جلسهٔ قبلی، نه از شروع) + - نزدیک‌ترین به ایده‌آل انتخاب می‌شود، نه اولین + - شکست جلسهٔ N → rollback همهٔ ۱..N-1 + - سقف ۹۰ روز → جلسات باقی planned + پیام +tests/Course/NextSuggestionTest.php + - لنگر = آخرین completed، نه booked + - عبور از max → warning +tests/Course/CourseProgressTest.php + - completed/total/next_session_number/next_params +tests/Course/SameResourcePreferenceTest.php + - منبع جلسهٔ اول ترجیح داده می‌شود + - منبع مشغول → fallback به least_gap، بدون خطا +tests/Course/CoursePolicyInteractionTest.php + - قانون سخت‌گیرانه‌تر برنده + - بازهٔ تهی → 422 روشن +tests/Course/CoursePackageTest.php + - هر جلسه یک واحد مصرف + - اعتبار کمتر از جلسات → warning نه error +tests/Course/CourseLifecycleTest.php + - لغو وسط دوره · no_show → skipped · جلسهٔ آخر → completed خودکار + - abandon نوبت‌های booked را لغو نمی‌کند +``` + +## ۱۱. مستندات + +`docs/api/course.md` بساز. حتماً بنویس: قاعدهٔ «سخت‌گیرانه‌تر برنده» بین پروتکل و قانون، +رفتار سقف ۹۰ روز، و اینکه `abandon` نوبت‌ها را لغو نمی‌کند. diff --git a/docs/new_feture/taskes/task-12-treatment-course/task.md b/docs/new_feture/taskes/task-12-treatment-course/task.md new file mode 100644 index 00000000..b232e468 --- /dev/null +++ b/docs/new_feture/taskes/task-12-treatment-course/task.md @@ -0,0 +1,78 @@ +# تسک ۱۲ — دوره درمان + +**فاز:** ۳ (کسب‌وکار) · **وابستگی:** ۰۷، ۱۱ · **زمان:** ۱۶-۲۰ ساعت + +--- + +## هدف + +مستند بند ۱۳: «لیزر معمولاً شش تا هشت جلسه است. طراحی قبلی فقط نوبت تکی می‌شناخت، در +حالی که این حالت اصلی کسب‌وکار است.» + +## وضعیت فعلی + +هیچ مفهومی از دوره وجود ندارد. `PatientSession` وجود دارد ولی «مراجعهٔ انجام‌شده» است، +نه جلسهٔ برنامه‌ریزی‌شدهٔ یک دوره. تسک ۰۴ ستون `session_count` را به `ServiceItem` اضافه +کرده ولی هیچ رفتاری به آن وصل نیست. + +## دامنه + +**هست:** +- `CourseProtocol` — پروتکل دوره: تعداد جلسه، فاصلهٔ حداقل/ایده‌آل/حداکثر، پارامتر هر جلسه +- `TreatmentCourse` — دورهٔ یک بیمار +- `CourseSession` — جلسات دوره (برنامه‌ریزی‌شده یا انجام‌شده) +- رزرو کل دوره یکجا، یا جلسه‌به‌جلسه +- پیشنهاد تاریخ جلسهٔ بعدی +- هشدار عبور از حداکثر فاصله +- ردیابی پیشرفت («جلسهٔ ۳ از ۸») +- ترجیح **همان منبع قبلی** (استراتژی `same_as_previous` تسک ۰۶) + +**نیست:** موتور قانون فاصله (تسک ۰۹ — `spacing` از آن استفاده می‌شود)، پکیج (تسک ۱۱ — +اتصال دارد ولی مستقل است). + +## Endpoint ها + +| متد | مسیر | توضیح | +|---|---|---| +| GET/POST | `/api/v1/course-protocols` | پروتکل دوره per سرویس | +| GET/PATCH/DELETE | `/api/v1/course-protocol/{uuid}` | | +| POST | `/api/v1/treatment-course` | شروع دوره برای بیمار | +| GET | `/api/v1/treatment-course/{uuid}` | جزئیات + جلسات + پیشرفت | +| GET | `/api/v1/patient/{uuid}/courses` | دوره‌های بیمار | +| POST | `/api/v1/treatment-course/{uuid}/book-all` | رزرو همهٔ جلسات باقی‌مانده | +| GET | `/api/v1/treatment-course/{uuid}/next-slot-suggestion` | پیشنهاد تاریخ جلسهٔ بعدی | +| POST | `/api/v1/treatment-course/{uuid}/abandon` | رهاکردن دوره با دلیل | + +## معیار پذیرش + +- ✅ موفق: پروتکل «لیزر فول‌بادی: ۸ جلسه، حداقل ۲۱ / ایده‌آل ۲۸ / حداکثر ۴۵ روز، + سطح انرژی ۱۲،۱۴،۱۶،۱۸،۲۰،۲۰،۲۲،۲۲» تعریف می‌شود → + `POST /treatment-course` هشت `CourseSession` با وضعیت `planned` می‌سازد و پارامتر هر + جلسه را از پروتکل کپی می‌کند. +- ✅ موفق: `POST /book-all` → هشت نوبت با فاصلهٔ ایده‌آل ۲۸ روز رزرو می‌شود؛ هر جلسه به + `CourseSession` متناظر لینک می‌شود. اگر روز ایده‌آل ظرفیت نداشت، **نزدیک‌ترین روز داخل + بازهٔ حداقل..حداکثر** انتخاب می‌شود. +- ✅ موفق: بعد از انجام جلسهٔ ۳، `GET /next-slot-suggestion` تاریخ ۲۸ روز بعد از **جلسهٔ ۳** + را پیشنهاد می‌دهد (نه از شروع دوره). +- ✅ موفق: بیمار ۵۰ روز از جلسهٔ قبل گذشته → پاسخ شامل + `warning: 'از حداکثر فاصلهٔ مجاز (۴۵ روز) عبور شده است'`. +- ✅ موفق: جلسهٔ ۲ به بعد، `same_as_previous` اپراتور جلسهٔ ۱ را انتخاب می‌کند اگر آزاد باشد. +- ✅ موفق: پیشرفت — `GET /treatment-course/{uuid}` می‌دهد + `{ completed: 3, total: 8, next_session_number: 4, next_params: { energy: 18 } }`. +- ❌ خطا: `book-all` وقتی برای یکی از جلسات هیچ وقتی نیست → **هیچ‌کدام رزرو نمی‌شود**، + `422` با شمارهٔ جلسهٔ مشکل‌دار. رزرو نیمه‌کاره ممنوع. +- ❌ خطا: شروع دوره برای سرویسی که پروتکل ندارد → `422`. +- ⚠️ مرزی: بیمار دورهٔ فعال دیگری برای همان سرویس دارد → `422` با لینک به دورهٔ موجود. +- ⚠️ مرزی: لغو یک جلسهٔ وسط دوره → آن `CourseSession` به `planned` برمی‌گردد، بقیه + دست‌نخورده؛ پیشنهاد بعدی مبنایش آخرین جلسهٔ **انجام‌شده** است. +- ⚠️ مرزی: دورهٔ متصل به پکیج (تسک ۱۱) → هر جلسه یک واحد اعتبار مصرف می‌کند. +- ⚠️ مرزی: تعداد جلسات پروتکل تغییر کرد → دوره‌های فعال دست‌نخورده (snapshot). +- ⚠️ مرزی: `book-all` بیشتر از بازهٔ ۹۰ روزهٔ مجاز (۸ جلسه × ۲۸ روز = ۲۲۴ روز) → + فقط جلساتی که در ۹۰ روز جا می‌شوند رزرو شوند، بقیه `planned` بمانند + پیام روشن. + +## خروجی + +- `src/Course/` +- `assets/admin/pages/CourseProtocolsPage.tsx` + `TreatmentCoursePage.tsx` +- کارت «دوره‌های درمان» در `PatientDetailPage.tsx` +- `docs/api/course.md` diff --git a/docs/new_feture/taskes/task-12-treatment-course/user_flow.md b/docs/new_feture/taskes/task-12-treatment-course/user_flow.md new file mode 100644 index 00000000..83ebcfa5 --- /dev/null +++ b/docs/new_feture/taskes/task-12-treatment-course/user_flow.md @@ -0,0 +1,149 @@ +# جریان کاربری — تسک ۱۲ + +## الف) کلینیک پروتکل دوره را تعریف می‌کند + +``` +پنل › خدمات › لیزر فول‌بادی › تب «پروتکل دوره» + │ + تعداد جلسات: ۸ + فاصلهٔ حداقل / ایده‌آل / حداکثر: ۲۱ / ۲۸ / ۴۵ روز + ☑ تلاش برای انتخاب همان اپراتور جلسات قبل + │ + پارامتر هر جلسه: + ┌──────┬──────────────┬────────────┐ + │ جلسه │ سطح انرژی │ مدت خاص │ + ├──────┼──────────────┼────────────┤ + │ ۱ │ ۱۲ │ ۷۵ دقیقه │ ← جلسهٔ اول طولانی‌تر (تست و آموزش) + │ ۲ │ ۱۴ │ — │ + │ … │ … │ — │ + │ ۸ │ ۲۲ │ — │ + └──────┴──────────────┴────────────┘ + ▼ +POST /api/v1/course-protocols +``` + +--- + +## ب) شروع دوره و رزرو کل آن + +``` +پنل › بیمار › «شروع دورهٔ درمان» + سرویس: لیزر فول‌بادی (پروتکل خودکار بارگذاری می‌شود) + پکیج: «۶ جلسه لیزر» ▾ (اختیاری — تسک ۱۱) + ▼ +POST /api/v1/treatment-course + → ۸ CourseSession با وضعیت «برنامه‌ریزی‌شده» + ⚠️ «اعتبار پکیج (۶) کمتر از جلسات دوره (۸) است» + ▼ +صفحهٔ دوره: + +┌────────────────────────────────────────────────────────┐ +│ لیزر فول‌بادی — ز. احمدی ● دورهٔ فعال │ +│ ●●●○○○○○ ۳ از ۸ جلسه │ +├──────┬─────────────┬────────┬─────────┬────────────────┤ +│ جلسه │ تاریخ │ فاصله │ انرژی │ وضعیت │ +├──────┼─────────────┼────────┼─────────┼────────────────┤ +│ ۱ │ ۱۴۰۵/۰۳/۰۵ │ — │ ۱۲ │ ✔ انجام‌شده │ +│ ۲ │ ۱۴۰۵/۰۴/۰۲ │ ۲۸ روز │ ۱۴ │ ✔ انجام‌شده │ +│ ۳ │ ۱۴۰۵/۰۵/۰۳ │ ۳۱ روز │ ۱۶ │ ✔ انجام‌شده │ +│ ۴ │ ۱۴۰۵/۰۵/۳۱ │ ۲۸ روز │ ۱۸ │ ◷ رزروشده │ +│ ۵ │ — │ — │ ۲۰ │ ○ برنامه‌ریزی‌شده│ +└──────┴─────────────┴────────┴─────────┴────────────────┘ + [رزرو جلسهٔ بعدی] [رزرو همهٔ جلسات باقی‌مانده] +``` + +ستون «فاصله» عدد واقعی است، نه ایده‌آل. کلینیک از آن می‌فهمد بیمار منظم است یا نه. + +``` +«رزرو همهٔ جلسات باقی‌مانده» + ▼ +POST /treatment-course/{uuid}/book-all + │ + ├─ لنگر: تاریخ جلسهٔ ۴ (آخرین رزروشده) + ├─ جلسهٔ ۵: هدف ۲۸ روز بعد → نزدیک‌ترین وقت در بازهٔ ۲۱..۴۵ روز + ├─ جلسهٔ ۶: لنگر = تاریخ واقعی جلسهٔ ۵ + ├─ جلسهٔ ۷: خارج از ۹۰ روز → planned می‌ماند + └─ همه در یک تراکنش + ▼ +200 { "booked_count": 2, "remaining_planned": 2, + "message": "۲ جلسه رزرو شد. جلسات ۷ و ۸ خارج از بازهٔ مجاز رزرو (۹۰ روز) هستند." } +``` + +--- + +## ج) پیشنهاد جلسهٔ بعدی بعد از هر جلسه + +``` +منشی وضعیت جلسهٔ ۳ را «انجام‌شده» می‌کند + ▼ +سیستم خودکار بنر نشان می‌دهد: + +┌────────────────────────────────────────────────┐ +│ 📅 جلسهٔ بعدی این بیمار │ +│ جلسهٔ ۴ از ۸ · سطح انرژی: ۱۸ │ +│ تاریخ پیشنهادی: ۱۴۰۵/۰۵/۳۱ (۲۸ روز بعد) │ +│ بازهٔ مجاز: ۱۴۰۵/۰۵/۲۴ تا ۱۴۰۵/۰۶/۱۷ │ +│ │ +│ ۰۹:۰۰ ▸ ۱۱:۳۰ ▸ ۱۴:۰۰ ▸ │ +│ [رزرو با اپراتور مریم]│ +└────────────────────────────────────────────────┘ +``` + +«اپراتور مریم» چون جلسات ۱ تا ۳ با او بود (`same_as_previous`). اگر آزاد نباشد، نامش +عوض می‌شود و رزرو رد نمی‌شود. + +--- + +## د) بیمار دیر می‌آید — عبور از حداکثر فاصله + +``` +۶۰ روز از جلسهٔ ۳ گذشته (حداکثر ۴۵ روز) + ▼ +GET /treatment-course/{uuid}/next-slot-suggestion + ▼ +{ + "session_number": 4, + "warning": "از حداکثر فاصلهٔ مجاز (۴۵ روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید.", + "suggested_slots": [ … ] +} +``` + +در UI یک نوار زرد بالای پیشنهادها. **رزرو مسدود نمی‌شود** — تصمیم بالینی است، نه فنی. +اگر کلینیکی می‌خواهد واقعاً مسدود شود، آن یک قانون `eligibility` است (تسک ۰۹). + +--- + +## ه) لغو جلسهٔ وسط دوره + +``` +جلسهٔ ۴ لغو می‌شود + ▼ +├─ Appointment → cancelled_* +├─ CourseSession ۴ → planned ، appointment_id → NULL +├─ اعتبار پکیج → refund +1 (تسک ۱۱) +├─ اشغال منابع → released (تسک ۰۷) +└─ جلسات ۵..۸ دست‌نخورده + ▼ +پیشنهاد بعدی: لنگر همان جلسهٔ ۳ (آخرین انجام‌شده) +``` + +جلسات بعدی خودکار جابه‌جا **نمی‌شوند**. جابه‌جایی زنجیره‌ای پنج نوبت آیندهٔ بیمار بدون +تأیید، همان مسئلهٔ `abandon` است: عمل برگشت‌ناپذیر روی داده و ظرفیت. + +پنل یک پیشنهاد نشان می‌دهد: «فاصلهٔ جلسات ۵ تا ۸ با لغو این جلسه از پروتکل خارج شد. +[بازچینی جلسات باقی‌مانده]» — با یک کلیک صریح. + +--- + +## و) پایان دوره + +``` +جلسهٔ ۸ → completed + ▼ +├─ TreatmentCourse → completed ، completed_at = now +├─ active_course_key → NULL (بیمار می‌تواند دورهٔ جدید شروع کند) +└─ رویداد CourseCompleted (تسک ۱۴) + ▼ +کارت بیمار: «دورهٔ لیزر فول‌بادی تکمیل شد — ۸ جلسه در ۲۳۱ روز» + [شروع دورهٔ نگهدارنده] +``` diff --git a/docs/new_feture/taskes/task-13-cancellation-waitlist/architecture.md b/docs/new_feture/taskes/task-13-cancellation-waitlist/architecture.md new file mode 100644 index 00000000..674b3a9c --- /dev/null +++ b/docs/new_feture/taskes/task-13-cancellation-waitlist/architecture.md @@ -0,0 +1,191 @@ +# معماری — تسک ۱۳ + +## ساختار فایل + +``` +src/Cancellation/ +├── Entity/{CancellationPolicy, NoShowRecord}.php +├── Service/ +│ ├── CancellationPolicyResolver.php # اختصاصی‌ترین سیاست +│ ├── PenaltyCalculator.php +│ ├── CancellationService.php # ارکستراتور لغو +│ └── NoShowTracker.php +└── Controller/CancellationController.php + +src/Waitlist/ +├── Entity/WaitlistEntry.php +├── Service/ +│ ├── WaitlistService.php +│ └── WaitlistMatcher.php # تطبیق ظرفیت آزاد با درخواست‌ها +├── MessageHandler/NotifyWaitlistHandler.php +└── Controller/WaitlistController.php +``` + +## `CancellationPolicy` + +```php +class CancellationPolicy +{ + use TenantOwnedTrait; + + private ?ServiceItem $service = null; // null = پیش‌فرض محیط + private int $freeWindowHours = 24; // تا چند ساعت قبل، رایگان + private string $penaltyMode = 'percent'; // none | percent | fixed + private int $penaltyValue = 0; + private bool $depositRefundable = false; // پس از پنجرهٔ رایگان + private bool $creditRefundable = true; // اعتبار پکیج (تسک ۱۱) + private int $noShowThreshold = 3; // بعد از چند بار، برچسب پرریسک + private ?string $riskTagUuid = null; // TenantTag موجود +} +``` + +`riskTagUuid` به `TenantTag` موجود اشاره می‌کند، نه یک ستون `is_risky` روی بیمار. +دلیل: سیستم برچسب از قبل هست، در `DiscountRule.target_tag_uuid` و `FieldRegistry` +(`patient.tags`) استفاده می‌شود، و قانون `eligibility` تسک ۰۹ می‌تواند رویش شرط بگذارد. +ستون بولین جدید یعنی یک مفهوم موازی که هیچ‌کدام از آن‌ها نمی‌بینند. + +## `PenaltyCalculator` + +```php +public function forCancellation(Appointment $appt, string $by, int $now): PenaltyResult +{ + // لغو توسط کلینیک: هرگز جریمه + if ($by === Appointment::STATUS_CANCELLED_BY_DOCTOR) { + return PenaltyResult::free(); + } + + $policy = $this->resolver->forAppointment($appt); + $hoursLeft = intdiv($appt->getSlotStart() - $now, 3600); + + if ($hoursLeft >= $policy->getFreeWindowHours()) { + return PenaltyResult::free(); + } + + $paid = $this->paymentRepo->totalPaidFor($appt); + $penalty = match ($policy->getPenaltyMode()) { + 'percent' => intdiv($this->snapshotFinal($appt) * $policy->getPenaltyValue(), 100), + 'fixed' => $policy->getPenaltyValue(), + default => 0, + }; + + return new PenaltyResult( + penaltyRials: min($penalty, $paid), // ← سقف: مبلغ پرداختی + depositRefundable: $policy->isDepositRefundable(), + creditRefundable: $policy->isCreditRefundable(), + ); +} +``` + +`min($penalty, $paid)` مهم است: جریمهٔ بیشتر از پرداختی یعنی بدهی — که مسئلهٔ حسابداری +است، نه لغو. برای نوبت نقدی (`$paid = 0`) جریمه صفر می‌شود و در پاسخ یک +`note: 'جریمه در مراجعهٔ بعدی محاسبه می‌شود'` می‌آید. + +## `cancellation-preview` — اجباری پیش از لغو + +``` +GET /appointment/{uuid}/cancellation-preview + ▼ +{ + "hours_left": 6, + "free_window_hours": 24, + "penalty_rials": 1200000, + "deposit_refundable": false, + "credit_refundable": true, + "refund_rials": 800000, + "message": "لغو در کمتر از ۲۴ ساعت باقی‌مانده ۵۰٪ جریمه دارد." +} +``` + +بدون این endpoint، کاربر لغو می‌کند و بعد جریمه می‌بیند. UI باید preview را در +`ConfirmDialog` نشان دهد. + +## `CancellationService` — ترتیب + +```php +$this->em->wrapInTransaction(function () use ($appt, $by, $reason) { + $penalty = $this->penalty->forCancellation($appt, $by, time()); + + $this->transition($appt, $by); // ۱ وضعیت + $this->occupancyWriter->release($appt); // ۲ آزادسازی منابع (تسک ۰۷) + $this->refundDeposit($appt, $penalty); // ۳ بیعانه + $this->chargePenalty($appt, $penalty); // ۴ جریمه در wallet_transactions + $this->refundCredit($appt, $penalty); // ۵ اعتبار پکیج (تسک ۱۱) + $this->courseLinker->releaseSession($appt); // ۶ جلسهٔ دوره (تسک ۱۲) + $this->events->dispatch(new AppointmentCancelled($appt->getUuid())); // ۷ بعد از commit +}); +``` + +مرحلهٔ ۷ رویداد است که `NotifyWaitlistHandler` به آن گوش می‌دهد — لیست انتظار async +مطلع می‌شود، نه در تراکنش لغو. + +## `WaitlistEntry` + +```php +class WaitlistEntry +{ + use TenantOwnedTrait; + private PatientRecord $patient; + private ServiceItem $service; + private ?Branch $branch = null; + private int $desiredFrom; // بازهٔ دلخواه + private int $desiredTo; + private array $preferredDayParts = []; // ['morning','afternoon','evening'] + private int $priority = 0; + private ?int $notifiedAt = null; + private int $notifyCount = 0; + private string $status = 'waiting'; // waiting | notified | converted | expired +} +``` + +## `WaitlistMatcher` — همه مطلع می‌شوند، صف انحصاری نه + +```php +public function onCapacityFreed(int $from, int $to, ServiceItem $service, ?Branch $branch): void +{ + $matches = $this->repo->findMatching($from, $to, $service, $branch, limit: 10); + foreach ($matches as $entry) { + $this->bus->dispatch(new NotifyWaitlistMessage($entry->getUuid())); + } +} +``` + +**تصمیم: broadcast، نه قفل انحصاری.** + +| گزینه | مشکل | +|---|---| +| قفل انحصاری برای نفر اول (مثلاً ۳۰ دقیقه) | نفر اول ممکن است شب باشد و پیام را نبیند؛ ظرفیت ۳۰ دقیقه بلوکه و بعد نفر دوم، و همین‌طور — یک ساعت خالی می‌تواند سه ساعت معطل بماند | +| **اطلاع به همه، اولین رزروکننده می‌برد** ✅ | ظرفیت سریع پر می‌شود؛ هزینه‌اش این است که چند نفر پیام می‌گیرند و جا نیست | + +هزینهٔ گزینهٔ دوم با یک جملهٔ صریح در پیامک قابل مدیریت است: +«یک وقت آزاد شد. اولین نفری که رزرو کند آن را می‌گیرد.» + +سقف ۱۰ نفر برای جلوگیری از انبوه پیامک. `priority` ترتیب را تعیین می‌کند (بیمار وفادار +یا پکیج‌دار می‌تواند اولویت بگیرد). + +## `NoShowTracker` + +```php +public function record(Appointment $appt): void +{ + $this->em->persist(new NoShowRecord($appt)); + $count = $this->repo->countForPatient($appt->patientRecord(), since: $this->windowStart()); + $policy = $this->resolver->forTenant($appt->tenantPair()); + + if ($count >= $policy->getNoShowThreshold() && $policy->getRiskTagUuid() !== null) { + $this->tagService->attach($appt->patientRecord(), $policy->getRiskTagUuid()); + } +} +``` + +پنجرهٔ شمارش: ۱۲ ماه گذشته (نه کل عمر). بیماری که سه سال پیش سه بار نیامده، امروز +پرریسک نیست. + +## پنل ادمین + +- `CancellationPolicyPage.tsx` — سیاست محیط + جدول override سرویس‌ها +- `WaitlistPage.tsx` — لیست درخواست‌ها با فیلتر بازه/سرویس، و تب «قابل تطبیق» که + ظرفیت‌های آزاد شده و کاندیدهایشان را نشان می‌دهد +- در `AppointmentDetailPage.tsx` دکمهٔ لغو → `ConfirmDialog` با محتوای preview +- در `PatientDetailPage.tsx` نشان «پرریسک» + شمارش عدم حضور +- `ReserveAppointmentsPage.tsx` موجود می‌ماند (نوبت رزرو روزی) — مفهوم متفاوتی است و + ادغامشان با لیست انتظار خارج از دامنهٔ این تسک است diff --git a/docs/new_feture/taskes/task-13-cancellation-waitlist/database.md b/docs/new_feture/taskes/task-13-cancellation-waitlist/database.md new file mode 100644 index 00000000..f0fb9337 --- /dev/null +++ b/docs/new_feture/taskes/task-13-cancellation-waitlist/database.md @@ -0,0 +1,141 @@ +# دیتابیس — تسک ۱۳ + +## `cancellation_policies` + +| ستون | نوع | توضیح | +|---|---|---| +| `id` | INT PK AI | | +| `uuid` | VARCHAR(36) UNIQUE | | +| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | +| `service_item_id` | INT NULL | NULL = پیش‌فرض محیط — FK ON DELETE CASCADE | +| `free_window_hours` | SMALLINT NOT NULL DEFAULT 24 | | +| `penalty_mode` | VARCHAR(10) NOT NULL DEFAULT 'none' | `none`\|`percent`\|`fixed` | +| `penalty_value` | INT NOT NULL DEFAULT 0 | درصد ۰..۱۰۰ یا ریال | +| `deposit_refundable` | TINYINT(1) NOT NULL DEFAULT 0 | پس از پنجرهٔ رایگان | +| `credit_refundable` | TINYINT(1) NOT NULL DEFAULT 1 | اعتبار پکیج | +| `no_show_threshold` | SMALLINT NOT NULL DEFAULT 3 | | +| `risk_tag_uuid` | VARCHAR(36) NULL | ارجاع به `tenant_tags.uuid` — بدون FK، الگوی موجود پروژه | +| `active` | TINYINT(1) NOT NULL DEFAULT 1 | | +| `created_at`/`updated_at` | INT NOT NULL | | + +```sql +UNIQUE KEY uniq_cancel_policy_scope (entity_type, entity_id, service_item_id) +KEY idx_cancel_policies_tenant (entity_type, entity_id, active) +``` + +`risk_tag_uuid` بدون FK — همان الگوی `DiscountRule.target_tag_uuid` موجود. + +## `no_show_records` + +```sql +CREATE TABLE no_show_records ( + id INT PRIMARY KEY AUTO_INCREMENT, + uuid VARCHAR(36) NOT NULL UNIQUE, + entity_type VARCHAR(10) NOT NULL, + entity_id INT NOT NULL, + patient_record_id INT NOT NULL, + appointment_id INT NOT NULL, + recorded_at INT NOT NULL, + recorded_by INT NULL, + UNIQUE KEY uniq_no_show_appointment (appointment_id), -- یک بار per نوبت + KEY idx_no_show_patient (patient_record_id, recorded_at), -- کوئری شمارش ۱۲ ماه + KEY idx_no_show_tenant (entity_type, entity_id, recorded_at), + CONSTRAINT fk_ns_patient FOREIGN KEY (patient_record_id) REFERENCES patient_records(id) ON DELETE CASCADE, + CONSTRAINT fk_ns_appt FOREIGN KEY (appointment_id) REFERENCES appointments(id) ON DELETE CASCADE +); +``` + +جدول جدا و نه یک ستون شمارنده روی بیمار — همان استدلال دفتر اعتبار تسک ۱۱: +شمارنده، «چه زمانی و کدام نوبت» را از دست می‌دهد و پنجرهٔ ۱۲ ماهه غیرقابل محاسبه می‌شود. + +`uniq_no_show_appointment`: تغییر وضعیت به `no_show` ممکن است دوبار اتفاق بیفتد +(idempotency)؛ رکورد دوم ثبت نشود. + +## `waitlist_entries` + +| ستون | نوع | توضیح | +|---|---|---| +| `id` | INT PK AI | | +| `uuid` | VARCHAR(36) UNIQUE | | +| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | +| `patient_record_id` | INT NOT NULL | FK ON DELETE CASCADE | +| `service_item_id` | INT NOT NULL | FK ON DELETE CASCADE | +| `branch_id` | INT NULL | FK ON DELETE CASCADE | +| `desired_from` | INT NOT NULL | | +| `desired_to` | INT NOT NULL | | +| `preferred_day_parts` | JSON NULL | `["morning","evening"]` | +| `priority` | SMALLINT NOT NULL DEFAULT 0 | | +| `status` | VARCHAR(12) NOT NULL DEFAULT 'waiting' | `waiting`\|`notified`\|`converted`\|`expired` | +| `notified_at` | INT NULL | آخرین اطلاع | +| `notify_count` | SMALLINT NOT NULL DEFAULT 0 | سقف برای جلوگیری از اسپم | +| `converted_appointment_id` | INT NULL | FK ON DELETE SET NULL | +| `created_at`/`updated_at` | INT NOT NULL | | + +```sql +KEY idx_waitlist_match (service_item_id, branch_id, status, desired_from, desired_to) +KEY idx_waitlist_tenant (entity_type, entity_id, status, created_at) +KEY idx_waitlist_patient (patient_record_id, status) +``` + +`idx_waitlist_match` کوئری داغ است: «چه کسانی منتظر این سرویس در این بازه‌اند؟» + +```sql +SELECT * FROM waitlist_entries +WHERE service_item_id = ? AND (branch_id = ? OR branch_id IS NULL) + AND status = 'waiting' + AND desired_from <= :freedEnd AND desired_to >= :freedStart +ORDER BY priority DESC, created_at ASC +LIMIT 10 +``` + +`preferred_day_parts` در PHP فیلتر می‌شود (JSON قابل ایندکس مطمئن نیست و نتیجه ≤ ۱۰ ردیف است). + +## هیچ تغییری در `appointments` + +وضعیت‌های `cancelled_by_user`, `cancelled_by_doctor`, `no_show` از قبل هستند. +`deposit_amount_rials` هم. + +## جریمه در دفتر مالی موجود + +جدول جدید ندارد. `WalletTransaction` موجود استفاده می‌شود: + +```php +new WalletTransaction( + user: $appt->getUser(), + amountRials: -$penalty, + kind: 'cancellation_penalty', // ← مقدار جدید در enum موجود + reference: $appt->getUuid(), +); +$tx->setRecordedEntity($appt->getEntityType(), $appt->getEntityId()); // per-محیط، طبق tenancy.md +``` + +`setRecordedEntity` اجباری است، وگرنه جریمه در دفتر همهٔ محیط‌ها دیده می‌شود +(`docs/architecture/tenancy.md`، بخش کیف پول). + +## Migration + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +ddev exec php bin/console app:cancellation:seed-default-policy --force +``` + +`seed-default-policy` برای هر محیط یک سیاست پیش‌فرض محافظه‌کار می‌سازد: +`free_window_hours=24, penalty_mode=none, deposit_refundable=true, credit_refundable=true`. + +**پیش‌فرض بدون جریمه** عمدی است: فعال شدن ناگهانی جریمه روی بیماران موجود، شکایت است. +کلینیک خودش باید فعالش کند. + +## پاکسازی + +```bash +ddev exec php bin/console app:waitlist:expire # روزانه +``` + +ورودی‌هایی که `desired_to` گذشته → `status='expired'`. + +## طبقه‌بندی tenant + +| جدول | وضعیت | +|---|---| +| `cancellation_policies`, `no_show_records`, `waitlist_entries` | جفت tenant | diff --git a/docs/new_feture/taskes/task-13-cancellation-waitlist/implementation_notes.md b/docs/new_feture/taskes/task-13-cancellation-waitlist/implementation_notes.md new file mode 100644 index 00000000..330b6991 --- /dev/null +++ b/docs/new_feture/taskes/task-13-cancellation-waitlist/implementation_notes.md @@ -0,0 +1,157 @@ +# نکات پیاده‌سازی — تسک ۱۳ + +## ۱. پیش‌فرض بدون جریمه + +`seed-default-policy` باید `penalty_mode='none'` بسازد. اگر پیش‌فرض جریمه‌دار باشد، +لحظهٔ deploy همهٔ بیماران با نوبت آیندهٔ نزدیک مشمول جریمه می‌شوند و کلینیک خبر ندارد. + +فعال‌سازی جریمه یک تصمیم کسب‌وکاری صریح است، نه پیش‌فرض فنی. + +## ۲. لغو توسط کلینیک هرگز جریمه ندارد + +```php +if ($by === Appointment::STATUS_CANCELLED_BY_DOCTOR) { + return PenaltyResult::free(); // ← اول از همه، پیش از هر محاسبه +} +``` + +این شرط باید **اولین** خط باشد. اگر بعد از محاسبهٔ پنجرهٔ زمانی بیاید، یک refactor +می‌تواند ترتیب را عوض کند و کلینیک از بیمار برای لغو خودش جریمه بگیرد. + +## ۳. سقف جریمه = مبلغ پرداختی + +```php +penaltyRials: min($penalty, $paid) +``` + +جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ `Invoice`/`Claim` است نه لغو. برای +نوبت نقدی (`$paid = 0`) جریمه صفر می‌شود و پاسخ یک `note` می‌گیرد. + +اگر روزی «بدهی لغو» لازم شد، یک تسک جدا با اتصال به `Billing` — نه یک مقدار منفی +پنهان در کیف پول. + +## ۴. `setRecordedEntity` روی تراکنش کیف پول + +```php +$tx->setRecordedEntity($appt->getEntityType(), $appt->getEntityId()); +``` + +فراموش کردنش یعنی کلینیک الف جریمهٔ ثبت‌شده در کلینیک ب را می‌بیند — دقیقاً همان نشتی +که `PatientWalletTenantTest` می‌سنجد. آن تست باید بعد از این تسک هم سبز بماند. + +## ۵. لیست انتظار: broadcast، با جملهٔ صریح + +تصمیم معماری (جدول کامل در `architecture.md`): ظرفیت آزادشده به حداکثر ۱۰ نفر اطلاع +داده می‌شود و اولین رزروکننده می‌برد. + +متن پیامک اجباراً شامل این جمله: + +> «یک وقت در تاریخ X آزاد شد. اولین نفری که رزرو کند آن را می‌گیرد.» + +بدون این جمله، ۹ نفر فکر می‌کنند نوبتشان تضمین شده و شکایت می‌کنند. با آن، انتظار +درست تنظیم می‌شود. + +`notify_count` سقف دارد (پیشنهاد: ۳). بیماری که سه بار مطلع شده و رزرو نکرده، دیگر +پیام نمی‌گیرد تا خودش لیست را تازه کند. + +## ۶. اطلاع‌رسانی async، بیرون تراکنش لغو + +```php +// CancellationService — داخل تراکنش فقط dispatch +$this->events->dispatch( + (new Envelope(new AppointmentCancelled($appt->getUuid()))) + ->with(new DispatchAfterCurrentBusStamp()) +); + +// NotifyWaitlistHandler — بیرون، async +public function __invoke(AppointmentCancelled $event): void +{ + $appt = $this->repo->findByUuid($event->appointmentUuid); + $this->matcher->onCapacityFreed($appt->getSlotStart(), $appt->getSlotEnd(), …); +} +``` + +ده پیامک داخل تراکنش لغو یعنی لغو کند می‌شود و اگر پیامک شکست خورد، لغو rollback +می‌شود — که غلط است. لغو موفق است حتی اگر هیچ پیامکی نرود. + +`messenger:consume async` از قبل در استک هست. + +## ۷. برچسب پرریسک، نه مسدودسازی + +```php +$this->tagService->attach($patient, $policy->getRiskTagUuid()); +// نه: $patient->setBlocked(true) +``` + +مسدودسازی یک تصمیم است که کلینیک باید بگیرد، و ابزارش از قبل ساخته می‌شود: یک قانون +`eligibility` (تسک ۰۹) با شرط `patient.tags in ['پرریسک']` و اثر `deny`. + +اگر اینجا مسدود کنی، دو مکانیزم موازی برای یک کار داری و کلینیک نمی‌تواند خاموشش کند. + +## ۸. پنجرهٔ شمارش عدم حضور + +```php +private function windowStart(): int { return time() - 365 * 86400; } +``` + +۱۲ ماه، نه کل تاریخ. سه عدم حضور در سال ۱۴۰۲ امروز بی‌معناست. مقدار را ثابت نگه دار +(نه تنظیم‌پذیر) تا شمارش بین کلینیک‌ها قابل مقایسه بماند؛ اگر لازم شد، ستون اضافه کن. + +## ۹. edge case ها + +| حالت | رفتار درست | +|---|---| +| لغو دوبارهٔ همان نوبت | idempotent — همان وضعیت، بدون جریمهٔ دوم | +| لغو نوبت گذشته | `422` — برای گذشته `no_show`/`completed` | +| جریمه > پرداختی | سقف = پرداختی + `note` | +| نوبت نقدی | جریمه صفر + `note: 'در مراجعهٔ بعدی محاسبه می‌شود'` | +| لغو توسط کلینیک | بدون جریمه، بیعانه کامل، اعتبار کامل | +| لغو جلسهٔ دوره | اعتبار **طبق سیاست**، نه همیشه؛ `CourseSession` → `planned` | +| بیمار در لیست انتظار که خودش نوبت گرفت | ورودی → `converted` خودکار (روی رویداد `AppointmentBooked`) | +| ظرفیت آزادشده که هیچ‌کس منتظرش نیست | هیچ کاری — لاگ debug، نه هشدار | +| ده نفر مطلع، هیچ‌کس رزرو نکرد | ورودی‌ها `waiting` می‌مانند، `notify_count++` | +| ثبت لیست انتظار برای بازهٔ گذشته | `422` | +| `desired_to - desired_from` بزرگ‌تر از ۹۰ روز | `422` — همان سقف جستجو | +| سیاست سرویس و سیاست محیط هر دو | سرویس (اختصاصی‌تر) برنده | + +سطر «بیمار در لیست انتظار که خودش نوبت گرفت» را فراموش نکن: بدون آن، بیمار نوبت دارد و +همچنان پیامک «وقت آزاد شد» می‌گیرد. + +## ۱۰. تست + +``` +tests/Cancellation/PenaltyCalculatorTest.php ← ⭐ + - داخل پنجرهٔ رایگان → صفر + - بیرون پنجره → درصد درست + - لغو توسط کلینیک → همیشه صفر (حتی ۱ ساعت قبل) + - جریمه > پرداختی → سقف + - نوبت نقدی → صفر + note +tests/Cancellation/CancellationServiceTest.php + - اشغال منابع آزاد می‌شود + - جریمه در wallet_transactions با recorded_entity + - لغو دوباره → idempotent + - لغو گذشته → 422 +tests/Cancellation/PolicyResolverTest.php + - سیاست سرویس بر محیط اولویت دارد +tests/Cancellation/NoShowTrackerTest.php + - سومین no_show → برچسب پرریسک + - عدم حضور قدیمی‌تر از ۱۲ ماه شمرده نمی‌شود + - همان نوبت دوبار → یک رکورد + - بیمار پرریسک مسدود نمی‌شود (رزرو موفق) +tests/Waitlist/WaitlistMatcherTest.php + - لغو → حداکثر ۱۰ نفر مطلع، به ترتیب priority سپس created_at + - فیلتر preferred_day_parts + - notify_count سقف دارد +tests/Waitlist/WaitlistConversionTest.php + - بیمار خودش نوبت گرفت → converted +tests/Waitlist/WaitlistAsyncTest.php + - شکست پیامک، لغو را rollback نمی‌کند +tests/Patient/PatientWalletTenantTest.php ← موجود، باید سبز بماند +tests/Course/CourseLifecycleTest.php ← موجود، سیاست اعتبار اعمال شود +``` + +## ۱۱. مستندات + +`docs/api/cancellation.md` و `docs/api/waitlist.md`. در اولی حتماً بنویس که +`cancellation-preview` پیش از لغو اجباری است و لغو توسط کلینیک هرگز جریمه ندارد. +در دومی تصمیم broadcast و دلیلش. diff --git a/docs/new_feture/taskes/task-13-cancellation-waitlist/task.md b/docs/new_feture/taskes/task-13-cancellation-waitlist/task.md new file mode 100644 index 00000000..dbb45fc6 --- /dev/null +++ b/docs/new_feture/taskes/task-13-cancellation-waitlist/task.md @@ -0,0 +1,75 @@ +# تسک ۱۳ — سیاست لغو، عدم حضور، لیست انتظار + +**فاز:** ۳ (کسب‌وکار) · **وابستگی:** ۰۷ · **زمان:** ۱۰-۱۲ ساعت + +--- + +## هدف + +مستند بند ۱۱: «هر کلینیک تنظیم می‌کند: تا چند ساعت قبل لغو رایگان است، جریمه چقدر است، +بیعانه برمی‌گردد یا نه، بعد از چند بار عدم حضور بیمار پرریسک علامت بخورد.» +و بند ۱۷: «رقابت روی ساعت‌های پرتقاضا → پیشنهاد خودکار ساعت جایگزین» و +بند ۱۸ فاز ۳: «لیست انتظار». + +## وضعیت فعلی + +- لغو کار می‌کند (`cancelled_by_user` / `cancelled_by_doctor`) ولی **بدون سیاست**: + هیچ جریمه‌ای، هیچ محدودیت زمانی، هیچ رفتاری با بیعانه +- `no_show` وضعیت هست ولی هیچ اثری ندارد +- `Appointment.is_reserve` وجود دارد: «نوبت رزرو» روزی (بدون ساعت) — یک لیست انتظار + ابتدایی که `ReserveAppointmentsPage.tsx` نمایشش می‌دهد +- بیعانه ثبت می‌شود (`deposit_required`, `deposit_amount_rials`) ولی بازگشتش دستی است + +## دامنه + +**هست:** +- `CancellationPolicy` per محیط/سرویس: پنجرهٔ لغو رایگان، درصد/مبلغ جریمه، رفتار بیعانه +- `NoShowPolicy`: بعد از N بار، برچسب پرریسک روی بیمار (استفاده از `TenantTag` موجود) +- محاسبهٔ جریمه در لحظهٔ لغو + ثبت در دفتر مالی موجود +- `Waitlist` — لیست انتظار برای بازهٔ زمانی مشخص (توسعهٔ `is_reserve` موجود) +- اطلاع‌رسانی خودکار به لیست انتظار وقتی ظرفیت آزاد می‌شود + +**نیست:** پیش‌بینی عدم حضور (فاز ۴ مستند — خارج از دامنه). + +## Endpoint ها + +| متد | مسیر | توضیح | +|---|---|---| +| GET/PUT | `/api/v1/cancellation-policy` | سیاست محیط | +| PUT | `/api/v1/service-item/{uuid}/cancellation-policy` | override سرویس | +| GET | `/api/v1/appointment/{uuid}/cancellation-preview` | جریمه و بازگشت **پیش از** لغو | +| POST | `/api/v1/appointment/{uuid}/cancel` | لغو با اعمال سیاست | +| GET/POST | `/api/v1/waitlist` | ثبت در لیست انتظار | +| DELETE | `/api/v1/waitlist/{uuid}` | | +| GET | `/api/v1/waitlist/matches` | (پنل) درخواست‌های قابل تطبیق با ظرفیت آزاد | + +## معیار پذیرش + +- ✅ موفق: سیاست «لغو رایگان تا ۲۴ ساعت قبل، پس از آن ۵۰٪ جریمه، بیعانه برنمی‌گردد» → + `GET /cancellation-preview` برای نوبت ۴۸ ساعت بعد: `penalty_rials: 0, deposit_refundable: true`؛ + برای نوبت ۶ ساعت بعد: `penalty_rials: <۵۰٪>, deposit_refundable: false`. +- ✅ موفق: `POST /cancel` جریمه را در `wallet_transactions` (الگوی موجود) ثبت می‌کند و + اشغال منابع را آزاد می‌کند. +- ✅ موفق: سومین `no_show` بیمار → برچسب «پرریسک» (`TenantTag`) خودکار اضافه می‌شود و + در `PatientDetailPage` دیده می‌شود. +- ✅ موفق: بیمار در لیست انتظار برای «۵ مرداد، بعدازظهر» است؛ نوبتی در آن بازه لغو + می‌شود → یک پیامک به او می‌رود و رکورد `notified_at` پر می‌شود. +- ✅ موفق: لغو دورهٔ درمان (تسک ۱۲) → اعتبار پکیج **طبق سیاست** برمی‌گردد، نه همیشه. +- ❌ خطا: `cancel` نوبتی که قبلاً لغو شده → `409` idempotent (همان وضعیت برگردد). +- ❌ خطا: `cancel` نوبت گذشته → `422`؛ برای گذشته `no_show` یا `completed` معنی دارد. +- ❌ خطا: ثبت در لیست انتظار برای بازهٔ گذشته → `422`. +- ⚠️ مرزی: جریمه بیشتر از مبلغ پرداختی → سقف = مبلغ پرداختی. +- ⚠️ مرزی: لغو توسط **کلینیک** (`cancelled_by_doctor`) → هرگز جریمه ندارد و بیعانه + کامل برمی‌گردد. +- ⚠️ مرزی: نوبت بدون پرداخت (نقدی سر جلسه) → جریمه ثبت می‌شود به‌عنوان بدهی، نه کسر. +- ⚠️ مرزی: لیست انتظار با ده نفر برای یک بازه → **همه** مطلع می‌شوند (اولین رزروکننده + می‌برد) — نه صف انحصاری. تصمیم و دلیلش در implementation_notes. +- ⚠️ مرزی: بیمار پرریسک → **مسدود نمی‌شود**؛ فقط برچسب. مسدودسازی یک قانون + `eligibility` (تسک ۰۹) روی همان برچسب است. + +## خروجی + +- `src/Cancellation/` + `src/Waitlist/` +- `assets/admin/pages/CancellationPolicyPage.tsx` + `WaitlistPage.tsx` +- توسعهٔ `AppointmentDetailPage.tsx` با پیش‌نمایش لغو +- `docs/api/cancellation.md` + `docs/api/waitlist.md` diff --git a/docs/new_feture/taskes/task-14-events-utilization/architecture.md b/docs/new_feture/taskes/task-14-events-utilization/architecture.md new file mode 100644 index 00000000..e1da8467 --- /dev/null +++ b/docs/new_feture/taskes/task-14-events-utilization/architecture.md @@ -0,0 +1,151 @@ +# معماری — تسک ۱۴ + +## ساختار فایل + +``` +src/Shared/Event/ +├── DomainEvent.php # کلاس پایه — payload فقط اسکالر و uuid +├── DomainEventPublisher.php # تنها نقطهٔ انتشار +├── Entity/DomainEventLog.php # outbox +└── MessageHandler/PublishDomainEventHandler.php + +src/Report/ +├── Service/ +│ ├── ResourceUtilizationReporter.php +│ └── PlanAccuracyReporter.php +├── Dto/{UtilizationRow, AccuracyRow}.php +└── Controller/ReportController.php +``` + +## قرارداد رویداد + +```php +abstract class DomainEvent +{ + public function __construct( + public readonly string $entityType, // محیط — همهٔ رویدادها tenant دارند + public readonly int $entityId, + public readonly array $payload, // فقط اسکالر و uuid + public readonly int $occurredAt, + ) {} + + abstract public function name(): string; // 'AppointmentBooked' +} +``` + +سه قاعدهٔ غیرقابل‌مذاکره: + +1. **payload فقط uuid و اسکالر** — هیچ entity ای در رویداد نیست. مصرف‌کننده خودش + واکشی می‌کند. entity در پیام async یعنی سریال‌سازی، detach شدن، و داده‌ی کهنه. +2. **انتشار بعد از commit** — با `DispatchAfterCurrentBusStamp` یا از راه outbox. +3. **هر رویداد محیط دارد** — مصرف‌کننده باید بداند رویداد مال کدام محیط است، وگرنه + پیامک کلینیک الف به شمارهٔ کلینیک ب می‌رود. + +## outbox — چرا لازم است + +``` +تراکنش: [ثبت نوبت] + [درج ردیف در domain_events] → commit اتمی +بعد: PublishDomainEventHandler ردیف را برمی‌دارد و به messenger می‌دهد +``` + +بدون outbox دو حالت شکست ممکن است: + +| حالت | نتیجه | +|---|---| +| dispatch قبل از commit، تراکنش rollback | پیامک رفته، نوبتی وجود ندارد | +| commit موفق، dispatch شکست خورد (Redis down) | نوبت هست، هیچ‌کس مطلع نشد | + +با outbox، ردیف رویداد **در همان تراکنش** ثبت می‌شود. یک worker (یا `scheduler` هر ۱۰ +ثانیه) ردیف‌های `published_at IS NULL` را برمی‌دارد و منتشر می‌کند. حداکثر یک بار +تأخیر، هرگز گم‌شدن. + +```php +final class DomainEventPublisher +{ + /** داخل تراکنش کاری صدا زده می‌شود — فقط درج، بدون I/O خارجی. */ + public function record(DomainEvent $event): void + { + $this->em->persist(DomainEventLog::from($event)); + } +} +``` + +تسک‌های ۰۷ تا ۱۳ به‌جای `bus->dispatch()` باید `publisher->record()` صدا بزنند. +اگر آن تسک‌ها تمام شده‌اند، این تسک شامل جایگزینی آن فراخوانی‌ها هم است. + +## `ResourceUtilizationReporter` + +```php +/** @return UtilizationRow[] */ +public function report(EntityContext $ctx, int $from, int $to, ?Branch $branch): array +``` + +چهار عدد per منبع: + +| عدد | از کجا | معنی | +|---|---|---| +| `available_minutes` | `ResourceAvailabilityService::rawWindows()` (تسک ۰۳) | ظرفیت تقویمی | +| `occupied_minutes` | `SUM(end_at - start_at)` روی `resource_occupancy` با `status='booked'` | زمان اشغال، شامل setup/cleanup و passive | +| `active_minutes` | همان، ولی `occupancy_kind != 'passive'` | زمان کار واقعی | +| `wasted_minutes` | `occupied - active` | زمانی که منبع رزرو بود ولی کار نمی‌کرد | + +``` +utilization = occupied / available → «چقدر از ظرفیت فروخته شد» +active_ratio = active / occupied → «چقدر از اشغال، کار واقعی بود» +``` + +`active_ratio` پایین دقیقاً همان چیزی است که مستند بند ۱۷ می‌خواهد کشف کند: منبعی که +۷۰٪ زمانش «رزرو ولی بی‌کار» است، یعنی بخش‌های نوبت اشتباه تعریف شده‌اند — مثلاً اپراتور +به بخش «انتظار» نسبت داده شده که نباید. + +## `PlanAccuracyReporter` + +مقایسهٔ پیش‌بینی و واقعیت per سرویس: + +```php +// پیش‌بینی: appointments.plan_total_minutes (تسک ۰۷) +// واقعیت: patient_sessions یا appointment_events (زمان بین ورود و پایان) +$deviation = intdiv(($actualAvg - $plannedAvg) * 100, max(1, $plannedAvg)); +``` + +| انحراف | شدت | معنی | +|---|---|---| +| ±۱۰٪ | `ok` | تعریف درست است | +| ±۱۰..۳۰٪ | `medium` | بازبینی بخش‌ها | +| > ۳۰٪ | `high` | تعریف اشتباه — ظرفیت غلط محاسبه می‌شود | + +انحراف **منفی** بزرگ هم مشکل است: سرویسی که ۹۰ دقیقه پیش‌بینی شده و ۴۵ دقیقه طول +می‌کشد، نصف ظرفیت کلینیک را الکی می‌بلعد — همان مسئله‌ای که کل این پروژه برای حلش است. + +حداقل نمونه: ۱۰ مراجعهٔ `completed`. کمتر از آن، `severity: 'insufficient_data'`. + +## کارایی گزارش‌ها + +هر دو گزارش کوئری تجمعی‌اند، نه پیمایش: + +```sql +SELECT ro.resource_id, + SUM(ro.end_at - ro.start_at) AS occupied, + SUM(CASE WHEN ro.occupancy_kind <> 'passive' THEN ro.end_at - ro.start_at ELSE 0 END) AS active +FROM resource_occupancy ro +WHERE ro.entity_type = :type AND ro.entity_id = :id + AND ro.status = 'booked' + AND ro.start_at >= :from AND ro.end_at <= :to +GROUP BY ro.resource_id +``` + +`idx_occ_tenant_range` تسک ۰۷ همین را پوشش می‌دهد. `available_minutes` جدا محاسبه +می‌شود (از تقویم، کش‌شده). سقف بازه ۹۰ روز. + +## پنل ادمین + +- `ResourceUtilizationPage.tsx` — جدول منابع + نمودار میله‌ای با `Recharts` (در استک هست). + ستون‌ها: منبع، ظرفیت، اشغال، کار فعال، بهره‌وری، نسبت فعال. ردیف‌های + `active_ratio < 0.3` با نشان هشدار. +- `PlanAccuracyPage.tsx` — جدول سرویس‌ها با انحراف و شدت + لینک به + «ویرایش بخش‌های این سرویس» (تسک ۰۵) + +لینک به ویرایش بخش‌ها مهم‌ترین بخش این صفحه است: گزارشی که مشکل را نشان می‌دهد ولی راه +اصلاح را نمی‌دهد، خوانده نمی‌شود. + +بازهٔ زمانی با `PersianDatePicker`، وضعیت در URL با `useUrlState`. diff --git a/docs/new_feture/taskes/task-14-events-utilization/database.md b/docs/new_feture/taskes/task-14-events-utilization/database.md new file mode 100644 index 00000000..945d465f --- /dev/null +++ b/docs/new_feture/taskes/task-14-events-utilization/database.md @@ -0,0 +1,126 @@ +# دیتابیس — تسک ۱۴ + +## `domain_events` — outbox + +| ستون | نوع | توضیح | +|---|---|---| +| `id` | BIGINT PK AI | | +| `uuid` | VARCHAR(36) UNIQUE | شناسهٔ idempotency برای مصرف‌کننده | +| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | محیط رویداد | +| `name` | VARCHAR(60) NOT NULL | `AppointmentBooked` | +| `payload` | JSON NOT NULL | فقط uuid و اسکالر | +| `occurred_at` | INT NOT NULL | زمان وقوع (نه انتشار) | +| `published_at` | INT NULL | NULL = منتشر نشده | +| `attempts` | SMALLINT NOT NULL DEFAULT 0 | | +| `last_error` | VARCHAR(255) NULL | | + +```sql +KEY idx_de_pending (published_at, occurred_at) -- worker: WHERE published_at IS NULL +KEY idx_de_tenant (entity_type, entity_id, occurred_at) +KEY idx_de_name (name, occurred_at) +``` + +`idx_de_pending` کوئری worker است: + +```sql +SELECT * FROM domain_events +WHERE published_at IS NULL AND attempts < 5 +ORDER BY occurred_at ASC +LIMIT 100 +``` + +`attempts < 5` سقف تلاش. ردیف مرده با `last_error` باقی می‌ماند تا ادمین ببیند — +حذف خاموش یعنی رویداد گم‌شدهٔ بی‌رد. + +## تغییر جدول موجود + +هیچ. `resource_occupancy` (تسک ۰۷) ستون‌های لازم برای گزارش بهره‌وری را دارد: +`start_at`, `end_at`, `occupancy_kind`, `status`, `resource_id`. +`appointments.plan_total_minutes` (تسک ۰۷) پیش‌بینی را دارد. + +`AppointmentEvent` موجود دست‌نخورده می‌ماند — تاریخچهٔ وضعیت نوبت است، رویداد دامنه نیست. +تفاوتشان را در `docs/architecture/domain-events.md` بنویس: + +| | `AppointmentEvent` | `DomainEventLog` | +|---|---|---| +| دامنه | فقط نوبت | همهٔ دامنه‌ها | +| مصرف‌کننده | UI تاریخچه | سیستم‌های دیگر (پیامک، حسابداری) | +| انتشار | ندارد | messenger | + +## کوئری گزارش بهره‌وری + +```sql +SELECT ro.resource_id, + SUM(ro.end_at - ro.start_at) AS occupied_seconds, + SUM(CASE WHEN ro.occupancy_kind <> 'passive' + THEN ro.end_at - ro.start_at ELSE 0 END) AS active_seconds, + COUNT(DISTINCT ro.appointment_id) AS appointment_count +FROM resource_occupancy ro +WHERE ro.entity_type = :type AND ro.entity_id = :id + AND ro.status = 'booked' + AND ro.start_at >= :from AND ro.start_at < :to +GROUP BY ro.resource_id +``` + +`ro.start_at < :to` (نه `end_at <= :to`) — نوبتی که در بازه شروع شده ولی بیرون تمام شده، +باید شمرده شود. جزئی است ولی روی گزارش هفتگی چند درصد اختلاف می‌سازد. + +ایندکس `idx_occ_tenant_range (entity_type, entity_id, start_at)` تسک ۰۷ این را پوشش می‌دهد. + +## کوئری دقت برنامه + +```sql +SELECT a.service_item_id, + AVG(a.plan_total_minutes) AS planned_avg, + AVG((ps.ended_at - ps.started_at) / 60) AS actual_avg, + COUNT(*) AS sample +FROM appointments a +JOIN patient_sessions ps ON ps.appointment_id = a.id +WHERE a.entity_type = :type AND a.entity_id = :id + AND a.status = 'completed' + AND a.slot_start >= :from AND a.slot_start < :to + AND a.plan_total_minutes IS NOT NULL + AND ps.ended_at IS NOT NULL +GROUP BY a.service_item_id +HAVING sample >= 10 +``` + +⚠️ **بررسی لازم پیش از پیاده‌سازی:** `patient_sessions` باید `appointment_id` و +`started_at`/`ended_at` داشته باشد. اگر ندارد، دو گزینه: + +1. از `appointment_events` استفاده کن: فاصلهٔ بین انتقال به `salon` و انتقال به `completed` +2. اگر آن هم نیست، این گزارش به یک تسک جدا موکول شود و فقط گزارش بهره‌وری در این تسک بماند + +**تصمیم را بگیر و بنویس** — نه یک گزارش با داده حدسی. + +## نگهداشت + +```bash +ddev exec php bin/console app:events:prune --older-than=180d --force +``` + +ردیف‌های `published_at IS NOT NULL` قدیمی‌تر از ۶ ماه. ردیف‌های شکست‌خورده +(`published_at IS NULL AND attempts >= 5`) **هرگز** حذف نمی‌شوند. + +## Migration + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +worker انتشار در `config/packages/messenger.yaml` و `scheduler`: + +```yaml +# هر ۱۰ ثانیه +App\Shared\Event\Message\FlushOutboxMessage: { frequency: 10 } +``` + +⚠️ طبق حافظهٔ عملیاتی پروژه، worker های Coolify باید loop-wrap شوند تا کانتینر خارج نشود. +دستور جدید را با همان الگو اضافه کن. + +## طبقه‌بندی tenant + +| جدول | وضعیت | +|---|---| +| `domain_events` | جفت tenant | diff --git a/docs/new_feture/taskes/task-14-events-utilization/implementation_notes.md b/docs/new_feture/taskes/task-14-events-utilization/implementation_notes.md new file mode 100644 index 00000000..f53700e0 --- /dev/null +++ b/docs/new_feture/taskes/task-14-events-utilization/implementation_notes.md @@ -0,0 +1,158 @@ +# نکات پیاده‌سازی — تسک ۱۴ + +## ۱. outbox، نه dispatch مستقیم + +تسک‌های ۰۷ تا ۱۳ هر کدام یک `bus->dispatch()` دارند. این تسک همه را به +`publisher->record()` تغییر می‌دهد: + +```php +// قبل +$this->bus->dispatch((new Envelope($event))->with(new DispatchAfterCurrentBusStamp())); + +// بعد +$this->publisher->record($event); // فقط persist — داخل همان تراکنش کاری +``` + +`DispatchAfterCurrentBusStamp` مشکل «rollback بعد از پیامک» را حل می‌کند ولی مشکل +«commit موفق، Redis پایین» را نه. outbox هر دو را حل می‌کند. + +اگر تسک‌های قبلی هنوز اجرا نشده‌اند، از روز اول `record()` بنویس. + +## ۲. payload فقط uuid + +```php +// ❌ entity در پیام async +new AppointmentBooked($appointment); + +// ✅ +new AppointmentBooked(['appointment_uuid' => $appointment->getUuid()]); +``` + +entity در پیام یعنی: سریال‌سازی سنگین، detach شدن از EntityManager، و داده‌ای که تا لحظهٔ +مصرف کهنه شده. مصرف‌کننده با uuid خودش واکشی می‌کند و تازه‌ترین حالت را می‌بیند. + +## ۳. idempotency در مصرف‌کننده، نه در انتشار + +messenger ممکن است یک پیام را دوبار تحویل دهد (at-least-once). پس **مصرف‌کننده** باید +idempotent باشد: + +```php +public function __invoke(AppointmentBooked $event): void +{ + if ($this->smsLogRepo->alreadySent($event->uuid, 'booking_confirmation')) { + return; + } + … +} +``` + +`domain_events.uuid` همان کلید idempotency است. تلاش برای تضمین exactly-once در سمت +انتشار، مسئله‌ای است که حل نمی‌شود؛ idempotent بودن مصرف‌کننده حل می‌شود. + +## ۴. `active_ratio` — عدد اصلی این تسک + +``` +utilization = occupied / available +active_ratio = active / occupied +``` + +`utilization` عدد فروش است و کلینیک دوستش دارد. `active_ratio` عدد **تشخیص** است: + +| `active_ratio` | معنی | +|---|---| +| > ۰.۸ | تعریف بخش‌ها درست است | +| ۰.۵ – ۰.۸ | زمان passive/انتظار قابل توجه — بازبینی | +| < ۰.۳ | **تعریف اشتباه** — منبع به بخشی نسبت داده شده که در آن کار نمی‌کند | + +مثال واقعی: اپراتوری که اشتباهاً به بخش «انتظار اثر بی‌حسی» هم نسبت داده شده، +`active_ratio` حدود ۰.۵ می‌گیرد — و همان لحظه‌ای است که کلینیک می‌فهمد ۳۰ دقیقه ظرفیت +هر نوبت را الکی می‌سوزاند. + +این توضیح باید **در خود UI** باشد (tooltip روی ستون)، نه فقط در مستندات. + +## ۵. `available_minutes = 0` → `utilization = null` + +```php +'utilization' => $available > 0 ? round($occupied / $available, 2) : null, +``` + +نه صفر. منبعی که تقویم ندارد، «بهره‌وری صفر» ندارد — بهره‌وری‌اش **تعریف‌نشده** است. +صفر نشان دادن یعنی کلینیک فکر می‌کند منبع بی‌استفاده است در حالی که مشکل نبود تقویم است. + +در UI: `—` با tooltip «تقویم کاری تعریف نشده» + لینک به تنظیم تقویم (تسک ۰۳). + +## ۶. مرز بازه در کوئری + +```sql +AND ro.start_at >= :from AND ro.start_at < :to +``` + +نه `end_at <= :to`. نوبتی که ۲۳:۳۰ شروع شده و ۰۰:۳۰ روز بعد تمام می‌شود، باید در روز +شروعش شمرده شود. با شرط `end_at` کامل حذف می‌شود. + +## ۷. `plan-accuracy` — اول منبع داده را بررسی کن + +کوئری این گزارش به `patient_sessions.appointment_id` و `started_at`/`ended_at` نیاز دارد. +**پیش از پیاده‌سازی** بررسی کن که این ستون‌ها هستند: + +```bash +ddev exec php bin/console doctrine:mapping:describe 'App\Patient\Entity\PatientSession' +``` + +اگر نیستند، جایگزین: `appointment_events` — فاصلهٔ بین انتقال به `salon` و به `completed`. +اگر آن هم قابل اتکا نیست، **این گزارش را به تسک جدا موکول کن** و در README تسک‌ها بنویس. +گزارشی با داده حدسی بدتر از نبود گزارش است: کلینیک بر اساسش بخش‌ها را عوض می‌کند. + +## ۸. edge case ها + +| حالت | رفتار درست | +|---|---| +| منبع بدون تقویم | `utilization: null` + لینک تنظیم تقویم | +| منبع بدون هیچ اشغال | `occupied: 0, active_ratio: null` | +| بخش `passive` | در `occupied` هست، در `active` نه | +| `setup/cleanup` | در `occupied` هست (منبع واقعاً اشغال بود) | +| اشغال `released` (لغوشده) | در گزارش **نمی‌آید** — `status='booked'` فقط | +| اشغال دستی (`appointment_id IS NULL`) | در `occupied` می‌آید، `appointment_count` تحت تأثیر نیست | +| منبع با `capacity=3` | `occupied` جمع همهٔ واحدهاست؛ `available` باید × capacity شود | +| نمونهٔ کمتر از ۱۰ در `plan-accuracy` | `severity: 'insufficient_data'`، عدد نمایش داده نشود | +| انحراف منفی بزرگ (پیش‌بینی > واقعیت) | `severity: 'high'` — همان‌قدر مهم | +| بازه > ۹۰ روز | `422` | +| رویداد شکست‌خورده با ۵ تلاش | ردیف می‌ماند، در `GET /domain-events` با نشان خطا | + +سطر `capacity=3` را فراموش نکن: اتاق سه‌تخته در ۸ ساعت، ۲۴ نفر-ساعت ظرفیت دارد نه ۸. +بدون ضرب در `capacity`، بهره‌وری‌اش سه برابر واقعی نشان داده می‌شود. + +## ۹. تست + +``` +tests/Shared/Event/OutboxTest.php ← ⭐ + - record() داخل تراکنش → ردیف در همان تراکنش + - rollback → هیچ ردیفی و هیچ انتشاری + - worker ردیف را منتشر و published_at را پر می‌کند + - شکست → attempts++ و last_error + - attempts >= 5 → دیگر برداشته نمی‌شود، حذف هم نمی‌شود +tests/Shared/Event/EventPayloadTest.php + - payload فقط اسکالر و uuid (reflection روی همهٔ زیرکلاس‌های DomainEvent) + - هر رویداد entity_type/entity_id دارد +tests/Report/ResourceUtilizationTest.php ← ⭐ + - passive در occupied هست، در active نه + - setup/cleanup در occupied + - released شمرده نمی‌شود + - capacity=3 → available × 3 + - منبع بدون تقویم → utilization null (نه صفر) + - مرز بازه: نوبت شب‌گذر در روز شروعش +tests/Report/PlanAccuracyTest.php + - انحراف مثبت و منفی هر دو high + - نمونهٔ < ۱۰ → insufficient_data +tests/Report/ReportAuthTest.php + - منشی روی domain-events → 403 + - بازه > ۹۰ روز → 422 +tests/Report/ReportQueryCountTest.php + - گزارش ۹۰ روزه: تعداد کوئری ثابت، مستقل از تعداد منبع +``` + +## ۱۰. مستندات + +- `docs/api/reports.md` — دو گزارش + معنی هر عدد + جدول `active_ratio` +- `docs/architecture/domain-events.md` — قرارداد رویداد، فهرست کامل، الگوی outbox، + تفاوت با `AppointmentEvent`، و قاعدهٔ idempotency مصرف‌کننده diff --git a/docs/new_feture/taskes/task-14-events-utilization/task.md b/docs/new_feture/taskes/task-14-events-utilization/task.md new file mode 100644 index 00000000..c7e75f00 --- /dev/null +++ b/docs/new_feture/taskes/task-14-events-utilization/task.md @@ -0,0 +1,83 @@ +# تسک ۱۴ — رویدادهای دامنه و گزارش بهره‌وری منابع + +**فاز:** ۴ (بهینه‌سازی) · **وابستگی:** ۰۷ · **زمان:** ۸-۱۰ ساعت + +--- + +## هدف + +دو چیز از مستند: + +1. **بند ۱۶** — فهرست رویدادهایی که سیستم منتشر می‌کند تا سیستم‌های دیگر (پیامک، + حسابداری، گزارش) به آن‌ها گوش بدهند. +2. **بند ۱۷، ریسک سوم** — «کلینیک بخش‌های نوبت را اشتباه تعریف کند → ظرفیت غلط حساب + می‌شود». راه‌حل مستند: **گزارش بهره‌وری منابع برای پیدا کردن اشکال.** + +گزارش بهره‌وری تنها ابزاری است که به کلینیک می‌گوید تعریف بخش‌هایش درست است یا نه. +بدون آن، تسک ۰۵ یک ابزار قدرتمند بدون بازخورد است. + +## وضعیت فعلی + +- `AppointmentEvent` وجود دارد و تاریخچهٔ تغییر وضعیت نوبت را ثبت می‌کند +- `symfony/messenger` + `symfony/redis-messenger` + `symfony/scheduler` در استک هستند +- پیامک از راه `Sms` domain و `messenger:consume async` کار می‌کند +- تسک‌های ۰۷ تا ۱۳ هر کدام یک `dispatch` گذاشته‌اند بدون یک قرارداد واحد + +## دامنه + +**هست:** +- قرارداد واحد رویداد دامنه: نام، payload (فقط uuid)، زمان انتشار (بعد از commit) +- ثبت همهٔ رویدادهای بند ۱۶ مستند +- `domain_events` — جدول outbox برای تضمین انتشار +- گزارش بهره‌وری منابع: ساعت آزاد / اشغال / انتظار / کار فعال per منبع per بازه +- گزارش «مدت پیش‌بینی‌شده در برابر مدت واقعی» برای تشخیص تعریف غلط بخش‌ها + +**نیست:** پیش‌بینی عدم حضور، پیشنهاد هوشمند وقت (فاز ۴ مستند، خارج از دامنه). + +## Endpoint ها + +| متد | مسیر | توضیح | +|---|---|---| +| GET | `/api/v1/reports/resource-utilization` | بهره‌وری منابع در بازه | +| GET | `/api/v1/reports/plan-accuracy` | مقایسهٔ مدت پیش‌بینی و واقعی per سرویس | +| GET | `/api/v1/domain-events` | (ادمین) رویدادهای منتشرشده — عیب‌یابی | + +## فهرست رویدادها (مستند بند ۱۶) + +``` +HoldCreated AppointmentBooked +AppointmentCancelled AppointmentRescheduled +PatientNoShow AppointmentCompleted +ResourceBlocked ResourceReleased +CourseStarted CourseSessionCompleted +CourseCompleted PackagePurchased +CreditConsumed CreditRefunded +``` + +## معیار پذیرش + +- ✅ موفق: ثبت نوبت → یک ردیف در `domain_events` با نام `AppointmentBooked` و + payload شامل `appointment_uuid`؛ و `GET /domain-events` آن را نشان می‌دهد. +- ✅ موفق: رویداد **بعد از** commit منتشر می‌شود. تست: تراکنشی که rollback می‌شود + هیچ رویدادی منتشر نمی‌کند. +- ✅ موفق: گزارش بهره‌وری برای اپراتور مریم در یک هفته → + `{ available_minutes: 2400, occupied_minutes: 1800, active_minutes: 1200, utilization: 0.75, active_ratio: 0.50 }`. +- ✅ موفق (**تشخیص تعریف غلط بخش‌ها**): سرویسی که `total_minutes` پیش‌بینی‌اش ۶۰ است ولی + میانگین مدت واقعی مراجعاتش ۹۰ دقیقه → `GET /reports/plan-accuracy` آن را با + `deviation_percent: +50` و `severity: 'high'` برمی‌گرداند. +- ✅ موفق: منبعی با `active_ratio` زیر ۰.۳ در گزارش با نشان «ظرفیت هدررفته» می‌آید — + یعنی بخش‌های `passive` یا انتظار زیادی به آن نسبت داده شده. +- ❌ خطا: گزارش با بازهٔ بزرگ‌تر از ۹۰ روز → `422`. +- ❌ خطا: منشی روی `GET /domain-events` → `403` (فقط `ROLE_ADMIN`). +- ⚠️ مرزی: منبع بدون هیچ تقویم → `available_minutes: 0` و `utilization: null` (نه صفر — + تقسیم بر صفر معنایی متفاوت دارد). +- ⚠️ مرزی: بخش‌های `passive` در `occupied_minutes` می‌آیند ولی در `active_minutes` نه. +- ⚠️ مرزی: `setup/cleanup` در `occupied_minutes` می‌آید (منبع واقعاً اشغال بوده). +- ⚠️ مرزی: رویداد تکراری (پیام دوباره از messenger) → مصرف‌کننده idempotent، نه رویداد. + +## خروجی + +- `src/Shared/Event/` — قرارداد رویداد + outbox +- `src/Report/` — دو گزارش +- `assets/admin/pages/ResourceUtilizationPage.tsx` + `PlanAccuracyPage.tsx` +- `docs/api/reports.md` + `docs/architecture/domain-events.md`