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.
This commit is contained in:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -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 قانون
۱۱ پکیج و دفتر اعتبار ── ۱۲ دوره درمان ── ۱۳ لغو/عدم‌حضور/لیست انتظار
۱۴ رویدادها و گزارش بهره‌وری
```
+93
View File
@@ -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` را باز کند، بدون اجبار.
@@ -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` برای شهر
(هرگز `<select>` بومی)
- `RoomsPage.tsx` — زیرصفحهٔ شعبه، `<BackButton fallback="/admin/branches" />`
- مسیرها در `App.tsx`: `/admin/branches`, `/admin/branches/new`, `/admin/branches/:uuid`,
`/admin/branches/:uuid/rooms`
@@ -0,0 +1,108 @@
# دیتابیس — تسک ۰۱
MariaDB 11.8 · Doctrine ORM 3.6 · همهٔ timestamp ها `INT` (Unix)
## `branches`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | ارجاع خارجی |
| `entity_type` | VARCHAR(10) NOT NULL | `doctor` \| `clinic` |
| `entity_id` | INT NOT NULL | |
| `name` | VARCHAR(150) NOT NULL | |
| `phone` | VARCHAR(20) NULL | |
| `address` | TEXT NULL | |
| `city_id` | INT NULL | FK منطقی به `categories.id` با `bundle='city'` |
| `province_id` | INT NULL | همان الگو با `bundle='state'` |
| `latitude` | DOUBLE NULL | |
| `longitude` | DOUBLE NULL | |
| `timezone` | VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran' | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at` / `updated_at` | INT NOT NULL | |
ایندکس‌ها:
```sql
KEY idx_branches_tenant (entity_type, entity_id, active)
UNIQUE KEY uniq_branches_uuid (uuid)
```
> `entity_type, entity_id` ستون‌های اول‌اند — شرطِ `TenantFilter` وگرنه از ایندکس استفاده نمی‌کند.
`timezone` از روز اول هست چون مستند بند ۹ می‌گوید ذخیره‌سازی UTC و نمایش محلی؛ امروز همه‌جا
`Asia/Tehran` است ولی افزودن ستون بعداً یعنی backfill روی داده‌های زمان‌دار.
## `branch_working_hours`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE CASCADE |
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه |
| `sequence` | TINYINT NOT NULL DEFAULT 0 | بازهٔ چندم آن روز |
| `start_minute` | SMALLINT NOT NULL | ۰..۱۴۴۰ |
| `end_minute` | SMALLINT NOT NULL | > `start_minute` |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
```sql
UNIQUE KEY uniq_branch_day_seq (branch_id, day_of_week, sequence)
KEY idx_bwh_branch_day (branch_id, day_of_week, active)
```
بدون ستون tenant — فرزند aggregate با ریشهٔ `branches` است و uuid از request نمی‌گیرد
(همیشه از راه `/branch/{uuid}/working-hours` لود می‌شود). در `GlobalTables::AGGREGATE_CHILDREN`
با ریشهٔ صریح ثبت شود.
## `rooms`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | **از request می‌آید** → پس جفت tenant خودش را دارد |
| `entity_type` / `entity_id` | VARCHAR(10)/INT NOT NULL | از `branch` در سازنده مشتق می‌شود |
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE CASCADE |
| `name` | VARCHAR(120) NOT NULL | |
| `room_type` | VARCHAR(60) NULL | متن آزاد، تعریف کلینیک |
| `capacity` | SMALLINT NOT NULL DEFAULT 1 | ظرفیت هم‌زمان |
| `floor` | VARCHAR(20) NULL | ویژگی آزاد — مستند بند ۶ |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at` / `updated_at` | INT NOT NULL | |
```sql
KEY idx_rooms_tenant (entity_type, entity_id, active)
KEY idx_rooms_branch (branch_id, active)
```
## تغییر جدول موجود
```sql
ALTER TABLE doctor_addresses
ADD COLUMN branch_id INT NULL,
ADD CONSTRAINT fk_doctor_addresses_branch
FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE SET NULL,
ADD KEY idx_doctor_addresses_branch (branch_id);
```
هیچ ستونی حذف یا تغییر نوع نمی‌دهد. `location_id` در JSON برنامهٔ هفتگی دست‌نخورده می‌ماند.
## 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:branch:backfill # dry-run
ddev exec php bin/console app:branch:backfill --force
```
## طبقه‌بندی tenant
| جدول | وضعیت | ثبت در |
|---|---|---|
| `branches` | جفت tenant | `TenantOwnedTrait` |
| `rooms` | جفت tenant | `TenantOwnedTrait` (uuid از request می‌آید) |
| `branch_working_hours` | فرزند aggregate | `GlobalTables::AGGREGATE_CHILDREN` → ریشه `Branch` |
بعد از migration:
```bash
ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php
```
@@ -0,0 +1,95 @@
# نکات پیاده‌سازی — تسک ۰۱
## ۱. چرا شعبه بالای آدرس می‌نشیند، نه جای آن
سه مصرف‌کنندهٔ زنده به `doctor_addresses.id` وابسته‌اند:
1. `WeeklySchedule.setting[day].sessions[].location_id` (JSON)
2. `SlotCalculatorService::buildSessionSlots()` که آن را در هر اسلات کپی می‌کند
3. `AppointmentController::bookingLocations()` که به سایت عمومی `location_uuid` می‌دهد
عوض کردن این قرارداد در build هیچ‌کدام از سه ریپو خطا نمی‌دهد — فقط در runtime آدرس گم می‌شود.
پس `branch_id` روی آدرس اضافه می‌شود و آدرس همان‌جا می‌ماند.
## ۲. پزشک مستقل هم شعبه دارد
وسوسه می‌شود که شعبه را فقط برای `entity_type=clinic` بسازیم. نکن. اگر پزشک مستقل شعبه
نداشته باشد، تسک ۰۲ باید دو مسیر کد برای «منبع مال شعبه» و «منبع مال پزشک» داشته باشد و
تسک ۰۶ هر دو را جدا حساب کند. مطب شخصی = شعبه‌ای با `entity_type=doctor`.
## ۳. حذف شعبه
هرگز `CASCADE` روی حذف شعبه به منابع و نوبت‌ها نده. `DELETE` فقط وقتی مجاز است که:
- هیچ `Room` فعالی نداشته باشد، **و**
- هیچ `Resource` فعالی (تسک ۰۲) نداشته باشد، **و**
- هیچ نوبت آیندهٔ فعالی روی منابعش نباشد (تسک ۰۷)
تا آن تسک‌ها نیامده‌اند، فقط شرط اول را چک کن ولی سرویس را طوری بنویس که افزودن دو شرط
بعدی یک خط باشد (لیست `DeletionGuardInterface` و تزریق آرایه‌ای از گاردها).
`active=false` مسیر اصلی است، نه `DELETE`.
## ۴. ساعت کاری — دقیقه، نه رشته
```php
// ❌ اشتباه: مقایسهٔ رشته‌ای در تسک ۰۶ می‌شکند ("9:00" < "10:00" غلط است)
private string $startTime = '09:00';
// ✅ درست
private int $startMinute = 540;
```
اعتبارسنجی در `WorkingHoursService`:
- `0 <= start < end <= 1440`
- بازه‌های یک روز نباید هم‌پوشانی داشته باشند (مرتب کن، بعد `prev.end <= next.start`)
- `sequence` را خود سرویس بعد از مرتب‌سازی تخصیص می‌دهد، نه کلاینت
## ۵. تفسیر «شعبه بدون ساعت کاری»
تصمیم صریح: **تعریف‌نشده، نه همیشه‌باز.** تسک ۰۳ وقتی برای شعبه‌ای ساعتی پیدا نکرد، به
رفتار فعلی برمی‌گردد (برنامهٔ پزشک تنها مرجع است). این باعث می‌شود همهٔ داده‌های موجود
بدون ساعت کاری شعبه دقیقاً مثل امروز کار کنند.
این نکته را در `docs/api/branch.md` بنویس، وگرنه اولین کسی که کش را دیباگ می‌کند فکر می‌کند
باگ است.
## ۶. edge case ها
| حالت | رفتار درست |
|---|---|
| شعبه در محیط A، اتاق ساخته‌شده با uuid شعبهٔ محیط B | `404``TenantOwnershipChecker::belongsTo` قبل از هر کاری |
| دو شعبه هم‌نام در یک محیط | مجاز (نام یکتا نیست؛ آدرس فرق دارد) |
| `capacity = 0` | `422` — حداقل ۱ |
| ساعت کاری روز جمعه خالی | معتبر — یعنی شعبه جمعه بسته است |
| شعبه‌ای که تنها شعبهٔ محیط است و غیرفعال می‌شود | مجاز، ولی هشدار در UI: «هیچ شعبهٔ فعالی باقی نمی‌ماند» |
| ساعت شبانه‌روزی | `start=0, end=1440` — نه دو ردیف |
## ۷. تست
```
tests/Branch/BranchCrudTest.php
- ساخت شعبه با نقش مالک کلینیک → 201 و tenant درست
- ساخت با نقش منشیِ بدون محیط انتخاب‌شده → 403
- دیدن شعبهٔ محیط دیگر → 404 (نه 403)
tests/Branch/WorkingHoursTest.php
- هفت روز معتبر → 200 و بازخوانی یکسان
- end <= start → 422
- دو بازهٔ هم‌پوشان در یک روز → 422
- بازهٔ شبانه‌روزی 0..1440 → 200
tests/Branch/BranchDeletionTest.php
- حذف شعبهٔ دارای اتاق فعال → 422
- حذف شعبهٔ خالی → 204
tests/Shared/TenantSchemaCoverageTest.php ← باید سبز بماند
```
اجرا:
```bash
ddev exec php bin/phpunit tests/Branch
ddev exec php vendor/bin/phpstan analyse src/Branch
```
## ۸. مستندات
`docs/api/branch.md` بساز (الگو: `docs/api/staff.md`). در `docs/api/README.md` هم اضافه کن.
در `docs/architecture/tenancy.md` جدول طبقه‌بندی را با سه جدول جدید به‌روز کن.
@@ -0,0 +1,61 @@
# تسک ۰۱ — شعبه (Branch) و اتاق (Room)
**فاز:** ۱ (هسته) · **وابستگی:** — · **زمان:** ۱۰-۱۲ ساعت
---
## هدف
سطح سوم مکان را به مدل اضافه کن: امروز `(entity_type, entity_id)` می‌گوید داده مال کدام
محیط است، ولی نمی‌گوید در کدام **ساختمان** و کدام **اتاق**. مستند بند ۴ سه سطح می‌خواهد
و «منابع همیشه مال شعبه‌اند چون فیزیکی‌اند» — بدون شعبه، تسک ۰۲ جایی برای نشستن ندارد.
## وضعیت فعلی
- محل مراجعه امروز `DoctorAddress` است و `location_id` هر شیفت در
`WeeklySchedule.setting[day].sessions[].location_id` به `doctor_addresses.id` اشاره می‌کند
(`SlotCalculatorService::buildSessionSlots()` آن را در هر اسلات کپی می‌کند).
- `Clinic` هیچ فیلد شعبه‌ای ندارد؛ یک آدرس متنی تخت دارد.
- ساعت کاری شعبه وجود ندارد — ساعت کاری فقط روی برنامهٔ پزشک است.
## دامنه
**هست:** entity های `Branch` و `Room`، ساعت کاری هفتگی شعبه، CRUD پنل ادمین،
پل زدن `DoctorAddress.branch_id` برای اینکه شیفت‌های موجود بدون تغییر به شعبه نگاشت شوند.
**نیست:** استفاده از شعبه در محاسبهٔ اسلات (تسک ۰۳)، اتاق به‌عنوان منبع قابل رزرو (تسک ۰۲).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/v1/branches` | لیست شعب محیط جاری (paginated) |
| POST | `/api/v1/branch` | ساخت شعبه |
| GET | `/api/v1/branch/{uuid}` | جزئیات + ساعت کاری |
| PATCH | `/api/v1/branch/{uuid}` | ویرایش |
| DELETE | `/api/v1/branch/{uuid}` | حذف (فقط بدون منبع/اتاق فعال) |
| PUT | `/api/v1/branch/{uuid}/working-hours` | ثبت ساعت کاری هفتگی |
| GET | `/api/v1/branch/{uuid}/rooms` | اتاق‌های شعبه |
| POST/PATCH/DELETE | `/api/v1/room[/{uuid}]` | CRUD اتاق |
## معیار پذیرش
- ✅ موفق: کلینیک با توکن مالک `POST /api/v1/branch` می‌زند → `201` و شعبه با
`entity_type=clinic, entity_id=<id>` ثبت می‌شود. `GET /api/v1/branches` همان را برمی‌گرداند.
- ✅ موفق: `PUT /branch/{uuid}/working-hours` با هفت روز → `200`؛ `GET /branch/{uuid}` همان
ساختار را با کلیدهای `0..6` (۰=شنبه) برمی‌گرداند.
- ❌ خطا: کلینیک B با uuid شعبهٔ کلینیک A → `404` با `ERR_NOT_FOUND_001` (نه ۴۰۳ — طبق
رفتار `TenantFilter`).
- ❌ خطا: `DELETE` شعبه‌ای که اتاق فعال دارد → `422` با پیام فارسی «شعبه دارای اتاق فعال است».
- ⚠️ مرزی: پزشک مستقل (محیط `doctor`) هم می‌تواند شعبه بسازد — «مطب» یک شعبه است.
اولین شعبه از روی `DoctorAddress` موجود ساخته می‌شود، نه دستی.
- ⚠️ مرزی: ساعت کاری با `end_time <= start_time``422`.
- ⚠️ مرزی: شعبه بدون ساعت کاری معتبر است (وراثت: تسک ۰۳ آن را «همیشه باز» تفسیر نمی‌کند،
«تعریف‌نشده» تفسیر می‌کند).
## خروجی
- `src/Branch/` کامل با تست
- `assets/admin/pages/BranchesPage.tsx` + `BranchFormPage.tsx` + `RoomsPage.tsx`
- `docs/api/branch.md`
- migration + دستور `app:branch:backfill` برای ساخت شعبهٔ اولیه از آدرس‌های موجود
@@ -0,0 +1,140 @@
# معماری — تسک ۰۲
## ساختار فایل
```
src/Resource/
├── Controller/
│ ├── ResourceController.php
│ ├── ResourceTypeController.php
│ ├── SkillController.php
│ └── ResourcePoolController.php
├── Entity/
│ ├── ResourceType.php
│ ├── ClinicResource.php # نامِ کلاس عمداً Resource نیست (تداخل با کلمهٔ رزرو PHP نیست، ولی با Symfony/Doctrine ابهام دارد)
│ ├── Skill.php
│ ├── ResourceSkill.php
│ ├── ResourcePool.php
│ └── ResourcePoolMember.php
├── Repository/…
├── Service/
│ ├── ResourceService.php
│ ├── SkillAssignmentService.php
│ ├── ResourcePoolService.php
│ └── ResourceLinker.php # پل بین Doctor/ClinicStaff/Room و ClinicResource
└── Command/
└── BackfillResourceCommand.php
```
## `ClinicResource`
```php
#[ORM\Entity(repositoryClass: ClinicResourceRepository::class)]
#[ORM\Table(name: 'clinic_resources')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_resources_tenant')]
class ClinicResource
{
use TenantOwnedTrait;
private string $uuid;
private Branch $branch; // منابع همیشه مال شعبه‌اند — فیزیکی‌اند
private ResourceType $type;
private string $name;
private int $capacity = 1; // ظرفیت هم‌زمان
private int $setupMinutes = 0;
private int $cleanupMinutes = 0;
private array $attributes = []; // JSON آزاد: gender, device_model, floor
private bool $active = true;
// ── پل به موجودیت‌های موجود؛ حداکثر یکی غیر-null است ──
private ?Doctor $doctor = null;
private ?ClinicStaff $staff = null;
private ?Room $room = null;
}
```
### چرا پل، نه ادغام
`Doctor` و `ClinicStaff` و `Room` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند
(`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیل آن‌ها به زیرکلاس
`Resource` یعنی مهاجرت هم‌زمان همهٔ آن مسیرها. به‌جایش:
```
Doctor 1 ──0..1 ClinicResource (type=doctor)
ClinicStaff 1 ──0..1 ClinicResource (type=staff)
Room 1 ──0..1 ClinicResource (type=room)
ClinicResource بدون پل = دستگاه/تجهیزات
```
`ResourceLinker` تنها نقطه‌ای است که این نگاشت را می‌داند:
```php
final class ResourceLinker
{
/** منبعِ متناظر با یک پرسنل؛ اگر نبود می‌سازد. */
public function forStaff(ClinicStaff $staff): ClinicResource { }
public function forDoctor(Doctor $doctor, Branch $branch): ClinicResource { }
/** برعکس: منبع → موجودیت اصلی، برای نمایش در UI. */
public function subject(ClinicResource $r): Doctor|ClinicStaff|Room|null { }
}
```
هیچ سرویس دیگری نباید مستقیم `$resource->getStaff()` را برای تصمیم‌گیری بخواند.
## مهارت‌ها — جدول، نه قانون
مستند بند ۶ صریح است: «کدام اپراتور مجاز است با کدام دستگاه کار کند» یک **اطلاعات** است،
نه یک قانون. با ۵۰ اپراتور و ۲۰۰ سرویس، سپردنش به موتور قوانین یعنی ۱۰٬۰۰۰ قانون.
```
skills (uuid, name, tenant)
resource_skills (resource_id, skill_id, level) ← جدول واسط ساده
```
`level` (۱..۵) از روز اول هست چون تسک ۰۶ استراتژی «حفظ متخصص‌ها» را روی همین می‌سازد
و افزودنش بعداً یعنی backfill با حدس.
## استخر منابع
```php
class ResourcePool
{
use TenantOwnedTrait;
private Branch $branch; // استخر درون یک شعبه است — منبعِ شعبهٔ دیگر جایگزین نیست
private ResourceType $type; // اعضا باید هم‌نوع باشند
private string $name;
private Collection $members; // ResourcePoolMember
}
```
قاعدهٔ اعتبار در `ResourcePoolService::replaceMembers()`:
همهٔ اعضا باید `branch` و `type` یکسان با خود استخر داشته باشند، وگرنه `422`.
دلیل: تسک ۰۶ فرض می‌کند «هر عضو استخر جایگزین کامل دیگری است» — اگر یکی در شعبهٔ
دیگری باشد، بیمار در ساختمان اشتباه می‌ایستد.
## اعتبارسنجی `attributes`
JSON آزاد است ولی نه بی‌قید:
```php
// ResourceService::normalizeAttributes()
// - کلید: [a-z_]{1,40}
// - مقدار: string|int|bool|float فقط — نه آرایه، نه object
// - حداکثر ۲۰ کلید
```
دلیل محدودیت اسکالر: تسک ۰۵ قید `same_gender` و تسک ۰۹ شرط‌های منبع را روی همین
مقادیر با مقایسهٔ ساده می‌سنجند. آرایهٔ تودرتو یعنی مقایسهٔ دلخواه، یعنی همان چیزی که
مستند بند ۸ ممنوع کرده.
کلیدهای شناخته‌شده (قرارداد، نه اجبار): `gender`, `device_model`, `floor`, `brand`.
## پنل ادمین
- `ResourcesPage.tsx``DataTable` با فیلتر شعبه/نوع/فعال، همه در URL (`useUrlState`)
- `ResourceFormPage.tsx``SearchableSelect` برای شعبه و نوع، چیپ برای مهارت‌ها،
`PriceInput` لازم نیست، ولی برای `setup/cleanup` عدد ساده با پسوند «دقیقه»
- `SkillsPage.tsx`, `ResourceTypesPage.tsx`, `ResourcePoolsPage.tsx` — لیست‌های ساده
- همهٔ زیرصفحه‌ها `backTo` یا `<BackButton />` دارند
@@ -0,0 +1,143 @@
# دیتابیس — تسک ۰۲
## `resource_types`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `code` | VARCHAR(40) NOT NULL | `doctor`, `staff`, `room`, `device`, … |
| `name` | VARCHAR(100) NOT NULL | نام نمایشی فارسی |
| `is_system` | TINYINT(1) NOT NULL DEFAULT 0 | نوع‌های `doctor/staff/room` توسط backfill ساخته می‌شوند و حذف نمی‌شوند |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_resource_types_tenant (entity_type, entity_id, active)
UNIQUE KEY uniq_rt_tenant_code (entity_type, entity_id, code)
```
## `clinic_resources`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | از `branch` مشتق می‌شود |
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE RESTRICT |
| `resource_type_id` | INT NOT NULL | FK → `resource_types.id` ON DELETE RESTRICT |
| `name` | VARCHAR(150) NOT NULL | |
| `capacity` | SMALLINT NOT NULL DEFAULT 1 | ظرفیت هم‌زمان |
| `setup_minutes` | SMALLINT NOT NULL DEFAULT 0 | آماده‌سازی پیش از بیمار |
| `cleanup_minutes` | SMALLINT NOT NULL DEFAULT 0 | تمیزکاری پس از بیمار |
| `attributes` | JSON NULL | اسکالر فقط، حداکثر ۲۰ کلید |
| `doctor_id` | INT NULL UNIQUE | FK → `doctors.id` ON DELETE CASCADE |
| `staff_id` | INT NULL UNIQUE | FK → `clinic_staff.id` ON DELETE CASCADE |
| `room_id` | INT NULL UNIQUE | FK → `rooms.id` ON DELETE CASCADE |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_resources_tenant (entity_type, entity_id, active)
KEY idx_resources_branch_type (branch_id, resource_type_id, active)
UNIQUE KEY uniq_resource_doctor (doctor_id)
UNIQUE KEY uniq_resource_staff (staff_id)
UNIQUE KEY uniq_resource_room (room_id)
```
سه کلید یکتای تهی‌پذیر تضمین می‌کنند یک پزشک/پرسنل/اتاق بیش از یک منبع نگیرد.
MariaDB چند `NULL` را در UNIQUE می‌پذیرد، پس دستگاه‌های بدون پل مشکلی ندارند.
> **قید سطح اپلیکیشن (نه DB):** حداکثر یکی از `doctor_id`/`staff_id`/`room_id` غیر-NULL.
> در سازندهٔ entity اجبار شود؛ MariaDB `CHECK` چندستونی را قابل اتکا اجرا نمی‌کند.
## `skills` و `resource_skills`
```sql
CREATE TABLE skills (
id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
name VARCHAR(120) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
KEY idx_skills_tenant (entity_type, entity_id, active)
);
CREATE TABLE resource_skills (
id INT PRIMARY KEY AUTO_INCREMENT,
resource_id INT NOT NULL,
skill_id INT NOT NULL,
level TINYINT NOT NULL DEFAULT 1, -- 1..5
created_at INT NOT NULL,
UNIQUE KEY uniq_resource_skill (resource_id, skill_id),
KEY idx_resource_skills_skill (skill_id, level),
CONSTRAINT fk_rs_resource FOREIGN KEY (resource_id) REFERENCES clinic_resources(id) ON DELETE CASCADE,
CONSTRAINT fk_rs_skill FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE RESTRICT
);
```
`idx_resource_skills_skill (skill_id, level)` عمدی است: پرس‌وجوی داغِ تسک ۰۶
«کدام منابع مهارت X را دارند» از این سمت می‌آید.
`resource_skills` بدون ستون tenant — فرزند aggregate با ریشهٔ `ClinicResource`،
و uuid از request نمی‌گیرد (همیشه از `/resource/{uuid}/skills`).
## `resource_pools` و `resource_pool_members`
```sql
CREATE TABLE resource_pools (
id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
branch_id INT NOT NULL,
resource_type_id INT NOT NULL,
name VARCHAR(150) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
KEY idx_pools_tenant (entity_type, entity_id, active),
KEY idx_pools_branch (branch_id, resource_type_id),
CONSTRAINT fk_pool_branch FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE,
CONSTRAINT fk_pool_type FOREIGN KEY (resource_type_id) REFERENCES resource_types(id) ON DELETE RESTRICT
);
CREATE TABLE resource_pool_members (
id INT PRIMARY KEY AUTO_INCREMENT,
pool_id INT NOT NULL,
resource_id INT NOT NULL,
priority SMALLINT NOT NULL DEFAULT 0, -- ترتیب ترجیح در استراتژی انتخاب
UNIQUE KEY uniq_pool_resource (pool_id, resource_id),
KEY idx_pool_members_resource (resource_id),
CONSTRAINT fk_pm_pool FOREIGN KEY (pool_id) REFERENCES resource_pools(id) ON DELETE CASCADE,
CONSTRAINT fk_pm_resource FOREIGN KEY (resource_id) REFERENCES clinic_resources(id) ON DELETE CASCADE
);
```
## Migration و backfill
```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:resource:backfill # dry-run
ddev exec php bin/console app:resource:backfill --force
```
`app:resource:backfill` برای هر محیط:
1. سه `resource_type` سیستمی می‌سازد (`doctor`, `staff`, `room`) اگر نباشند
2. هر `Room` فعال → یک منبع با `capacity` همان اتاق
3. هر `ClinicStaff` فعال → یک منبع `type=staff` در شعبهٔ اول محیط
4. هر `Doctor` دارای `WeeklySchedule` در آن محیط → یک منبع `type=doctor`
idempotent باشد: اجرای دوباره چیزی دوباره نمی‌سازد.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `resource_types`, `clinic_resources`, `skills`, `resource_pools` | جفت tenant |
| `resource_skills`, `resource_pool_members` | `AGGREGATE_CHILDREN` |
@@ -0,0 +1,111 @@
# نکات پیاده‌سازی — تسک ۰۲
## ۱. ظرفیت: یک ردیف با ظرفیت ۳، نه سه ردیف
مستند بند ۶ صریح است و دلیلش در تسک ۰۶ روشن می‌شود: با سه ردیف، موتور جستجو باید سه
تقویم را ادغام کند و «کدام تخت» بشود یک تصمیم که هیچ‌کس نمی‌خواهد بگیرد. با ظرفیت ۳،
شرط اشغال یک شمارش ساده است:
```
SELECT COUNT(*) FROM resource_occupancy
WHERE resource_id = ? AND [بازه‌ها تداخل دارند] AND status IN (…)
→ اگر < capacity، جا هست
```
این را همین حالا در `docs/api/resource.md` بنویس تا کسی وسوسه نشود «اتاق ۱ تخت ۲» بسازد.
## ۲. `ClinicResource` نه `Resource`
نام کلاس `Resource` در PHP مشکل ندارد ولی در این کدبیس با `App\Shared\…` و مفهوم
«منبع API» قاطی می‌شود و در جستجوی کد نویز شدیدی می‌سازد. نام جدول `clinic_resources`
هم به همان دلیل. متغیرها و متدها می‌توانند `$resource` باشند.
## ۳. پل‌ها و `active`
`ClinicStaff.active = false` باید `ClinicResource.active` را هم false کند، وگرنه پرسنل
غیرفعال همچنان در جستجوی وقت ظاهر می‌شود. این را در `StaffService` (تسک موجود) با یک
فراخوانی به `ResourceLinker::syncActive()` انجام بده — نه با Doctrine lifecycle callback،
چون callback در `getArrayResult()` اجرا نمی‌شود و رفتار نامتقارن می‌سازد.
عکسش برقرار نیست: غیرفعال کردن منبع، پرسنل را غیرفعال نمی‌کند (پرسنل ممکن است فقط
اداری باشد).
## ۴. `setup_minutes` / `cleanup_minutes` چه هستند و چه نیستند
- **جزو نوبت بیمار نیستند** — بیمار ساعت ۱۰:۰۰ می‌آید و ۱۰:۳۰ می‌رود
- **منبع را اشغال می‌کنند** — یونیت از ۹:۵۵ تا ۱۰:۴۰ در دسترس نیست
پس در تسک ۰۷ بازهٔ ثبت‌شده در `resource_occupancy` گسترده‌تر از بازهٔ نوبت است. الان فقط
ستون را بساز و در `docs/api/resource.md` این تفاوت را بنویس؛ محاسبه‌اش کار تسک ۰۶ است.
اشتباه رایج: این را با `WeeklySchedule.meta.buffer_minutes` موجود یکی گرفتن. آن یکی
فاصلهٔ سراسری بین دو نوبتِ **پزشک** است؛ این یکی per منبع است. تا وقتی حالت `resource`
نیامده، هر دو کنار هم زندگی می‌کنند و `buffer_minutes` دست‌نخورده می‌ماند.
## ۵. مهارت را با `jobTitle` قاطی نکن
`ClinicStaff.jobTitle` متن آزاد و برای نمایش است. مهارت یک موجودیت با هویت است که در
شرط نیازمندی (تسک ۰۵) و شرط قانون (تسک ۰۹) استفاده می‌شود. هیچ‌جا `jobTitle` را برای
تصمیم‌گیری parse نکن.
## ۶. edge case ها
| حالت | رفتار درست |
|---|---|
| منبع بدون هیچ پل (دستگاه) | معتبر — حالت عادی تجهیزات |
| دو پل هم‌زمان (`doctor_id` و `staff_id`) | `422` در سازنده |
| حذف `resource_type` که منبع دارد | `422` |
| حذف `resource_type` با `is_system=1` | `422` همیشه |
| غیرفعال کردن منبعی که نوبت آیندهٔ فعال دارد | مجاز، ولی پاسخ شامل `warnings[]` با تعداد نوبت‌ها |
| استخر با صفر عضو | معتبر (در حال ساخت)، ولی تسک ۰۶ آن را «هیچ منبعی» می‌بیند |
| `capacity` روی منبع `type=doctor` بزرگ‌تر از ۱ | `422` — پزشک هم‌زمان دو بیمار ندارد |
| `level` خارج از ۱..۵ | `422` |
| مهارت محیط A روی منبع محیط B | `404` (پیش از هر بررسی: `TenantOwnershipChecker`) |
## ۷. کارایی
پرس‌وجوی داغ تسک ۰۶: «منابع فعالِ شعبهٔ X از نوع Y که مهارت Z را دارند».
```php
// ClinicResourceRepository::findEligible()
// یک کوئری با JOIN به resource_skills، بدون N+1
$qb->select('r')
->from(ClinicResource::class, 'r')
->join('r.skills', 'rs')
->where('r.branch = :branch')
->andWhere('r.type = :type')
->andWhere('r.active = true')
->andWhere('rs.skill IN (:skills)')
->groupBy('r.id')
->having('COUNT(DISTINCT rs.skill) = :skillCount'); // همهٔ مهارت‌ها، نه یکی
```
`HAVING COUNT(DISTINCT …)` عمدی است: نیازمندی «مهارت الف و ب» یعنی هر دو، نه یکی.
## ۸. تست
```
tests/Resource/ResourceCrudTest.php
- ساخت منبع + خواندن → tenant و branch درست
- منبع با شعبهٔ محیط دیگر → 404
- capacity=0 → 422 · capacity=2 روی type=doctor → 422
- دو پل هم‌زمان → 422
tests/Resource/SkillAssignmentTest.php
- PUT skills جایگزینی کامل (حذف نداده‌ها)
- level خارج بازه → 422
- حذف مهارتِ در استفاده → 422
tests/Resource/ResourcePoolTest.php
- عضو از شعبهٔ دیگر → 422
- عضو از نوع دیگر → 422
tests/Resource/ResourceEligibilityTest.php
- findEligible با دو مهارت: منبعی که فقط یکی را دارد برنمی‌گردد
tests/Resource/BackfillResourceTest.php
- idempotent: دو بار اجرا = یک بار
tests/Shared/TenantSchemaCoverageTest.php
tests/Shared/TenantLookupInventoryTest.php ← شمارنده به‌روز شود
```
## ۹. مستندات
`docs/api/resource.md` بساز. در `docs/api/staff.md` یک بخش «رابطه با منبع» اضافه کن.
`docs/architecture/tenancy.md` جدول طبقه‌بندی را به‌روز کن.
@@ -0,0 +1,75 @@
# تسک ۰۲ — مدل منبع: نوع منبع، منبع، مهارت، استخر
**فاز:** ۱ (هسته) · **وابستگی:** ۰۱ · **زمان:** ۱۴-۱۸ ساعت
---
## هدف
قانون طلایی اول مستند: «تقویم مال منبع است، نه مال پزشک». امروز تنها موجودیتی که
می‌تواند اشغال شود پزشک است و پرسنل (`ClinicStaff`) فقط یک برچسب روی سرویس و نوبت است.
این تسک لایهٔ منبع را می‌سازد: هر چیزی که ممکن است اشغال باشد — پزشک، اپراتور، دستیار،
دستگاه، اتاق، تخت، یونیت.
## وضعیت فعلی
```php
// src/Staff/Entity/ClinicStaff.php — تنها «منبع» امروز
private string $fullName;
private ?string $jobTitle; // متن آزاد، بدون معنای ساختاری
private bool $active;
private ?User $user; // برای ورود به پنل
```
بدون تقویم، بدون ظرفیت، بدون مهارت. `ServiceItem.staffMembers` یک ManyToMany به همین است
و `Appointment.staff_id` یک ارجاع تکی — هیچ‌کدام تداخل زمانی را بررسی نمی‌کنند.
## دامنه
**هست:** `ResourceType`، `Resource`، `Skill`، `ResourceSkill`، `ResourcePool`،
`ResourcePoolMember`، ویژگی‌های آزاد (`attributes` JSON)، ظرفیت هم‌زمان،
زمان آماده‌سازی/تمیزکاری per منبع، CRUD پنل، و **پل زدن `ClinicStaff` و `Doctor` و `Room`
به `Resource`**.
**نیست:** تقویم و مرخصی (تسک ۰۳)، نیازمندی منبع per بخش (تسک ۰۵)، محاسبهٔ اشغال (تسک ۰۶/۰۷).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET/POST | `/api/v1/resource-types` | نوع منبع (کلینیک خودش تعریف می‌کند) |
| PATCH/DELETE | `/api/v1/resource-type/{uuid}` | |
| GET | `/api/v1/resources` | لیست با فیلتر `branch_uuid`, `type_uuid`, `active` |
| POST | `/api/v1/resource` | ساخت منبع |
| GET/PATCH/DELETE | `/api/v1/resource/{uuid}` | |
| GET/POST | `/api/v1/skills` | مهارت‌ها |
| PATCH/DELETE | `/api/v1/skill/{uuid}` | |
| PUT | `/api/v1/resource/{uuid}/skills` | جایگزینی کامل مهارت‌های منبع |
| GET/POST | `/api/v1/resource-pools` | استخر منابع قابل جایگزینی |
| PATCH/DELETE | `/api/v1/resource-pool/{uuid}` | |
| PUT | `/api/v1/resource-pool/{uuid}/members` | جایگزینی کامل اعضا |
## معیار پذیرش
- ✅ موفق: کلینیک نوع منبع «دستگاه لیزر» می‌سازد، سه منبع از آن نوع در شعبهٔ مرکزی ثبت
می‌کند، یک استخر «لیزرهای آلکساندرایت» می‌سازد و هر سه را عضو می‌کند →
`GET /api/v1/resource-pool/{uuid}` هر سه را با `branch_uuid` برمی‌گرداند.
- ✅ موفق: مهارت «لیزر آلکساندرایت» ساخته و به دو اپراتور داده می‌شود →
`GET /api/v1/resources?skill_uuid=…` فقط همان دو را برمی‌گرداند.
- ✅ موفق: بعد از اجرای `app:resource:backfill`، هر `ClinicStaff` فعال یک `Resource` با
`type=staff` و هر `Doctor` دارای برنامه یک `Resource` با `type=doctor` دارد.
- ❌ خطا: منبع با `branch_uuid` متعلق به محیط دیگر → `404`.
- ❌ خطا: عضو کردن منبعی از شعبهٔ A در استخری که منابعش در شعبهٔ B هستند → `422`
«همهٔ اعضای استخر باید در یک شعبه باشند».
- ⚠️ مرزی: `capacity = 3` روی اتاق تزریق → یک ردیف، نه سه. `GET` مقدار ۳ را برمی‌گرداند.
- ⚠️ مرزی: `setup_minutes = 0, cleanup_minutes = 10` → معتبر.
- ⚠️ مرزی: حذف مهارتی که به منبعی داده شده → `422`؛ باید اول از منابع برداشته شود.
- ⚠️ مرزی: `attributes` با کلید ناشناخته → پذیرفته می‌شود (عمداً آزاد)، ولی مقدار غیر
اسکالر → `422`.
## خروجی
- `src/Resource/` کامل با تست
- صفحات پنل: `ResourcesPage`, `ResourceFormPage`, `ResourceTypesPage`, `SkillsPage`, `ResourcePoolsPage`
- `docs/api/resource.md`
- `app:resource:backfill` (dry-run پیش‌فرض)
@@ -0,0 +1,139 @@
# معماری — تسک ۰۳
## ساختار فایل
```
src/Resource/
├── Entity/
│ ├── ResourceCalendar.php # شیفت تکرارشوندهٔ هفتگی
│ └── ResourceException.php # مرخصی/غیبت/سرویس دستگاه/تعطیلی موردی
├── Controller/
│ ├── ResourceCalendarController.php
│ └── ResourceExceptionController.php
├── Service/
│ ├── ResourceCalendarService.php
│ └── ResourceAvailabilityService.php ← قلب این تسک
└── Repository/…
src/Holiday/
├── Entity/
│ ├── NationalHoliday.php
│ └── TenantHolidayOverride.php
├── Controller/HolidayController.php
├── Service/HolidayResolver.php
└── Command/ImportNationalHolidaysCommand.php
```
## `ResourceAvailabilityService` — قرارداد
```php
final class ResourceAvailabilityService
{
/**
* بازه‌های آزادِ خام یک منبع (بدون در نظر گرفتن نوبت‌ها).
* خروجی: بازه‌های مرتب و ادغام‌شده، بر حسب Unix timestamp.
*
* @return array<array{start:int, end:int}>
*/
public function rawWindows(ClinicResource $resource, int $from, int $to): array;
/**
* چرا این روز خالی است. null یعنی خالی نیست.
* همان قرارداد SlotCalculatorService::explainEmptyDay — پنل به دلیل نیاز دارد.
*/
public function explainEmptyDay(ClinicResource $resource, int $dayStart): ?string;
public const EMPTY_NO_CALENDAR = 'no_calendar';
public const EMPTY_NATIONAL_HOLIDAY = 'national_holiday';
public const EMPTY_RESOURCE_EXCEPTION = 'resource_exception';
public const EMPTY_OUTSIDE_BRANCH_HOURS = 'outside_branch_hours';
public const EMPTY_DAY_OFF = 'day_off';
}
```
## الگوریتم `rawWindows`
```
ورودی: منبع، [from, to)
۱. یک بار برای کل بازه واکشی کن (نه per-day):
- branch_working_hours شعبهٔ منبع (۱ کوئری)
- resource_calendars منبع (۱ کوئری)
- resource_exceptions متداخل با بازه (۱ کوئری)
- national_holidays متداخل با بازه (۱ کوئری)
- tenant_holiday_overrides محیط (۱ کوئری)
۲. برای هر روز از from تا to:
الف) اگر تعطیل رسمی است و override با is_working=true ندارد → روز را رد کن
ب) بازه‌های شیفت منبع آن روزِ هفته را بگیر
ج) اگر شعبه ساعت کاری تعریف‌شده دارد → تقاطع بگیر
اگر ندارد → بازهٔ منبع دست‌نخورده می‌ماند
د) استثناهای متداخل را کسر کن (اتحاد استثناها، بعد تفاضل)
ه) بازه‌های حاصل را به لیست اضافه کن
۳. ادغام بازه‌های مجاور و مرتب‌سازی
```
**پنج کوئری ثابت برای هر بازه، نه رشد خطی با تعداد روز.** این دقیقاً همان کاری است که
`SlotCalculatorService::findNextAvailableStart()` امروز برای پزشک می‌کند و باید حفظ شود؛
تسک ۰۶ روی همین حساب می‌کند که بتواند زیر نیم ثانیه بماند.
## عملیات بازه — یک جای واحد
تقاطع، اتحاد، تفاضل و ادغامِ بازه‌ها در سه تسک بعدی هم لازم است. یک کلاس بدون وابستگی:
```php
// src/Shared/Time/IntervalSet.php
final class IntervalSet
{
/** @param array<array{start:int,end:int}> $intervals */
public static function normalize(array $intervals): array; // مرتب + ادغام مجاور
public static function intersect(array $a, array $b): array;
public static function subtract(array $from, array $minus): array;
public static function union(array $a, array $b): array;
public static function totalSeconds(array $intervals): int;
}
```
خالص و بدون I/O → تست واحد سریع و بدون دیتابیس. هر جای دیگری که بازه جمع/کم می‌کند
باید از این استفاده کند، وگرنه سه پیاده‌سازی با سه باگ مرزی متفاوت خواهیم داشت.
## تعطیلات رسمی
```php
class NationalHoliday // سراسری — GlobalTables::ENTITIES
{
private int $date; // نیمه‌شب روز، Unix
private string $title; // «عید فطر»
private bool $isOfficial; // تعطیل رسمی یا مناسبت غیرتعطیل
}
class TenantHolidayOverride // per محیط
{
use TenantOwnedTrait;
private int $date;
private bool $isWorking; // true = این تعطیل رسمی برای ما کاری است
private ?string $note;
}
```
`HolidayResolver::isClosedFor(EntityContext $ctx, int $dayStart): bool` تنها نقطهٔ ترکیب
این دو است. `Holiday` موجود (per پزشک) دست‌نخورده می‌ماند و در حالت `slot`/`service`
همچنان مرجع است؛ در حالت `resource` هر دو منبع اعمال می‌شوند (اتحاد).
## import تعطیلات
```bash
ddev exec php bin/console app:holiday:import --year=1405 --file=var/holidays-1405.json
```
فایل JSON با تاریخ شمسی؛ تبدیل با `jalaali-js` معادل PHP در `src/Shared/Time/JalaliDate.php`
(اگر نبود، بساز). عمداً از سرویس آنلاین نمی‌خوانیم: تعطیلات ایران سالانه با مصوبه تغییر
می‌کنند و وابستگی به یک API خارجی یعنی جستجوی وقت به آن گره می‌خورد.
## پنل ادمین
- `ResourceCalendarPage.tsx` — گرید هفت‌روزه، هر روز چند بازه، درگ ندارد (فرم ساده)
- `ResourceExceptionsPage.tsx` — لیست + `PersianDatePicker` برای بازه
- `HolidaysSettingsPage.tsx` — تعطیلات رسمی سال با تیک «ما این روز کار می‌کنیم»
- هر سه زیرصفحه‌اند → `backTo` اجباری
@@ -0,0 +1,112 @@
# دیتابیس — تسک ۰۳
## `resource_calendars`
شیفت تکرارشوندهٔ هفتگی یک منبع.
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `resource_id` | INT NOT NULL | FK → `clinic_resources.id` ON DELETE CASCADE |
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه |
| `sequence` | TINYINT NOT NULL DEFAULT 0 | |
| `start_minute` | SMALLINT NOT NULL | |
| `end_minute` | SMALLINT NOT NULL | |
| `valid_from` | INT NULL | شیفت فصلی؛ NULL = از همیشه |
| `valid_to` | INT NULL | NULL = تا همیشه |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
```sql
UNIQUE KEY uniq_rc_resource_day_seq (resource_id, day_of_week, sequence)
KEY idx_rc_resource_day (resource_id, day_of_week, active)
```
`valid_from/valid_to` از روز اول: شیفت تابستانی/زمستانی حالت رایج کلینیک است و
افزودنش بعداً یعنی یا حذف و ثبت دوبارهٔ شیفت‌ها یا یک جدول موازی.
فرزند aggregate با ریشهٔ `ClinicResource`.
## `resource_exceptions`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | **از request می‌آید** → جفت tenant خودش را دارد |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | از منبع مشتق می‌شود |
| `resource_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `type` | VARCHAR(20) NOT NULL | `leave` \| `absence` \| `maintenance` \| `blocked` |
| `start_at` | INT NOT NULL | Unix |
| `end_at` | INT NOT NULL | Unix، `> start_at` |
| `reason` | VARCHAR(255) NULL | |
| `created_by` | INT NULL | FK → `users.id` ON DELETE SET NULL |
| `created_at` | INT NOT NULL | |
```sql
KEY idx_rex_tenant (entity_type, entity_id, start_at)
KEY idx_rex_resource_range (resource_id, start_at, end_at)
```
`idx_rex_resource_range` کوئری داغ است: «استثناهای این منبع که با [from, to) تداخل دارند».
> نوع `blocked` عمداً هست: مسدود کردن دستیِ یک بازه توسط منشی («امروز عصر کسی را نگذار»)
> با مرخصی یکی نیست و گزارش بهره‌وری (تسک ۱۴) باید تفکیکشان کند.
## `national_holidays`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `date` | INT NOT NULL UNIQUE | نیمه‌شب روز به وقت `Asia/Tehran`، Unix |
| `jalali_date` | VARCHAR(10) NOT NULL | `1405-01-13` — برای import و نمایش |
| `title` | VARCHAR(150) NOT NULL | |
| `is_official` | TINYINT(1) NOT NULL DEFAULT 1 | مناسبت غیرتعطیل هم ثبت می‌شود |
| `created_at` | INT NOT NULL | |
```sql
UNIQUE KEY uniq_national_holiday_date (date)
KEY idx_national_holiday_jalali (jalali_date)
```
سراسری — در `GlobalTables::ENTITIES` با دلیل: «تقویم رسمی کشور، مال هیچ محیطی نیست».
## `tenant_holiday_overrides`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `date` | INT NOT NULL | نیمه‌شب روز |
| `is_working` | TINYINT(1) NOT NULL | true = روز رسمیِ تعطیل، برای ما کاری است |
| `note` | VARCHAR(255) NULL | |
| `created_at` | INT NOT NULL | |
```sql
UNIQUE KEY uniq_tho_tenant_date (entity_type, entity_id, date)
KEY idx_tho_tenant (entity_type, entity_id, date)
```
`is_working=false` هم معنی دارد: روزی که رسمی نیست ولی این محیط تعطیل است (مثلاً
تعطیلی سالانهٔ کلینیک). پس این جدول هر دو جهت را می‌پوشاند و `Holiday` موجود فقط برای
سازگاری عقب‌رو در حالت `slot`/`service` می‌ماند.
## 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:holiday:import --year=1405 --file=var/holidays-1405.json
```
`app:resource:calendar:backfill` (اختیاری، در همین تسک): برای هر منبعِ `type=doctor` که از
`WeeklySchedule` ساخته شده، شیفت‌های همان برنامه را به `resource_calendars` کپی می‌کند تا
تقویم منبع از روز اول خالی نباشد. dry-run پیش‌فرض.
## طبقه‌بندی tenant
| جدول | وضعیت | دلیل |
|---|---|---|
| `resource_calendars` | `AGGREGATE_CHILDREN` | ریشه `ClinicResource`؛ uuid از request نمی‌گیرد |
| `resource_exceptions` | جفت tenant | uuid از request می‌آید (`PATCH /resource-exception/{uuid}`) |
| `national_holidays` | `ENTITIES` | تقویم کشوری |
| `tenant_holiday_overrides` | جفت tenant | |
@@ -0,0 +1,133 @@
# نکات پیاده‌سازی — تسک ۰۳
## ۱. `IntervalSet` اول، بقیه بعد
اولین چیزی که می‌نویسی `src/Shared/Time/IntervalSet.php` و تستش است — قبل از هر entity.
سه تسک بعدی روی درستی‌اش حساب می‌کنند و باگ مرزی در تفاضل بازه، در تسک ۰۶ به شکل
«یک اسلات عجیب» ظاهر می‌شود که دیباگش ساعت‌ها می‌برد.
مرزهایی که تست واحد باید بپوشاند:
```php
subtract([[0,100]], [[0,100]]) === [] // کامل
subtract([[0,100]], [[20,30]]) === [[0,20],[30,100]] // وسط
subtract([[0,100]], [[100,200]]) === [[0,100]] // مجاور، نه متداخل
subtract([[0,100]], [[-10,10]]) === [[10,100]] // از چپ بیرون‌زده
intersect([[0,100]], []) === [] // خالی = هیچ، نه همه‌چیز
normalize([[0,50],[50,100]]) === [[0,100]] // ادغام مجاور
normalize([[10,20],[0,5]]) === [[0,5],[10,20]] // مرتب‌سازی
```
قرارداد بازه‌ها: **نیم‌باز `[start, end)`**. همه‌جا. `end == start` یعنی بازهٔ تهی و حذف می‌شود.
## ۲. شعبهٔ بدون ساعت کاری = بی‌قید، نه بسته
```php
$branchHours = $this->branchHoursRepo->forDay($branch, $dow);
$windows = $branchHours === []
? $resourceShifts // بی‌قید
: IntervalSet::intersect($resourceShifts, $branchHours);
```
اگر برعکسش را بنویسی، همهٔ داده‌های موجود (که هیچ شعبه‌ای ساعت کاری ندارد) یک‌شبه
هیچ وقتی نمی‌دهند. این تصمیم در تسک ۰۱ هم نوشته شده — هر دو جا باید یکی باشد.
## ۳. تعطیلات: ملی، محیطی، منبعی — ترتیب
```
روز تعطیل است اگر:
(در national_holidays با is_official=true باشد
و tenant_holiday_override با is_working=true نداشته باشد)
یا
(tenant_holiday_override با is_working=false داشته باشد)
```
منبع می‌تواند با `resource_exception` روزِ باز را ببندد، ولی **نمی‌تواند** روز تعطیل را باز
کند. باز کردن فقط در سطح محیط معنی دارد (کل کلینیک آن روز کار می‌کند یا نه).
## ۴. زمان و منطقهٔ زمانی
ذخیره‌سازی همیشه Unix timestamp (UTC ذاتی). `date('w', $ts)` و `strtotime('today')` به
منطقهٔ زمانی PHP وابسته‌اند. `SlotCalculatorService` امروز روی همین فرض کار می‌کند و
`php.ini` پروژه روی `Asia/Tehran` است.
قاعده: **هیچ‌جا `date()` بدون منطقهٔ زمانی صریح ننویس** وقتی شعبه `timezone` دارد.
```php
$tz = new \DateTimeZone($branch->getTimezone());
$day = (new \DateTimeImmutable("@{$ts}"))->setTimezone($tz);
$dow = ((int) $day->format('w') + 1) % 7; // ← همان تبدیل SlotCalculatorService
```
تبدیل `(w + 1) % 7` عمداً همان است که در `SlotCalculatorService:359` هست. دو قرارداد
شمارش روز هفته در یک کدبیس = باگ قطعی.
## ۵. کارایی — پنج کوئری، نه پنج × تعداد روز
```php
// ❌ اشتباه
foreach ($days as $day) { $exceptions = $repo->findForDay($resource, $day); }
// ✅ درست — یک بار برای کل بازه، بعد در حافظه
$exceptions = $repo->findOverlapping($resource, $from, $to);
$byDay = $this->bucketByDay($exceptions, $from, $to);
```
معیار: `rawWindows()` برای یک منبع در ۹۰ روز باید **دقیقاً ۵ کوئری** بزند. یک تست با
`ProfilerStack` یا شمارندهٔ `SQLLogger` این را قفل کند — وگرنه اولین refactor آن را می‌شکند.
## ۶. سازگاری با نوبت‌دهی موجود
این تسک هیچ‌چیز از `SlotCalculatorService` را تغییر نمی‌دهد. `ResourceAvailabilityService`
یک سرویس **موازی** است که فقط در حالت `booking_mode=resource` (تسک ۰۶) صدا زده می‌شود.
تنها نقطهٔ اتصال: `HolidayResolver` می‌تواند از تسک بعد در `SlotCalculatorService` هم
استفاده شود تا پزشک‌های حالت `slot` هم تعطیلات رسمی را بگیرند. آن یک تغییر رفتاری است
(روزهایی که امروز باز بودند بسته می‌شوند) — پس **در این تسک انجام نده**، به‌عنوان یک
تغییر جدا با تأیید محصول.
## ۷. edge case ها
| حالت | رفتار درست |
|---|---|
| منبع بدون هیچ `resource_calendar` | `rawWindows` خالی، `explainEmptyDay` = `no_calendar` |
| استثنا که کل روز را می‌پوشاند | آن روز خالی |
| استثنا نیم‌روزه | فقط همان بازه کسر شود |
| دو استثنای هم‌پوشان | `union` بعد `subtract` — نه کسر پشت‌سرهم (دوبار کسر بازهٔ مشترک) |
| شیفت با `valid_to` گذشته | نادیده گرفته شود |
| شیفت شبانه (۲۲:۰۰ تا ۰۲:۰۰) | **پشتیبانی نمی‌شود در این تسک**`422` با پیام روشن. دو ردیف بنویسند |
| `from > to` | `422` |
| بازهٔ بزرگ‌تر از ۹۰ روز | `422` — سقف مستند بند ۱۰ |
| تعطیل رسمی + override محیط + استثنای منبع، هر سه روی یک روز | ترتیب بند ۳ بالا |
شیفت شبانه عمداً بیرون است: پشتیبانی‌اش یعنی هر بازهٔ روزانه ممکن است به روز بعد سرریز
کند و کل منطق bucket-by-day باید بازنویسی شود. اگر کلینیکی لازم داشت، تسک جدا.
## ۸. تست
```
tests/Shared/Time/IntervalSetTest.php ← اول این، واحد و بدون DB
tests/Resource/ResourceCalendarTest.php
- PUT شیفت‌ها، بازخوانی یکسان
- end <= start → 422 · هم‌پوشانی در یک روز → 422
- شیفت شبانه → 422
tests/Resource/ResourceAvailabilityTest.php
- منبع بدون تقویم → خالی + دلیل no_calendar
- تقاطع با ساعت شعبه
- شعبهٔ بدون ساعت → بی‌قید
- کسر استثنای نیم‌روزه
- دو استثنای هم‌پوشان → یک بار کسر
tests/Resource/AvailabilityQueryCountTest.php
- ۹۰ روز → دقیقاً ۵ کوئری
tests/Holiday/HolidayResolverTest.php
- تعطیل رسمی برای همه
- override is_working=true فقط برای همان محیط
- override is_working=false روی روز غیررسمی
tests/Holiday/ImportNationalHolidaysTest.php
- idempotent
```
## ۹. مستندات
`docs/api/resource-calendar.md` بساز. در `docs/api/appointment-settings.md` یک بخش
«تفاوت با تقویم منبع» اضافه کن تا کسی دو سیستم را قاطی نکند.
@@ -0,0 +1,76 @@
# تسک ۰۳ — تقویم منبع، استثنا، تعطیلات ملی
**فاز:** ۱ (هسته) · **وابستگی:** ۰۱، ۰۲ · **زمان:** ۱۲-۱۴ ساعت
---
## هدف
مستند بند ۹ ساعت آزاد را از کسر هفت لایه می‌سازد. امروز چهار لایه داریم و همه روی
**پزشک** سوارند. این تسک لایه‌های غایب را اضافه می‌کند و آن‌ها را روی **منبع** می‌نشاند:
```
ساعت کاری شعبه ← تسک ۰۱ ساخت، اینجا وارد محاسبه می‌شود
– شیفت منبع ← این تسک
– تعطیلات رسمی کشور ← این تسک
– مرخصی و غیبت ← این تسک
– سرویس دوره‌ای دستگاه ← این تسک (همان جدول استثنا با نوع دیگر)
– نوبت‌های ثبت‌شده ← موجود (تسک ۰۷ به resource_occupancy منتقل می‌کند)
– رزروهای موقت ← موجود
– آماده‌سازی و تمیزکاری ← تسک ۰۲ ستون‌ها را ساخت، تسک ۰۶ اعمال می‌کند
```
## وضعیت فعلی
- `WeeklySchedule` — JSON هفتگی per `(doctor, clinic)` با `sessions[]`
- `DateOverride` — تنظیم یک روز خاص per `(doctor, clinic)`
- `Holiday` — بازهٔ تعطیلی per `(doctor, clinic)`، دستی
- جدول تعطیلات رسمی کشور **وجود ندارد**؛ هر پزشک باید دستی ثبت کند
## دامنه
**هست:** `ResourceCalendar` (شیفت تکرارشوندهٔ منبع)، `ResourceException` (مرخصی، غیبت،
سرویس دوره‌ای، تعطیلی موردی)، `NationalHoliday` (جدول کشوری با import شمسی) و
`TenantHolidayOverride` (کلینیکی که پنجشنبه کار می‌کند)، و یک سرویس واحد
`ResourceAvailabilityService` که «ساعت آزاد یک منبع در یک بازه» را می‌دهد.
**نیست:** تقاطع چند منبع و برنامهٔ چندبخشی (تسک ۰۶)، ثبت اشغال (تسک ۰۷).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/v1/resource/{uuid}/calendar` | شیفت‌های هفتگی منبع |
| PUT | `/api/v1/resource/{uuid}/calendar` | جایگزینی کامل شیفت‌ها |
| GET | `/api/v1/resource/{uuid}/exceptions` | مرخصی/سرویس، با فیلتر بازه |
| POST | `/api/v1/resource/{uuid}/exception` | ثبت استثنا |
| PATCH/DELETE | `/api/v1/resource-exception/{uuid}` | |
| GET | `/api/v1/resource/{uuid}/availability?from&to` | ساعت آزاد خام (بدون نوبت) — برای پنل |
| GET | `/api/v1/national-holidays?year=1405` | تعطیلات رسمی سال |
| POST | `/api/v1/holiday-overrides` | تغییر تعطیلی رسمی توسط محیط |
| DELETE | `/api/v1/holiday-override/{uuid}` | |
## معیار پذیرش
- ✅ موفق: منبع «اپراتور مریم» شیفت شنبه تا چهارشنبه ۹:۰۰-۱۷:۰۰ می‌گیرد →
`GET /resource/{uuid}/availability?from=…&to=…` پنج بازه برمی‌گرداند و جمعه خالی است.
- ✅ موفق: مرخصی سه‌شنبه ثبت می‌شود → همان endpoint سه‌شنبه را خالی می‌دهد.
- ✅ موفق: تعطیل رسمی ۱۳ فروردین در `national_holidays` هست → آن روز برای همهٔ منابع
همهٔ محیط‌ها خالی است، بدون هیچ ثبت دستی.
- ✅ موفق: کلینیکی که پنجشنبه کار می‌کند یک `holiday_override` با `is_working=true` ثبت
می‌کند → فقط منابع همان محیط پنجشنبه باز می‌شوند.
- ❌ خطا: شیفت با `end_minute <= start_minute``422`.
- ❌ خطا: استثنا با `end < start``422`؛ استثنا روی منبع محیط دیگر → `404`.
- ⚠️ مرزی: شیفت منبع بیرون از ساعت کاری شعبه → **تقاطع** گرفته می‌شود، نه رد. اگر تقاطع
خالی شد، پاسخ `availability` آن روز را خالی می‌دهد و دلیل `outside_branch_hours` را
برمی‌گرداند.
- ⚠️ مرزی: شعبهٔ بدون ساعت کاری → شیفت منبع بی‌قید اعمال می‌شود (سازگاری با داده‌های موجود).
- ⚠️ مرزی: استثنای نیم‌روزه (۱۴:۰۰ تا ۱۸:۰۰) → فقط همان بازه کسر می‌شود، نه کل روز.
- ⚠️ مرزی: دو استثنای هم‌پوشان → مجاز، اتحاد گرفته می‌شود.
## خروجی
- `src/Resource/Calendar/` + `src/Resource/Service/ResourceAvailabilityService.php`
- صفحات: `ResourceCalendarPage.tsx`, `ResourceExceptionsPage.tsx`, `HolidaysSettingsPage.tsx`
- `docs/api/resource-calendar.md`
- دستور `app:holiday:import --year=1405` برای بارگذاری تعطیلات رسمی
@@ -0,0 +1,170 @@
# معماری — تسک ۰۴
## واژگان — مهم‌ترین نکتهٔ این تسک
مستند و کد فعلی دو واژهٔ متفاوت برای چیزهای متفاوت دارند و قاطی کردنشان کل تسک را خراب می‌کند:
| مستند | معادل در این کدبیس | یعنی |
|---|---|---|
| دسته‌بندی (`service_category`) | **`ServiceCategory` جدید، درختی** | «زیبایی › لیزر» — فقط برای مرتب کردن |
| سرویس (`service`) | **`ServiceItem` موجود** | چیزی که بیمار رزرو می‌کند: «لیزر کندلا» |
| گروه آیتم (`item_group`) | **`ItemGroup` جدید** | «نواحی موردنظر»، «سطح انرژی» |
| آیتم (`service_item`) | **`ServiceOption` جدید** | «صورت»، «بیکینی»، «دندان ۵» |
| — | `ServiceSection` موجود | **بخش کلینیک** (رادیولوژی، تزریقات) — سازمانی، نه کاتالوگی |
⚠️ نام `ServiceItem` در کد فعلی معادل «سرویس» مستند است، نه «آیتم». پس آیتم‌های مستند
کلاس جدید `ServiceOption` می‌گیرند. تغییر نام `ServiceItem` **ممنوع** است — در
`appointment_service_items`, `service_item_staff`, `session_services`, `tariffs` و سه ریپوی
کلاینت استفاده می‌شود.
این جدول را عیناً در `docs/api/clinic-services.md` بنویس.
## ساختار فایل
```
src/ClinicService/
├── Entity/
│ ├── ServiceCategory.php # جدید — درختی
│ ├── ItemGroup.php # جدید
│ ├── ServiceOption.php # جدید — «آیتم» مستند
│ ├── ServiceOptionRelation.php # جدید — ناسازگاری/پیش‌نیاز
│ ├── ServiceBranchOverride.php # جدید
│ ├── ServiceItem.php # موجود — ستون‌های تازه
│ └── ServiceSection.php # موجود — دست‌نخورده
├── Service/
│ ├── ServiceSelectionValidator.php # اعتبارسنجی انتخاب
│ ├── DurationCalculator.php # محاسبهٔ مدت با دو نوع زمان
│ ├── ServicePriceResolver.php # قیمت با override شعبه
│ └── ItemGroupService.php
└── Controller/
├── ServiceCategoryController.php
├── ItemGroupController.php
└── ServiceSelectionController.php
```
## `DurationCalculator` — قلب تسک
```php
final class DurationCalculator
{
/**
* مدت کل یک انتخاب. قاعدهٔ مستند بند ۷:
* «اولین آیتم هر گروه: زمان تنها — بقیه: زمان اضافه»
*
* @param ServiceOption[] $options انتخاب‌های کاربر
*/
public function totalMinutes(ServiceItem $service, array $options, ?Branch $branch = null): int
{
$base = $this->baseDuration($service, $branch); // مدت پایهٔ سرویس (ممکن است ۰ باشد)
$byGroup = [];
foreach ($options as $option) {
$byGroup[$option->getGroup()->getId()][] = $option;
}
$total = $base;
foreach ($byGroup as $groupOptions) {
// ترتیب پایدار: بلندترین «زمان تنها» اول، تا انتخاب کاربر روی نتیجه اثر نگذارد
usort($groupOptions, fn($a, $b) => $b->getSoloMinutes() <=> $a->getSoloMinutes());
$total += $groupOptions[0]->getSoloMinutes();
foreach (array_slice($groupOptions, 1) as $rest) {
$total += $rest->getAdditionalMinutes() ?? $rest->getSoloMinutes();
}
}
return $total;
}
}
```
**چرا مرتب‌سازی نزولی؟** بدون آن، «صورت بعد بیکینی» و «بیکینی بعد صورت» دو مدت متفاوت
می‌دهند و همان انتخاب در دو نشست دو قیمت/دو ظرفیت می‌گیرد. مستند این را نگفته ولی
لازمهٔ قطعی بودن است. تصمیم: بیشترین زمان تنها، «آیتم اصلی» است.
**چرا per گروه، نه per کل انتخاب؟** آماده‌سازی per نوع کار است. «سطح انرژی» و «ناحیه»
دو کار متفاوت‌اند و هر کدام آماده‌سازی خودش را دارد.
## `ServiceSelectionValidator`
```php
/** @return SelectionResult{valid: bool, errors: SelectionError[], total_minutes: int, total_price_rials: int} */
public function validate(EntityContext $ctx, ServiceItem $service, array $optionUuids, ?Branch $branch): SelectionResult
```
ترتیب بررسی — عمداً همین ترتیب:
```
۱. مالکیت محیط همهٔ uuid ها → یک بیگانه = 404، نه پیام دقیق‌تر
۲. آیتم‌ها واقعاً به این سرویس تعلق دارند → 422
۳. قید min/max هر گروه
۴. ناسازگاری‌ها
۵. پیش‌نیازها
۶. محاسبهٔ مدت و قیمت (فقط اگر ۱..۵ سبز باشند)
```
مرحلهٔ ۱ اول است چون پیام‌های مراحل بعد وجود و نام آیتم را لو می‌دهند — دقیقاً همان
نشتی‌ای که در `GET /api/v1/appointment-service-slots` پیدا و رفع شد
(`docs/architecture/tenancy.md`، جدول «uuid از درخواست»).
خطاها **همه با هم** برگردانده می‌شوند، نه اولی. فرم انتخاب باید همهٔ ایرادها را یک‌جا
نشان دهد.
## `ServiceOption`
```php
class ServiceOption
{
use TenantOwnedTrait; // uuid از request می‌آید
private ItemGroup $group;
private string $name;
private int $soloMinutes; // «زمان تنها»
private ?int $additionalMinutes = null; // «زمان اضافه»؛ null → soloMinutes
private int $priceRials = 0;
private int $sortOrder = 0;
private bool $active = true;
}
```
`additionalMinutes` تهی‌پذیر عمدی است: مقدار null یعنی «تعریف نشده، محافظه‌کارانه رفتار کن»
و همان `soloMinutes` را می‌گیرد. این باعث می‌شود مهاجرت داده‌های موجود بدون تغییر رفتار
انجام شود؛ کلینیک بعداً عدد واقعی را وارد می‌کند و ظرفیتش آزاد می‌شود.
## `ServiceOptionRelation`
```php
private ServiceOption $source;
private ServiceOption $target;
private string $type; // TYPE_INCOMPATIBLE | TYPE_REQUIRES
```
- `incompatible` **متقارن** است: ثبت (الف، ب) خودکار (ب، الف) را هم معنا می‌دهد.
در repository با `WHERE (source IN :sel AND target IN :sel)` هر دو جهت پوشش داده می‌شود؛
ردیف دوم ذخیره نمی‌شود.
- `requires` **جهت‌دار** است و باید بدون حلقه بماند. تشخیص حلقه با DFS هنگام ثبت
(`ServiceOptionRelationService::assertNoCycle()`).
## قیمت با override شعبه
```php
final class ServicePriceResolver
{
/** ترتیب: override شعبه ← تعرفهٔ سال جاری ← قیمت پایهٔ سرویس */
public function basePrice(ServiceItem $service, ?Branch $branch, int $at): int;
}
```
`Tariff` موجود (سالانه) دست‌نخورده می‌ماند و در این زنجیره قرار می‌گیرد. لیست قیمت
بازه‌دار کامل کار تسک ۰۸ است؛ اینجا فقط لایهٔ شعبه اضافه می‌شود.
## پنل ادمین
`ServiceDetailPage.tsx` موجود یک تب می‌گیرد: «گروه‌ها و آیتم‌ها».
- لیست گروه‌ها با `min/max` قابل ویرایش inline
- زیر هر گروه، جدول آیتم‌ها با ستون‌های: نام، زمان تنها، زمان اضافه، قیمت، فعال
- ناسازگاری/پیش‌نیاز با `SearchableSelect` چندانتخابی روی آیتم‌های همان سرویس
- پیش‌نمایش زنده: «انتخاب صورت + بیکینی → ۲۳ دقیقه» با صدا زدن
`POST /service-selection/validate` (debounce ۴۰۰ms)
پیش‌نمایش زنده اختیاری نیست — بدون آن، کلینیک تفاوت «زمان تنها» و «زمان اضافه» را
نمی‌فهمد و هر دو را یک عدد می‌گذارد، که یعنی کل این تسک بی‌اثر می‌شود.
@@ -0,0 +1,149 @@
# دیتابیس — تسک ۰۴
## تغییر جدول موجود: `service_items`
```sql
ALTER TABLE service_items
ADD COLUMN service_category_id INT NULL AFTER section_id,
ADD COLUMN session_count SMALLINT NOT NULL DEFAULT 1, -- ۱ = تک‌جلسه، >۱ = دوره‌ای
ADD COLUMN preparation_note TEXT NULL, -- «آماده‌سازی بیمار» — مستند بند ۵
ADD CONSTRAINT fk_service_items_category
FOREIGN KEY (service_category_id) REFERENCES service_categories(id) ON DELETE SET NULL,
ADD KEY idx_service_items_category (service_category_id);
```
`duration_minutes` موجود **حذف نمی‌شود** و همچنان «مدت پایهٔ سرویس» است. آیتم‌ها روی آن
اضافه می‌کنند. در حالت `booking_mode=service` فعلی هیچ تغییری در رفتار نیست چون هیچ
سرویسی گروه ندارد و `totalMinutes` برابر همان `duration_minutes` می‌ماند.
## `service_categories` — درختی
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `parent_id` | INT NULL | FK → خودش، ON DELETE RESTRICT |
| `name` | VARCHAR(150) NOT NULL | |
| `path` | VARCHAR(255) NOT NULL | materialized path: `/1/7/23/` |
| `depth` | TINYINT NOT NULL DEFAULT 0 | |
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_svc_cat_tenant (entity_type, entity_id, active)
KEY idx_svc_cat_parent (parent_id, sort_order)
KEY idx_svc_cat_path (path)
```
**materialized path** به‌جای adjacency خالص: تسک ۰۹ شرط «سرویس در دستهٔ جراحی یا
زیردسته‌هایش» را می‌خواهد و با `path LIKE '/1/7/%'` یک کوئری است، نه یک پیمایش بازگشتی.
سقف عمق: ۴. در `ServiceCategoryService` اجبار شود.
## `item_groups`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | از سرویس مشتق می‌شود |
| `service_item_id` | INT NOT NULL | FK → `service_items.id` ON DELETE CASCADE |
| `name` | VARCHAR(150) NOT NULL | «نواحی موردنظر» |
| `min_select` | SMALLINT NOT NULL DEFAULT 0 | ۰ = اختیاری |
| `max_select` | SMALLINT NULL | NULL = نامحدود |
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_item_groups_tenant (entity_type, entity_id, active)
KEY idx_item_groups_service (service_item_id, sort_order)
```
## `service_options` — «آیتم» مستند
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | از request می‌آید |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `item_group_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `name` | VARCHAR(150) NOT NULL | |
| `solo_minutes` | SMALLINT NOT NULL | «زمان تنها» |
| `additional_minutes` | SMALLINT NULL | «زمان اضافه»؛ NULL → `solo_minutes` |
| `price_rials` | INT NOT NULL DEFAULT 0 | |
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_service_options_tenant (entity_type, entity_id, active)
KEY idx_service_options_group (item_group_id, sort_order)
```
قید اپلیکیشنی: `additional_minutes <= solo_minutes` (زمان اضافه هرگز بیشتر از زمان تنها
نیست — آماده‌سازی که دو بار نمی‌شود). نقض → `422` با پیام روشن.
## `service_option_relations`
```sql
CREATE TABLE service_option_relations (
id INT PRIMARY KEY AUTO_INCREMENT,
source_option_id INT NOT NULL,
target_option_id INT NOT NULL,
type VARCHAR(20) NOT NULL, -- incompatible | requires
UNIQUE KEY uniq_sor (source_option_id, target_option_id, type),
KEY idx_sor_target (target_option_id, type),
CONSTRAINT fk_sor_source FOREIGN KEY (source_option_id) REFERENCES service_options(id) ON DELETE CASCADE,
CONSTRAINT fk_sor_target FOREIGN KEY (target_option_id) REFERENCES service_options(id) ON DELETE CASCADE
);
```
فرزند aggregate با ریشهٔ `ServiceOption`. قید اپلیکیشنی: `source != target`.
## `service_branch_overrides`
```sql
CREATE TABLE service_branch_overrides (
id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
service_item_id INT NOT NULL,
branch_id INT NOT NULL,
price_rials INT NULL, -- NULL = ارث از سرویس
duration_minutes SMALLINT NULL, -- NULL = ارث از سرویس
bookable TINYINT(1) NULL, -- NULL = ارث؛ 0 = این شعبه ارائه نمی‌دهد
created_at INT NOT NULL,
updated_at INT NOT NULL,
UNIQUE KEY uniq_sbo (service_item_id, branch_id),
KEY idx_sbo_tenant (entity_type, entity_id),
KEY idx_sbo_branch (branch_id),
CONSTRAINT fk_sbo_service FOREIGN KEY (service_item_id) REFERENCES service_items(id) ON DELETE CASCADE,
CONSTRAINT fk_sbo_branch FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE
);
```
سه ستون تهی‌پذیرند تا override جزئی ممکن باشد: فقط قیمت، بدون دست زدن به مدت.
## Migration و backfill
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
backfill لازم نیست: سرویس‌های موجود گروه ندارند، `DurationCalculator` مقدار
`service_items.duration_minutes` را برمی‌گرداند و رفتار حالت `service` بدون تغییر می‌ماند.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `service_categories`, `item_groups`, `service_options`, `service_branch_overrides` | جفت tenant |
| `service_option_relations` | `AGGREGATE_CHILDREN` → ریشه `ServiceOption` |
`TenantLookupInventoryTest` شمارنده دارد؛ `findByUuid` های جدید (`ServiceOptionRepository`,
`ItemGroupRepository`) باید با `TenantOwnershipChecker` جفت شوند و بعد عدد به‌روز شود.
@@ -0,0 +1,133 @@
# نکات پیاده‌سازی — تسک ۰۴
## ۱. `ServiceItem` را تغییر نام نده
در این جدول‌ها و کدها به آن ارجاع هست:
```
appointment_service_items · service_item_staff · service_item_consumables
service_item_audit_logs · tariffs.service_item_id · session_services
appointments.service_item_id
```
و در `nobat724_front/services/response.js` و `clinic-pro-tauri/src/service/response.js`
کلید `service_item_uuid` در بدنهٔ رزرو می‌رود. تغییر نام یعنی شکستن سه ریپو بدون یک
خطای build. کلاس جدید `ServiceOption` بساز و در `docs/api/clinic-services.md` جدول
واژگان (فایل architecture) را عیناً بنویس.
## ۲. `/service-selection/validate` هم عمومی است هم پنلی
سایت عمومی بدون توکن آن را صدا می‌زند (بیمار هنوز وارد نشده). پس:
- در `security.yaml` مسیرش را whitelist کن
- بدون کاربر احراز شده، `TenantFilter` خاموش است → **گارد دستی اجباری است**:
محیط از `doctor_uuid` + `clinic_uuid` درخواست حل می‌شود و همهٔ uuid ها با
`TenantOwnershipChecker::belongsToPair()` سنجیده می‌شوند
- نرخ‌محدودسازی: این endpoint یک enumerate کنندهٔ کاتالوگ است. `symfony/rate-limiter`
روی IP، مثل بقیهٔ endpoint های عمومی
این دقیقاً همان اشتباهی است که یک بار در `GET /api/v1/appointment-service-slots` رخ داد و
در فاز ۸ tenancy رفع شد. تکرارش نکن.
## ۳. قطعیت محاسبهٔ مدت
دو انتخاب یکسان با ترتیب متفاوت باید **همیشه** یک عدد بدهند. تست:
```php
$a = $calc->totalMinutes($service, [$face, $bikini]);
$b = $calc->totalMinutes($service, [$bikini, $face]);
self::assertSame($a, $b);
```
اگر این تست نباشد، اولین بهینه‌سازی که ترتیب آرایه را عوض کند، قیمت‌ها را تغییر می‌دهد و
هیچ‌کس نمی‌فهمد چرا.
## ۴. تصمیم: مرتب‌سازی نزولی بر اساس «زمان تنها»
مستند نگفته کدام آیتم «اولی» است. سه گزینه بررسی شد:
| گزینه | مشکل |
|---|---|
| ترتیب انتخاب کاربر | غیرقطعی — همان انتخاب دو مدت می‌دهد |
| `sort_order` تعریف‌شده | کلینیک باید برای هر ترکیب فکر کند؛ عملاً پر نمی‌شود |
| **بیشترین «زمان تنها»** ✅ | قطعی، بدون ورودی اضافه، و از نظر کسب‌وکار درست: کار بزرگ‌تر آماده‌سازی را می‌بلعد |
انتخاب سوم. دلیلش را در کد به‌صورت کامنت بنویس، وگرنه اولین بازبینی‌کننده آن را
«مرتب‌سازی بی‌دلیل» می‌بیند و حذفش می‌کند.
## ۵. `additional_minutes = null` یعنی محافظه‌کار
```php
$rest->getAdditionalMinutes() ?? $rest->getSoloMinutes()
```
نه صفر. اگر null را صفر بگیری، سرویس‌های موجود که این ستون را ندارند یک‌شبه مدتشان
نصف می‌شود و ظرفیت الکی باز می‌شود — یعنی نوبت روی نوبت.
## ۶. ناسازگاری متقارن، پیش‌نیاز جهت‌دار
```php
// ناسازگاری — یک ردیف کافی است، هر دو جهت پرس‌وجو می‌شوند
$conflicts = $relationRepo->createQueryBuilder('r')
->where('r.type = :incompatible')
->andWhere('r.source IN (:sel) AND r.target IN (:sel)')
->setParameter('sel', $selectedIds)
->getQuery()->getResult();
```
پیش‌نیاز جهت‌دار است و حلقه ممنوع. `assertNoCycle()` با DFS هنگام **ثبت** اجرا شود، نه
هنگام اعتبارسنجی انتخاب — بررسی حلقه در مسیر داغ رزرو، هزینهٔ بی‌دلیل است.
## ۷. عمق درخت و حذف دسته
- سقف عمق ۴ (`depth <= 3` با ریشهٔ صفر)
- حذف دسته‌ای که فرزند یا سرویس دارد → `422`
- جابه‌جایی دسته → `path` همهٔ نوادگان با یک `UPDATE … SET path = REPLACE(path, :old, :new)`
به‌روز شود، در یک تراکنش
## ۸. edge case ها
| حالت | رفتار درست |
|---|---|
| سرویس بدون هیچ گروه | معتبر — رفتار امروزی، مدت = `duration_minutes` |
| گروه بدون هیچ آیتم فعال و `min_select=1` | انتخاب همیشه نامعتبر می‌شود → هشدار در پنل هنگام ذخیره |
| `min_select > max_select` | `422` |
| `max_select` بزرگ‌تر از تعداد آیتم‌های فعال | مجاز؛ عملاً یعنی نامحدود |
| `additional_minutes > solo_minutes` | `422` |
| آیتم غیرفعال در انتخاب | `422` با کد `inactive_option` |
| override شعبه با `bookable=0` | سرویس در آن شعبه در لیست رزرو نیاید |
| دو آیتم ناسازگار در دو گروه مختلف | همچنان ناسازگار — رابطه بین آیتم‌هاست، نه گروه‌ها |
| انتخاب آیتم از سرویس دیگر | `422` `option_not_in_service` (بعد از بررسی tenant) |
## ۹. تست
```
tests/ClinicService/DurationCalculatorTest.php
- یک آیتم → solo
- دو آیتم یک گروه → solo(بزرگ‌تر) + additional(کوچک‌تر)
- دو گروه → هر گروه solo خودش
- additional=null → از solo استفاده شود
- قطعیت: جابه‌جایی ترتیب ورودی، همان عدد
tests/ClinicService/ServiceSelectionValidatorTest.php
- min_select نقض → کد min_select
- max_select نقض → کد max_select
- ناسازگار → کد incompatible با نام هر دو
- پیش‌نیاز غایب → کد missing_prerequisite
- چند خطا هم‌زمان → همه با هم برگردند
- uuid محیط دیگر → 404 و هیچ اطلاعاتی در بدنه
tests/ClinicService/ServicePriceResolverTest.php
- override شعبه بر تعرفه اولویت دارد
- override جزئی (فقط قیمت) مدت را دست نمی‌زند
tests/ClinicService/ServiceCategoryTreeTest.php
- عمق ۵ → 422 · حذف دستهٔ دارای فرزند → 422 · جابه‌جایی path نوادگان
tests/ClinicService/BackwardCompatibilityTest.php
- سرویس بدون گروه: appointment-service-slots دقیقاً همان خروجی قبلی
```
آخرین تست مهم‌ترین است: **این تسک نباید رفتار نوبت‌دهی سرویسی فعلی را تغییر دهد.**
## ۱۰. مستندات
`docs/api/clinic-services.md` به‌روزرسانی با جدول واژگان + endpoint های جدید.
یادآوری: قرارداد `POST /service-selection/validate` را `nobat724_front` مصرف می‌کند و
شکستنش در build خطا نمی‌دهد.
@@ -0,0 +1,89 @@
# تسک ۰۴ — کاتالوگ خدمات نسخهٔ ۲: گروه آیتم، دو نوع زمان، ناسازگاری
**فاز:** ۱ (هسته) · **وابستگی:** ۰۱ · **زمان:** ۱۴-۱۶ ساعت
---
## هدف
مستند بند ۵ می‌گوید انتخاب آیتم خودش قانون دارد و این قوانین **نباید** به موتور قوانین
سپرده شوند: «حتماً یک سطح انرژی، فقط یکی»، «بین ۱ تا ۸ دندان»، «بیکینی با فول‌بادی
جمع نمی‌شود». و مهم‌تر: هر آیتم دو زمان دارد — «زمان تنها» و «زمان اضافه».
## وضعیت فعلی
```php
// src/ClinicService/Entity/ServiceItem.php
private ?int $durationMinutes = null; // یک عدد، تخت
private int $priceRials = 0;
private bool $bookable = false;
```
و در `AppointmentController::serviceSlots()`:
```php
$totalMinutes += $duration; // ← جمع ساده؛ همان فرمولی که مستند ردش می‌کند
```
نتیجه: بیمار که «صورت + بیکینی» می‌خواهد، ۱۵+۱۵=۳۰ دقیقه ظرفیت می‌گیرد در حالی که
واقعیت ۱۵+۸=۲۳ دقیقه است. هفت دقیقه ضرب در روزی ۲۰ نوبت = یک ساعت ظرفیت هدررفته در روز.
همچنین `ServiceSection` تک‌سطحی است و دسته‌بندی درختی مستند را ندارد.
## دامنه
**هست:**
- `ServiceCategory` درختی (جدا از `ServiceSection` موجود که «بخش کلینیک» است)
- `ItemGroup` با `min_select` / `max_select`
- روی `ServiceItem`: `solo_duration_minutes` و `additional_duration_minutes`
- `ServiceItemRelation` برای `incompatible_with` و `requires`
- `ServiceBranchOverride` برای قیمت و مدت اختصاصی شعبه
- `session_count` روی سرویس (تک‌جلسه یا دوره‌ای — پروتکل کاملش تسک ۱۲)
- `ServiceSelectionValidator` — اعتبارسنجی انتخاب کاربر پیش از هر محاسبه
- `DurationCalculator` — محاسبهٔ درست مدت با دو نوع زمان
**نیست:** بخش‌های نوبت و نیازمندی منبع (تسک ۰۵)، اعمال روی جستجوی وقت (تسک ۰۶).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/v1/service-categories/tree` | درخت دسته‌بندی |
| POST/PATCH/DELETE | `/api/v1/service-category[/{uuid}]` | |
| GET/POST | `/api/v1/service-item/{uuid}/groups` | گروه‌های آیتم یک سرویس |
| PATCH/DELETE | `/api/v1/item-group/{uuid}` | |
| PUT | `/api/v1/item-group/{uuid}/items` | جایگزینی کامل آیتم‌های گروه |
| PUT | `/api/v1/service-item/{uuid}/relations` | ناسازگاری و پیش‌نیاز |
| PUT | `/api/v1/service-item/{uuid}/branch-overrides` | قیمت/مدت per شعبه |
| POST | `/api/v1/service-selection/validate` | اعتبارسنجی انتخاب + مدت و قیمت محاسبه‌شده |
`POST /service-selection/validate` مهم‌ترین endpoint این تسک است: سایت عمومی و پنل هر دو
پیش از رفتن به مرحلهٔ انتخاب زمان، آن را صدا می‌زنند.
## معیار پذیرش
- ✅ موفق: سرویس «لیزر» با گروه «نواحی» (`min=1, max=8`) و آیتم‌های صورت (تنها ۱۵، اضافه ۸)
و بیکینی (تنها ۱۲، اضافه ۸). انتخاب هر دو →
`POST /service-selection/validate` برمی‌گرداند `total_duration_minutes = 23`
(اولین آیتم زمان تنها، بقیه زمان اضافه) و `valid = true`.
- ✅ موفق: انتخاب فقط بیکینی → `total_duration_minutes = 12`.
- ✅ موفق: شعبهٔ مرکزی برای همین سرویس `price_rials` بالاتر دارد →
با `branch_uuid` مرکزی، قیمت override اعمال می‌شود.
- ❌ خطا: انتخاب صفر آیتم از گروهی با `min_select=1``valid=false` با
`errors[{group_uuid, code: 'min_select', message: 'انتخاب حداقل یک مورد از «نواحی» الزامی است'}]`.
- ❌ خطا: انتخاب ۹ آیتم از گروهی با `max_select=8``valid=false` با کد `max_select`.
- ❌ خطا: انتخاب دو آیتم ناسازگار → `valid=false` با کد `incompatible` و نام هر دو آیتم.
- ❌ خطا: انتخاب آیتمی که پیش‌نیازش انتخاب نشده → `valid=false` با کد `missing_prerequisite`.
- ❌ خطا: uuid آیتم از محیط دیگر → `404` (نه ۴۲۲ — نباید وجودش لو برود).
- ⚠️ مرزی: `max_select = null` یعنی نامحدود.
- ⚠️ مرزی: گروه با `min_select = 0` یعنی اختیاری.
- ⚠️ مرزی: آیتم بدون `additional_duration_minutes` → از `solo_duration_minutes` استفاده شود
(سازگاری با داده‌های موجود که فقط یک `duration_minutes` دارند).
- ⚠️ مرزی: حلقهٔ پیش‌نیاز (الف پیش‌نیاز ب، ب پیش‌نیاز الف) → `422` هنگام ثبت رابطه.
## خروجی
- توسعهٔ `src/ClinicService/` (بدون شکستن endpoint های موجود)
- `assets/admin/pages/ServiceDetailPage.tsx` توسعه: تب «گروه‌ها و آیتم‌ها»
- `docs/api/clinic-services.md` به‌روزرسانی
- migration + backfill: `duration_minutes` موجود → `solo_duration_minutes`
@@ -0,0 +1,196 @@
# معماری — تسک ۰۵
## ساختار فایل
```
src/Appointment/Plan/
├── Entity/
│ ├── SegmentTemplate.php
│ └── SegmentRequirement.php
├── Dto/
│ ├── AppointmentPlan.php # نتیجهٔ نهایی — immutable
│ ├── PlannedSegment.php
│ └── PlannedRequirement.php
├── Service/
│ ├── AppointmentPlanBuilder.php # ارکستراتور
│ ├── SegmentAssembler.php # جمع‌آوری + ادغام بخش‌ها
│ ├── SegmentDurationResolver.php# مدت هر بخش
│ └── RequirementResolver.php # نیازمندی → منابع کاندید
├── Controller/
│ ├── SegmentTemplateController.php
│ └── AppointmentPlanController.php
└── Exception/NoEligibleResourceException.php
```
پنج کلاس سرویس به‌جای یک کلاس بزرگ، چون هر کدام یک دلیل تغییر دارد: ادغام بخش‌ها،
محاسبهٔ مدت، و پیدا کردن منبع کاندید سه مسئلهٔ مستقل‌اند و تسک ۰۹ فقط به دوتای اول
قلاب می‌زند.
## `SegmentTemplate`
```php
class SegmentTemplate
{
use TenantOwnedTrait;
public const OWNER_SERVICE = 'service'; // بخش پایهٔ سرویس
public const OWNER_OPTION = 'option'; // بخش اضافهٔ یک آیتم
private string $ownerType;
private ?ServiceItem $serviceItem = null;
private ?ServiceOption $option = null;
private string $name; // «انتظار اثر بی‌حسی»
private string $segmentType; // کلید ادغام: prep | wait | treatment | aftercare | custom:*
private int $sequence; // ترتیب اجرا
private ?int $fixedMinutes = null; // مدت ثابت؛ null یعنی مدت پویا
private ?int $durationShare = null; // درصد از مدت محاسبه‌شدهٔ سرویس، وقتی fixedMinutes نیست
private bool $patientPresent = true;
private bool $mergeable = false; // با بخش‌های هم‌نوع ادغام می‌شود
private bool $active = true;
}
```
### مدت ثابت یا سهمی
دو حالت، دقیقاً یکی از آن‌ها:
- `fixedMinutes = 30` — انتظار اثر کرم همیشه ۳۰ دقیقه است، چه یک ناحیه چه پنج ناحیه
- `durationShare = 100` — «خود لیزر» همهٔ مدتِ محاسبه‌شده از `DurationCalculator` (تسک ۰۴)
را می‌گیرد
جمع `durationShare` همهٔ بخش‌های یک سرویس باید دقیقاً ۱۰۰ باشد (اگر هیچ بخش سهمی نباشد،
شرط بی‌اثر است). اعتبارسنجی هنگام ذخیرهٔ الگو، نه هنگام ساخت برنامه.
## `SegmentRequirement`
```php
class SegmentRequirement
{
public const OCCUPANCY_EXCLUSIVE = 'exclusive'; // منبع کامل اشغال
public const OCCUPANCY_SHARED = 'shared'; // یک واحد از ظرفیت
public const OCCUPANCY_PASSIVE = 'passive'; // رزرو ولی بدون کار فعال
private SegmentTemplate $segment;
private ResourceType $role; // نقش: اپراتور، دستگاه، اتاق
private int $count = 1;
private ?ResourcePool $pool = null; // «هر عضو این استخر»
private ?ClinicResource $specific = null; // منبع مشخص (کم‌کاربرد ولی لازم)
private array $requiredSkills = []; // skill_id[] — همه لازم‌اند، نه یکی
private array $constraints = []; // {same_gender_as_patient: true, attributes: {...}}
private string $occupancy = self::OCCUPANCY_EXCLUSIVE;
}
```
`pool` و `specific` هر دو تهی‌پذیرند؛ اگر هیچ‌کدام نباشد یعنی «هر منبعِ آن نقش در آن شعبه
که شرط‌ها را دارد».
### `constraints` — فهرست بسته
مثل `DiscountRule`، شرط‌ها از یک فهرست بسته می‌آیند، نه کد دلخواه:
| کلید | مقدار | معنی |
|---|---|---|
| `same_gender_as_patient` | bool | منبع باید `attributes.gender` برابر جنسیت بیمار داشته باشد |
| `attributes` | object اسکالر | تطبیق دقیق روی `clinic_resources.attributes` |
| `min_skill_level` | 1..5 | حداقل سطح مهارت |
هر کلید ناشناخته → `422` هنگام ذخیره. این محدودیت عمدی است (مستند بند ۸): تسک ۰۶ باید
همهٔ این‌ها را به یک کوئری تبدیل کند.
## `AppointmentPlanBuilder` — جریان
```php
public function build(PlanRequest $request): AppointmentPlan
{
// ۱. اعتبارسنجی انتخاب (تسک ۰۴) — اگر نامعتبر بود همین‌جا تمام
$selection = $this->selectionValidator->validate(...);
// ۲. جمع‌آوری بخش‌ها: پایهٔ سرویس + بخش‌های اضافهٔ هر آیتم انتخابی
$raw = $this->assembler->collect($service, $selection->options);
// ۳. ادغام هم‌نوع‌ها (mergeable=true و segmentType یکسان → یکی)
$merged = $this->assembler->merge($raw);
// ۴. مدت هر بخش
$timed = $this->durationResolver->resolve($merged, $selection->totalMinutes);
// ۵. چیدمان: offset تجمعی بر اساس sequence
$sequenced = $this->assembler->layout($timed);
// ۶. نیازمندی‌ها → منابع کاندید (اینجا کوئری می‌خورد)
$withResources = $this->requirementResolver->resolve($sequenced, $request->branch, $request->patient);
// ۷. نقطهٔ اتصال تسک ۰۹: قوانین دستهٔ «منبع» و «زمان» اینجا اعمال می‌شوند
// فعلاً یک no-op PolicyApplier تزریق شود تا امضا بعداً عوض نشود.
return $this->policies->applyToPlan($withResources);
}
```
مرحلهٔ ۷ عمداً از روز اول در امضا هست حتی وقتی خالی است — افزودنش بعداً یعنی تغییر
امضای عمومی و همهٔ تست‌ها.
## ادغام بخش‌ها
```
ورودی: بخش‌های سرویس + بخش‌های همهٔ آیتم‌های انتخابی
گروه‌بندی بر اساس segmentType
برای هر گروه:
اگر همهٔ اعضا mergeable=true → یک بخش با:
نام: نام بخشِ سرویس (یا اولین)
مدت: بیشترین مدت ثابت، یا مجموع سهم‌ها
نیازمندی‌ها: اتحاد (بدون تکرار؛ count بیشینه برای هر نقش)
وگرنه → همه جدا می‌مانند، به ترتیب sequence
```
مثال مستند: بیمار پنج ناحیه انتخاب می‌کند، هر ناحیه یک بخش «آماده‌سازی» با
`mergeable=true` دارد → یک آماده‌سازی، نه پنج تا.
## `RequirementResolver`
```php
/** @return ClinicResource[] منابع واجد شرایط برای این نیازمندی */
public function candidates(SegmentRequirement $req, Branch $branch, ?Patient $patient): array
```
از `ClinicResourceRepository::findEligible()` (تسک ۰۲) استفاده می‌کند:
شعبه + نقش + `HAVING COUNT(DISTINCT skill) = n` + فیلتر `attributes` در PHP
(JSON در MariaDB قابل ایندکس‌گذاری مطمئن نیست؛ تعداد منابع یک شعبه کوچک است).
اگر `candidates === []``NoEligibleResourceException` با پیام ساخته‌شده از نقش و مهارت‌ها:
```php
throw new NoEligibleResourceException(sprintf(
'هیچ %s با مهارت %s در شعبهٔ %s موجود نیست',
$req->getRole()->getName(), implode('، ', $skillNames), $branch->getName()
));
```
پیام انسانی اجباری است — مستند بند ۱۰ صریح می‌گوید خطا باید بگوید چه چیزی کم است.
## سازگاری: سرویس بدون الگو
`SegmentAssembler::collect()` وقتی هیچ `SegmentTemplate` پیدا نکرد، یک بخش مجازی می‌سازد:
```php
new PlannedSegment(
name: $service->getName(),
segmentType: 'treatment',
durationMinutes: $selection->totalMinutes,
requirements: [ PlannedRequirement::doctorDefault() ], // منبع type=doctor
);
```
این تضمین می‌کند حالت `booking_mode=service` امروزی، وقتی به موتور جدید مهاجرت کند،
دقیقاً همان رفتار را داشته باشد.
## پنل ادمین
`ServiceSegmentsPage.tsx` (زیرصفحهٔ `ServiceDetailPage`):
- لیست مرتب بخش‌ها با drag ندارد؛ `sequence` عددی
- هر بخش قابل بازشدن: مدت (ثابت/سهمی)، حضور بیمار، ادغام‌پذیر
- زیر هر بخش، نیازمندی‌ها: نقش (`SearchableSelect`)، تعداد، استخر، مهارت‌ها (چیپ)،
نوع اشغال (رادیو با توضیح فارسی هر گزینه)
- **نوار پیش‌نمایش زمانی**: چهار بخش روی یک خط با عرض متناسب مدت و آیکن منابع هر بخش
— این تنها راهی است که کاربر غیرفنی می‌فهمد چه ساخته
@@ -0,0 +1,95 @@
# دیتابیس — تسک ۰۵
## `segment_templates`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | از request می‌آید |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `owner_type` | VARCHAR(10) NOT NULL | `service` \| `option` |
| `service_item_id` | INT NULL | FK → `service_items.id` ON DELETE CASCADE |
| `service_option_id` | INT NULL | FK → `service_options.id` ON DELETE CASCADE |
| `name` | VARCHAR(150) NOT NULL | |
| `segment_type` | VARCHAR(40) NOT NULL | کلید ادغام |
| `sequence` | SMALLINT NOT NULL | |
| `fixed_minutes` | SMALLINT NULL | |
| `duration_share` | SMALLINT NULL | درصد ۰..۱۰۰ |
| `patient_present` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `mergeable` | TINYINT(1) NOT NULL DEFAULT 0 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_seg_tpl_tenant (entity_type, entity_id, active)
KEY idx_seg_tpl_service (service_item_id, sequence)
KEY idx_seg_tpl_option (service_option_id, sequence)
```
قیدهای اپلیکیشنی (در سازنده/سرویس، نه `CHECK`):
- دقیقاً یکی از `service_item_id` / `service_option_id` غیر-NULL و با `owner_type` سازگار
- دقیقاً یکی از `fixed_minutes` / `duration_share` غیر-NULL
- جمع `duration_share` بخش‌های یک سرویس = ۱۰۰ (اگر حداقل یکی سهمی باشد)
## `segment_requirements`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `segment_template_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `resource_type_id` | INT NOT NULL | FK → `resource_types.id` ON DELETE RESTRICT |
| `count` | SMALLINT NOT NULL DEFAULT 1 | |
| `resource_pool_id` | INT NULL | FK → `resource_pools.id` ON DELETE SET NULL |
| `specific_resource_id` | INT NULL | FK → `clinic_resources.id` ON DELETE SET NULL |
| `required_skills` | JSON NULL | آرایهٔ `skill_id` |
| `constraints` | JSON NULL | فهرست بسته — جدول architecture |
| `occupancy` | VARCHAR(10) NOT NULL DEFAULT 'exclusive' | `exclusive`\|`shared`\|`passive` |
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
```sql
KEY idx_seg_req_segment (segment_template_id, sort_order)
KEY idx_seg_req_pool (resource_pool_id)
```
فرزند aggregate با ریشهٔ `SegmentTemplate` — uuid از request فقط در
`PUT /segment-template/{uuid}/requirements` می‌آید که خودش از ریشه لنگر می‌خورد،
پس ستون tenant لازم ندارد.
> `required_skills` عمداً JSON است نه جدول واسط: همیشه کامل خوانده و کامل جایگزین می‌شود،
> و هیچ کوئری‌ای از سمت مهارت به نیازمندی نمی‌رود. جدول واسط اینجا فقط سه JOIN اضافه
> به مسیر داغ تسک ۰۶ می‌آورد.
## هیچ جدول جدیدی برای «برنامهٔ ساخته‌شده» نیست
`AppointmentPlan` یک DTO درون‌حافظه‌ای است، نه entity. ذخیره‌اش وقتی معنی پیدا می‌کند که
نوبت ثبت شود — که کار تسک ۰۷ است (`appointment_segments`).
دلیل: برنامه یک تابع خالص از (سرویس، آیتم‌ها، بیمار، شعبه، قوانین فعال) است. ذخیره‌کردنش
پیش از ثبت یعنی نگه‌داشتن حالت موقتی که باید منقضی شود — همان مسئله‌ای که رزرو موقت
تسک ۰۷ حل می‌کند و دو مکانیزم موازی لازم نیست.
## 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:segment:seed-templates --preset=beauty --tenant=clinic:12 --force
```
`app:segment:seed-templates` سه پریست دارد (مستند بند ۱۷: «الگوی آماده برای هر نوع کلینیک»):
| پریست | بخش‌ها |
|---|---|
| `beauty` | آماده‌سازی ۵ · انتظار ۳۰ (فقط اتاق) · درمان (سهمی ۱۰۰) · مراقبت ۵ |
| `dental` | آماده‌سازی ۵ · درمان (سهمی ۱۰۰) · تمیزکاری یونیت ۱۰ (بدون حضور بیمار) |
| `physio` | درمان (سهمی ۱۰۰) |
dry-run پیش‌فرض. پریست‌ها روی سرویس‌های موجود اعمال نمی‌شوند مگر با `--service=<uuid>`.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `segment_templates` | جفت tenant |
| `segment_requirements` | `AGGREGATE_CHILDREN` → ریشه `SegmentTemplate` |
@@ -0,0 +1,131 @@
# نکات پیاده‌سازی — تسک ۰۵
## ۱. برنامه یک تابع خالص است
`AppointmentPlanBuilder::build()` نباید چیزی بنویسد، چیزی cache کند، یا به `time()` نگاه کند.
ورودی یکسان → خروجی یکسان. دلیلش تسک ۰۶ است: موتور جستجو یک برنامه می‌سازد و آن را
برای ۹۰ روز × ده‌ها نقطهٔ شروع استفاده می‌کند. اگر ساختن برنامه عوارض جانبی داشته باشد،
جستجو یا کند می‌شود یا نتیجهٔ ناپایدار می‌دهد.
تنها I/O مجاز: خواندن الگوها و منابع کاندید (مرحلهٔ ۶).
## ۲. `offset_minutes` نسبی است، نه مطلق
بخش‌ها با فاصله از **شروع نوبت** ذخیره می‌شوند، نه timestamp:
```
بخش ۱ — offset 0, duration 5
بخش ۲ — offset 5, duration 30
بخش ۳ — offset 35, duration 20
بخش ۴ — offset 55, duration 5
```
تسک ۰۶ همین برنامه را روی هر نقطهٔ شروع کاندید «می‌لغزاند». اگر offset مطلق بود، برای هر
نقطهٔ شروع باید برنامه از نو ساخته می‌شد.
## ۳. `setup/cleanup` منبع کجا اعمال می‌شود
**نه در برنامه، در اشغال.** برنامه می‌گوید «اپراتور از دقیقهٔ ۳۵ تا ۵۵ لازم است». اشغال
واقعی همان منبع، با `setup=5, cleanup=10`، از دقیقهٔ ۳۰ تا ۶۵ است.
پس `PlannedRequirement` دو مقدار می‌دهد:
```php
public readonly int $startOffset; // ۳۵ — چیزی که به بیمار نشان داده می‌شود
public readonly int $endOffset; // ۵۵
public readonly int $occupancyStartOffset; // ۳۰ — چیزی که تسک ۰۶/۰۷ استفاده می‌کند
public readonly int $occupancyEndOffset; // ۶۵
```
محاسبه‌شان اینجا انجام می‌شود چون `setup/cleanup` per منبع کاندید متفاوت است و باید
بعد از مرحلهٔ ۶ (پیدا کردن کاندیدها) حساب شود — با بیشینهٔ کاندیدها، تا برنامه محافظه‌کار
بماند و تسک ۰۶ بعد از انتخاب منبع دقیقش کند.
## ۴. ادغام: `count` بیشینه، نه جمع
وقتی دو بخش هم‌نوع ادغام می‌شوند و هر دو «۱ اپراتور» می‌خواهند، نتیجه **۱** اپراتور است،
نه ۲. همان اپراتور هر دو کار را می‌کند.
جمع فقط وقتی درست است که دو کار واقعاً هم‌زمان انجام شوند — که در بخش‌های ادغام‌شده
(که ذاتاً یک کار شده‌اند) صدق نمی‌کند.
## ۵. قید جنسیت — سکوت ممنوع
```php
if (($req->constraints['same_gender_as_patient'] ?? false) && $patient?->getGender() === null) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'برای این سرویس ثبت جنسیت بیمار الزامی است',
422
);
}
```
نادیده گرفتنِ قید وقتی داده نیست، بدترین حالت است: در کلینیک زیبایی ایران این یک الزام
جدی است (مستند بند ۶) و نقض خاموشش یعنی بیمار سر قرار با اپراتور نامناسب روبه‌رو می‌شود.
## ۶. `occupancy` سه حالت — تفاوت عملی
| حالت | در تسک ۰۶ | در تسک ۰۷ |
|---|---|---|
| `exclusive` | منبع باید کاملاً آزاد باشد | یک ردیف اشغال با `units = capacity` |
| `shared` | `COUNT(اشغال‌های فعال) < capacity` | یک ردیف با `units = 1` |
| `passive` | مثل `exclusive` | ردیف اشغال با پرچم `passive` — گزارش بهره‌وری آن را «کارِ فعال» حساب نمی‌کند |
اینجا فقط ستون و اعتبارسنجی است؛ معنی‌شان در ۰۶ و ۰۷ پیاده می‌شود. ولی تفاوت را همین
حالا در `docs/api/appointment-plan.md` بنویس.
## ۷. سقف‌ها — حفاظت از تسک ۰۶
| سقف | مقدار | چرا |
|---|---|---|
| تعداد بخش یک برنامه | ۲۰ | هر بخش یعنی یک بررسی تداخل per نقطهٔ شروع |
| مجموع مدت برنامه | ۴۸۰ دقیقه | برنامهٔ طولانی‌تر عملاً هیچ روزی جا نمی‌شود |
| تعداد نیازمندی هر بخش | ۱۰ | |
| تعداد آیتم انتخابی | ۲۰ | (تسک ۰۴ هم `max_select` دارد، این سقف کلی است) |
نقض → `422` با پیام روشن، نه تلاش برای محاسبه. مستند بند ۱۷ ریسک «کند شدن جستجو با
زیاد شدن منابع» را اولین ریسک می‌داند؛ سقف‌ها ارزان‌ترین دفاع‌اند.
## ۸. edge case ها
| حالت | رفتار درست |
|---|---|
| سرویس بدون الگو | یک بخش مجازی با منبع `type=doctor` — رفتار امروزی |
| بخش با `requirements = []` | معتبر؛ زمان می‌گیرد، منبع نمی‌گیرد |
| همهٔ بخش‌ها `fixed_minutes` و آیتم‌ها مدت دارند | مدت آیتم‌ها **نادیده** نمی‌رود: اگر هیچ بخش سهمی نباشد و آیتم مدت داشته باشد → `422` هنگام ذخیرهٔ الگو |
| `duration_share` جمعش ۹۹ | `422` هنگام ذخیره |
| دو بخش با یک `sequence` | ترتیب بر اساس `id` پایدار شود، نه خطا |
| بخش آیتمی که آیتمش انتخاب نشده | در برنامه نمی‌آید |
| `specific_resource` غیرفعال | `candidates=[]` → خطای انسانی |
| استخر خالی | همان |
| بیمار مهمان (بدون پرونده) و قید جنسیت | جنسیت از فرم رزرو (`patient_gender` روی `Appointment` هست) گرفته شود |
## ۹. تست
```
tests/Appointment/Plan/SegmentAssemblerTest.php
- جمع‌آوری بخش سرویس + بخش آیتم
- ادغام دو بخش هم‌نوع mergeable → یکی، count بیشینه نه جمع
- بخش غیر-mergeable هم‌نوع → جدا می‌ماند
tests/Appointment/Plan/SegmentDurationResolverTest.php
- fixed ثابت می‌ماند وقتی آیتم‌ها زیاد شوند
- سهمی با مدت محاسبه‌شدهٔ تسک ۰۴ مقیاس می‌گیرد
- جمع مدت بخش‌ها = total_minutes
tests/Appointment/Plan/AppointmentPlanBuilderTest.php
- سناریوی کامل مستند: چهار بخش، offset های ۰/۵/۳۵/۵۵، total=60
- سرویس بدون الگو → یک بخش با منبع doctor (سازگاری)
- قطعیت: دو بار build با ورودی یکسان → خروجی یکسان
tests/Appointment/Plan/RequirementResolverTest.php
- مهارت موجود → کاندید
- هیچ کاندید → NoEligibleResourceException با پیام شامل نقش و مهارت
- قید جنسیت با بیمار بدون جنسیت → 422
- منبع محیط دیگر هرگز کاندید نمی‌شود
tests/Appointment/Plan/PlanLimitsTest.php
- ۲۱ بخش → 422 · ۴۸۱ دقیقه → 422
```
## ۱۰. مستندات
`docs/api/appointment-plan.md` بساز — شامل جدول سه حالت اشغال، مثال کامل خروجی
`preview`، و توضیح تفاوت `offset` نمایشی با `occupancy_offset`.
@@ -0,0 +1,102 @@
# تسک ۰۵ — بخش‌های نوبت و سازندهٔ برنامه
**فاز:** ۱ (هسته) · **وابستگی:** ۰۲، ۰۴ · **زمان:** ۱۶-۲۰ ساعت
---
## هدف
مهم‌ترین بخش مستند (بند ۷). یک نوبت یک تکه زمان پیوسته نیست:
| بخش | مدت | اتاق | اپراتور | دستگاه |
|---|---|---|---|---|
| مالیدن کرم بی‌حسی | ۵ | اشغال | اشغال | آزاد |
| انتظار اثر کرم | ۳۰ | اشغال | **آزاد** | آزاد |
| خود لیزر | ۲۰ | اشغال | اشغال | اشغال |
| مراقبت بعد | ۵ | اشغال | اشغال | آزاد |
با مدل امروز اپراتور ۶۰ دقیقه قفل می‌شود در حالی که ۳۰ دقیقه کار می‌کند. نصف ظرفیت
هدر می‌رود.
## وضعیت فعلی
```php
// src/Appointment/Entity/Appointment.php
private int $slotStart; // یک بازهٔ پیوسته
private int $slotEnd;
```
هیچ مفهومی از بخش، و هیچ نیازمندی منبعی وجود ندارد. تنها منبعی که تداخلش بررسی می‌شود
پزشک است (`AppointmentRepository::isSlotTaken`).
## دامنه
**هست:**
- `SegmentTemplate` — الگوی بخش‌های یک سرویس (و بخش‌های اضافهٔ هر `ServiceOption`)
- `SegmentRequirement` — نیازمندی منبع هر بخش: نقش، تعداد، شرط مهارت، قید، نوع اشغال
- `AppointmentPlanBuilder` — از (سرویس، آیتم‌ها، بیمار، شعبه) یک **برنامهٔ نوبت** می‌سازد
- ادغام بخش‌های هم‌نوع، محاسبهٔ مدت هر بخش، چیدمان ترتیبی
- `GET /api/v1/appointment-plan/preview` برای دیدن برنامه پیش از جستجوی وقت
**نیست:** پیدا کردن منابع آزاد و زمان (تسک ۰۶)، ثبت اشغال (تسک ۰۷)،
اعمال قوانین روی برنامه (تسک ۰۹ — نقطهٔ اتصالش اینجا آماده می‌شود).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET/PUT | `/api/v1/service-item/{uuid}/segments` | الگوی بخش‌های سرویس |
| PUT | `/api/v1/segment-template/{uuid}/requirements` | نیازمندی‌های منبع یک بخش |
| POST | `/api/v1/appointment-plan/preview` | ساخت و برگرداندن برنامهٔ نوبت (بدون ثبت) |
## خروجی `POST /appointment-plan/preview`
```json
{
"success": true,
"data": {
"total_minutes": 60,
"segments": [
{ "sequence": 1, "name": "بی‌حسی موضعی", "offset_minutes": 0, "duration_minutes": 5,
"patient_present": true,
"requirements": [
{ "role": "room", "count": 1, "occupancy": "exclusive", "candidates": 3 },
{ "role": "operator", "count": 1, "occupancy": "exclusive", "candidates": 2 }
] },
{ "sequence": 2, "name": "انتظار", "offset_minutes": 5, "duration_minutes": 30,
"patient_present": true, "mergeable": true,
"requirements": [ { "role": "room", "count": 1, "occupancy": "exclusive", "candidates": 3 } ] }
]
}
}
```
`candidates` تعداد منابع واجد شرایط است — اگر صفر باشد، برنامه ساخته نمی‌شود و خطای
انسانی برمی‌گردد: «هیچ اپراتور خانمی با مهارت لیزر در این شعبه نیست» (مستند بند ۱۰).
## معیار پذیرش
- ✅ موفق: سرویس «لیزر» با چهار بخش بالا تعریف می‌شود؛ `preview` برنامه‌ای با
`total_minutes = 60` و چهار بخش با `offset_minutes` صحیح (۰، ۵، ۳۵، ۵۵) برمی‌گرداند.
- ✅ موفق: انتخاب دو ناحیه (صورت + بیکینی) → بخش «آماده‌سازی» **یک بار** می‌آید
(`mergeable=true` هم‌نوع‌ها ادغام می‌شوند) ولی بخش «لیزر» مدتش با `DurationCalculator`
تسک ۰۴ محاسبه شده است.
- ✅ موفق: سرویسی که هیچ `SegmentTemplate` ندارد → برنامه‌ای با **یک بخش** برابر کل مدت
و نیازمندی پیش‌فرض (منبع `type=doctor`). این همان رفتار امروز است.
- ❌ خطا: نیازمندی با مهارتی که هیچ منبعی در آن شعبه ندارد →
`422` با `ERR_NO_ELIGIBLE_RESOURCE` و پیام فارسی شامل نقش و مهارت.
- ❌ خطا: بخش با `duration_minutes <= 0` و بدون منبع مدت پویا → `422`.
- ⚠️ مرزی: بخش با `requirements = []` (مثلاً «انتظار در خانه») → معتبر؛ زمان می‌گیرد،
هیچ منبعی نمی‌گیرد.
- ⚠️ مرزی: قید `same_gender_as_patient` وقتی جنسیت بیمار نامشخص است → نیازمندی نادیده
گرفته **نمی‌شود**؛ `422` با پیام «برای این سرویس ثبت جنسیت بیمار الزامی است».
- ⚠️ مرزی: `setup/cleanup` منبع در `preview` **نمایش داده نمی‌شود** ولی در
`occupancy_offset` هر نیازمندی می‌آید تا تسک ۰۶ همان را استفاده کند.
- ⚠️ مرزی: مجموع مدت بخش‌ها بیشتر از ۸ ساعت → `422` (حفاظت از جستجوی وقت).
## خروجی
- `src/Appointment/Plan/` — entity ها، `AppointmentPlanBuilder`، DTO ها
- `assets/admin/pages/ServiceSegmentsPage.tsx` + پیش‌نمایش برنامه
- `docs/api/appointment-plan.md`
- الگوهای آماده: `app:segment:seed-templates --preset=beauty|dental|physio` (مستند بند ۱۷)
@@ -0,0 +1,123 @@
# جریان کاربری — تسک ۰۵
## الف) کلینیک الگوی بخش‌های یک سرویس را تعریف می‌کند
```
پنل › خدمات › لیزر کندلا › تب «بخش‌های نوبت»
├─ «افزودن بخش»
│ نام: مالیدن کرم بی‌حسی
│ نوع (کلید ادغام): آماده‌سازی
│ مدت: ثابت — ۵ دقیقه
│ بیمار حاضر است: بله
│ با بخش‌های هم‌نوع ادغام شود: بله
│ └─ نیازمندی‌ها:
│ [اتاق] × ۱ — انحصاری
│ [اپراتور] × ۱ — انحصاری — مهارت: لیزر آلکساندرایت — قید: هم‌جنس با بیمار
├─ بخش ۲: انتظار اثر بی‌حسی — ثابت ۳۰ — ادغام‌پذیر
│ نیازمندی: فقط [اتاق] × ۱ انحصاری ← اپراتور اینجا آزاد است
├─ بخش ۳: خود لیزر — سهمی ۱۰۰٪
│ نیازمندی: [اتاق] × ۱ · [اپراتور] × ۱ · [دستگاه] × ۱ از استخر «لیزرهای آلکساندرایت»
└─ بخش ۴: مراقبت بعد — ثابت ۵
نیازمندی: [اتاق] × ۱ · [اپراتور] × ۱
نوار پیش‌نمایش زمانی (زیر فرم، زنده):
┌─────┬───────────────────┬─────────────┬─────┐
۵' │ ۳۰' │ ۲۰' │ ۵' │
│🏠👤 │ 🏠 │ 🏠 👤 🔧 │🏠👤 │
└─────┴───────────────────┴─────────────┴─────┘
کل: ۶۰ دقیقه · اپراتور واقعاً درگیر: ۳۰ دقیقه
«ذخیره» → PUT /api/v1/service-item/{uuid}/segments
```
خط «اپراتور واقعاً درگیر: ۳۰ دقیقه» مهم‌ترین بازخورد این صفحه است: کلینیک آنجا می‌فهمد
چرا این کار ارزشش را دارد.
---
## ب) بیمار سرویس و آیتم انتخاب می‌کند (سایت عمومی)
```
انتخاب پزشک/کلینیک
GET /api/v1/appointment-booking-services/{doctorUuid}
→ booking_mode = "resource" ← حالت جدید
→ services[] با گروه‌های آیتم
بیمار انتخاب می‌کند: ناحیه = صورت + بیکینی · سطح انرژی = ۱۶
POST /api/v1/service-selection/validate (تسک ۰۴، debounce)
→ valid: true · total_duration_minutes: 23 · total_price_rials: …
│ اگر valid=false:
│ خطاها زیر همان گروه نمایش داده می‌شوند
│ «انتخاب حداقل یک مورد از نواحی الزامی است»
│ «صورت و فول‌بادی با هم قابل انتخاب نیستند»
│ و دکمهٔ «ادامه» غیرفعال می‌ماند
POST /api/v1/appointment-plan/preview
→ total_minutes: 68
segments: [آماده‌سازی ۵ · انتظار ۳۰ · لیزر ۲۳ · مراقبت ۵ · تمیزکاری ۵]
│ اگر NoEligibleResourceException:
│ «هیچ اپراتور خانمی با مهارت لیزر آلکساندرایت در شعبهٔ مرکزی موجود نیست»
│ + پیشنهاد شعبهٔ دیگر (اگر داشته باشد)
مرحلهٔ انتخاب زمان → تسک ۰۶
```
**نکته UX:** برنامهٔ نوبت به بیمار **نمایش داده نمی‌شود**. بیمار فقط «۶۸ دقیقه» و
«توضیحات آماده‌سازی» را می‌بیند. بخش‌ها جزئیات عملیاتی کلینیک‌اند؛ نشان دادنشان به بیمار
فقط سؤال می‌سازد.
استثنا: بخش‌هایی با `patient_present = false` نباید در مدت اعلامی به بیمار بیایند
(«تمیزکاری یونیت» ۵ دقیقهٔ بعد از رفتن بیمار است). پس دو عدد وجود دارد:
- `total_minutes` = ۶۸ (اشغال کلینیک) — برای موتور
- `patient_facing_minutes` = ۶۳ — برای نمایش
هر دو در پاسخ `preview` برگردند.
---
## ج) منشی از پنل نوبت می‌سازد
همان جریان ب، با دو تفاوت:
1. `forManagement = true` — بازهٔ رزرو و خاموش بودن نوبت‌دهی آنلاین اعمال نمی‌شود
(همان رفتاری که `SlotCalculatorService::isWithinBookingWindow()` امروز دارد)
2. منشی می‌تواند **منبع را دستی انتخاب کند**: پاسخ `preview` برای هر نیازمندی
`candidates` را با نام برمی‌گرداند و پنل یک `SearchableSelect` اختیاری نشان می‌دهد.
خالی گذاشتن یعنی «تو انتخاب کن» (استراتژی تسک ۰۶).
---
## د) حالت خطا — هیچ منبعی موجود نیست
```
POST /appointment-plan/preview
422 {
"success": false,
"errors": [{
"code": "ERR_NO_ELIGIBLE_RESOURCE",
"message": "هیچ اپراتور خانمی با مهارت لیزر آلکساندرایت در شعبهٔ مرکزی موجود نیست",
"meta": { "segment": "خود لیزر", "role": "اپراتور", "branch_uuid": "…" }
}]
}
```
`meta` اجباری است: پنل با آن می‌تواند مستقیم به صفحهٔ منابع همان شعبه لینک بدهد
(«افزودن اپراتور») و کلینیک در سه کلیک مشکل را حل کند، به‌جای اینکه با یک پیام
بن‌بست بماند.
@@ -0,0 +1,186 @@
# معماری — تسک ۰۶
## ساختار فایل
```
src/Appointment/Availability/
├── AvailabilityEngine.php # ارکستراتور
├── CandidateGenerator.php # نقطه‌های شروع ممکن + هرس
├── ResourceAllocator.php # تطبیق نیازمندی‌ها به منابع آزاد
├── OccupancyIndex.php # ایندکس درون‌حافظه‌ای اشغال‌ها
├── Strategy/
│ ├── ResourcePickerInterface.php
│ ├── LeastGapPicker.php # پیش‌فرض
│ ├── BalancedPicker.php
│ ├── PreserveSpecialistsPicker.php
│ └── SameAsPreviousPicker.php
├── Cache/DailyWindowCache.php
├── Dto/{AvailabilitySlot, ResourceAssignment, AvailabilityRequest}.php
└── Controller/AvailabilityController.php
```
## جریان اصلی
```php
public function search(AvailabilityRequest $req): array
{
// ۱. برنامه یک بار ساخته می‌شود، نه per روز (تسک ۰۵)
$plan = $this->planBuilder->build($req->toPlanRequest());
// ۲. منابع کاندید هر نیازمندی — یک بار برای کل بازه
$candidates = $plan->allCandidateResourceIds();
// ۳. سه واکشی انبوه برای کل بازه (نه per روز، نه per منبع)
$windows = $this->availability->rawWindowsBulk($candidates, $req->from, $req->to);
$occupancy = $this->occupancyRepo->findForResources($candidates, $req->from, $req->to);
$index = OccupancyIndex::build($occupancy, $windows);
// ۴. نقطه‌های شروع کاندید + هرس
$starts = $this->candidates->generate($plan, $windows, $req);
// ۵. برای هر نقطه: تخصیص منبع
$result = [];
foreach ($starts as $start) {
$assignment = $this->allocator->tryAllocate($plan, $start, $index, $req->strategy);
if ($assignment !== null) {
$result[] = new AvailabilitySlot($start, $plan->totalMinutes, $assignment);
if (count($result) >= $req->limit) break;
}
}
// ۶. قلاب تسک ۰۹: قوانین فاصلهٔ زمانی
return $this->policies->filterSlots($result, $req);
}
```
**سه کوئری برای کل بازه.** هیچ کوئری‌ای داخل حلقه. این تنها راه رسیدن به هدف نیم ثانیه است.
## `OccupancyIndex`
ساختار درون‌حافظه‌ای که «آیا منبع R در بازهٔ [s, e) جا دارد؟» را بدون کوئری جواب می‌دهد:
```php
final class OccupancyIndex
{
/** @var array<int, array<array{start:int,end:int,units:int}>> resourceId → بازه‌های اشغال مرتب */
private array $busy;
/** @var array<int, array<array{start:int,end:int}>> resourceId → پنجره‌های آزاد */
private array $windows;
/** @var array<int,int> resourceId → capacity */
private array $capacity;
public function hasRoom(int $resourceId, int $start, int $end, int $units): bool
{
// ۱. باید کاملاً داخل یکی از پنجره‌های آزاد باشد
// ۲. جمع units اشغال‌های متداخل + units درخواستی <= capacity
}
}
```
بازه‌های `busy` مرتب نگه داشته می‌شوند تا بررسی تداخل با جستجوی دودویی روی نقطهٔ شروع
انجام شود، نه پیمایش خطی — با ۵۰۰ نوبت × ده‌ها کاندید، تفاوتش دیده می‌شود.
## `CandidateGenerator` — هرس زودهنگام
```
گام پیش‌فرض: ۱۵ دقیقه (قابل تنظیم per محیط: appointment_settings.slot_granularity)
برای هر روز از بازه:
۱. پنجره‌های آزاد «تنگ‌ترین منبع» را بگیر
(منبعی که کمترین دقیقهٔ آزاد در آن روز دارد — معمولاً دستگاه)
۲. نقطه‌های شروع فقط داخل آن پنجره‌ها تولید شوند
۳. نقطه‌ای که [start, start+totalMinutes) از پنجره بیرون بزند → حذف
۴. نقطهٔ گذشته → حذف
```
گام ۱ مهم‌ترین هرس است: اگر دستگاه لیزر روزی ۴ ساعت آزاد است، تولید ۹۶ کاندید برای
۲۴ ساعت بی‌معنی است. با این هرس معمولاً ۸۰٪ کاندیدها قبل از هر محاسبه‌ای حذف می‌شوند.
## `ResourceAllocator` — تطبیق
مسئلهٔ واقعی: هر بخش چند نیازمندی دارد، هر نیازمندی چند کاندید، و **منبع مشترک بین
بخش‌ها باید یکی باشد**.
```php
public function tryAllocate(AppointmentPlan $plan, int $start, OccupancyIndex $index, string $strategy): ?ResourceAssignment
{
$chosen = []; // requirementKey → resourceId
foreach ($plan->segments as $segment) {
foreach ($segment->requirements as $req) {
$key = $req->groupKey(); // نقش + مهارت‌ها + قیدها → نیازمندی‌های هم‌شکل یک منبع می‌گیرند
if (isset($chosen[$key])) {
// منبع قبلاً انتخاب شده — فقط باید در این بازه هم آزاد باشد
if (!$index->hasRoom($chosen[$key], )) return null;
continue;
}
$free = array_filter($req->candidateIds, fn($id) => $index->hasRoom($id, ));
if ($free === []) return null;
$chosen[$key] = $this->pickers[$strategy]->pick($free, $req, $index, $plan);
}
}
return new ResourceAssignment($chosen);
}
```
### `groupKey()` — چرا لازم است
اپراتورِ بخش ۱ و اپراتورِ بخش ۳ باید یک نفر باشند (بیمار وسط کار اپراتور عوض نمی‌کند).
`groupKey` نیازمندی‌های هم‌شکل را یکی می‌کند. اگر واقعاً دو نفر لازم است، نیازمندی باید
`count = 2` باشد یا مهارت/قید متفاوت داشته باشد.
⚠️ این ساده‌سازی است: در حالت کلی، تخصیص با backtracking کامل است. عمداً backtracking
نمی‌کنیم — با سقف‌های تسک ۰۵ (۲۰ بخش، ۱۰ نیازمندی) حالت‌های شکست نادرند و هزینهٔ
backtracking در مسیر داغ توجیه ندارد. اگر تخصیص حریصانه شکست خورد، آن نقطهٔ شروع رد
می‌شود؛ بدترین حالت یعنی یک زمان ممکن نمایش داده نمی‌شود، نه یک رزرو اشتباه.
این تصمیم را در `docs/api/appointment-availability.md` بنویس.
## استراتژی‌های انتخاب منبع
| استراتژی | قاعده | کاربرد |
|---|---|---|
| `least_gap` (پیش‌فرض) | منبعی که کمترین شکاف بلااستفاده بسازد — نزدیک‌ترین اشغال قبلی/بعدی | بیشترین بهره‌وری |
| `balanced` | کم‌کارترین منبع آن روز | رضایت پرسنل |
| `preserve_specialists` | کمترین `level` کافی — متخصص برای کار ساده مصرف نشود | کلینیک با اپراتور ماهر کم |
| `same_as_previous` | همان منبع جلسات قبلی همان بیمار (تسک ۱۲) | دوره‌های درمان |
`ResourcePickerInterface` با تزریق آرایه‌ای (`!tagged_iterator`) — افزودن استراتژی پنجم
نباید هیچ کلاس موجودی را تغییر دهد (OCP).
## کش پنجره‌های روزانه
```php
final class DailyWindowCache
{
// کلید: resource:{id}:windows:{Y-m-d}
// TTL: تا پایان همان روز
// ابطال: هر تغییر در resource_calendars / resource_exceptions / holidays آن منبع
}
```
Redis از قبل در استک هست (`symfony/redis-messenger`). **فقط پنجره‌های تقویمی کش می‌شوند،
نه اشغال‌ها** — اشغال هر ثانیه عوض می‌شود و کش‌کردنش یعنی نمایش وقتِ گرفته‌شده.
## حالت `resource` روی برنامهٔ هفتگی
```php
// WeeklySchedule
public const MODE_RESOURCE = 'resource';
public const DEFAULT_META = [
,
'booking_mode' => self::MODE_SLOT,
'slot_granularity' => 15, // جدید
'picker_strategy' => 'least_gap', // جدید
];
```
`booking_mode` امروز پس از اولین ثبت **قفل** می‌شود (`getStoredBookingMode()`).
این تسک یک استثنای کنترل‌شده اضافه می‌کند: ارتقا از `slot`/`service` به `resource` مجاز
است (یک‌طرفه، بازگشت ممنوع)، مشروط بر اینکه هیچ نوبت فعال آینده‌ای وجود نداشته باشد.
`POST /api/v1/appointment-settings/upgrade-booking-mode` با تأیید صریح.
@@ -0,0 +1,95 @@
# دیتابیس — تسک ۰۶
این تسک **جدول جدیدی نمی‌سازد** جز کش. مصرف‌کنندهٔ جدول‌های تسک ۰۲/۰۳ و
`resource_occupancy` تسک ۰۷ است.
⚠️ **وابستگی معکوس:** موتور جستجو به `resource_occupancy` نیاز دارد ولی آن جدول در تسک ۰۷
ساخته می‌شود. راه‌حل: **مهاجرت جدول `resource_occupancy` در همین تسک انجام شود** و تسک ۰۷
فقط منطق نوشتن در آن را اضافه کند. تعریف کامل جدول در
[task-07/database.md](../task-07-hold-and-book/database.md) است؛ اینجا فقط ایندکس‌های
لازم برای خواندن ذکر می‌شوند.
## ایندکس‌های حیاتی خواندن
```sql
-- کوئری داغ: اشغال‌های این منابع در این بازه
KEY idx_occupancy_resource_range (resource_id, start_at, end_at, status)
```
پرس‌وجو:
```sql
SELECT resource_id, start_at, end_at, units
FROM resource_occupancy
WHERE resource_id IN (?, ?, )
AND start_at < :to AND end_at > :from
AND status IN ('hold', 'booked')
```
`resource_id` ستون اول است چون `IN` روی آن، محدودکننده‌ترین شرط است. اضافه کردن
`entity_type` به ابتدای این ایندکس **اشتباه** است: اینجا فیلتر tenant از راه منابع
(که خودشان محیط دارند) اعمال شده و ستون tenant-پیشرو فقط ایندکس را بی‌اثر می‌کند.
یک ایندکس دوم tenant-پیشرو برای لیست‌های پنل جدا تعریف می‌شود (تسک ۰۷).
## تنظیمات جدید روی `weekly_schedules.setting.meta`
بدون تغییر schema (ستون JSON موجود):
```json
{
"meta": {
"booking_mode": "resource",
"slot_granularity": 15,
"picker_strategy": "least_gap",
"buffer_minutes": 0
}
}
```
`setMeta()` باید کلیدهای جدید را با اعتبارسنجی بپذیرد:
- `slot_granularity` ∈ {5, 10, 15, 20, 30, 60}
- `picker_strategy` ∈ کلیدهای ثبت‌شدهٔ `ResourcePickerInterface`
مقدار نامعتبر → مقدار فعلی حفظ می‌شود (همان الگوی موجود `setMeta`).
## کش
Redis، بدون جدول. کلیدها:
```
cp:avail:res:{resourceId}:win:{Y-m-d} → JSON بازه‌های آزاد TTL تا پایان روز
cp:avail:month:{branchId}:{serviceId}:{Y-m} → JSON بولین per روز TTL 300s
```
ابطال:
| رویداد | کلیدهای باطل |
|---|---|
| تغییر `resource_calendars` | همهٔ `win` آن منبع |
| ثبت/حذف `resource_exceptions` | `win` آن منبع در بازهٔ استثنا |
| تغییر `branch_working_hours` | `win` همهٔ منابع آن شعبه |
| تغییر `tenant_holiday_overrides` | `win` همهٔ منابع آن محیط در آن روز |
| ثبت/لغو نوبت | فقط `month`**`win` هرگز** (اشغال کش نمی‌شود) |
آخرین سطر مهم‌ترین است: اگر کسی وسوسه شد اشغال را هم کش کند، نتیجه‌اش نمایش وقتِ
گرفته‌شده و شکست رزرو در مرحلهٔ آخر است.
## تست کارایی — داده مصنوعی
```bash
ddev exec php bin/console app:dev:seed-availability-benchmark --force
```
می‌سازد:
- ۱ شعبه · ۳ اتاق (ظرفیت ۱) · ۲ اپراتور · ۳ دستگاه در یک استخر
- ۱ سرویس با ۴ بخش (سناریوی مستند)
- ۵۰۰ نوبت پراکنده در ۳۰ روز آینده
`AvailabilityPerformanceTest` روی همین داده اجرا می‌شود و **دو** چیز را می‌سنجد:
```php
self::assertLessThan(500, $elapsedMs, 'جستجوی ۳۰ روزه باید زیر نیم ثانیه باشد');
self::assertLessThanOrEqual(5, $queryCount, 'تعداد کوئری نباید با تعداد روز رشد کند');
```
شرط دوم مهم‌تر از اولی است: زمان روی ماشین‌های مختلف فرق می‌کند، تعداد کوئری نه.
@@ -0,0 +1,158 @@
# نکات پیاده‌سازی — تسک ۰۶
## ۱. سه کوئری، بعد هیچ
قاعدهٔ غیرقابل‌مذاکره: **داخل حلقهٔ روز و حلقهٔ نقطهٔ شروع، هیچ I/O نیست.**
```php
// ❌ مرگ کارایی
foreach ($days as $day) {
foreach ($starts as $start) {
if ($this->occupancyRepo->isFree($resource, $start, $end)) { } // ← N×M کوئری
}
}
// ✅
$index = OccupancyIndex::build($this->occupancyRepo->findForResources($ids, $from, $to), $windows);
foreach ($starts as $start) { $index->hasRoom($id, $start, $end, 1); }
```
`AvailabilityPerformanceTest` تعداد کوئری را قفل می‌کند تا اولین refactor این را نشکند.
## ۲. `setup/cleanup` — بازهٔ اشغال، نه بازهٔ بخش
```php
$occStart = $segmentStart - $resource->getSetupMinutes() * 60;
$occEnd = $segmentEnd + $resource->getCleanupMinutes() * 60;
$index->hasRoom($resourceId, $occStart, $occEnd, $units);
```
نکتهٔ ظریف: `setup/cleanup` per **منبع** است، ولی منبع در لحظهٔ بررسی هنوز انتخاب نشده.
پس دو گذر:
1. بررسی اولیه با **بیشینهٔ** `setup/cleanup` کاندیدها (محافظه‌کار)
2. بعد از انتخاب منبع، بازهٔ دقیق همان منبع محاسبه و دوباره بررسی شود
گذر دوم ارزان است (یک منبع، یک بازه) و از رد شدن اشتباه کاندیدها جلوگیری می‌کند.
## ۳. `capacity` و `units`
```
exclusive → units = capacity (منبع کامل)
shared → units = 1
passive → units = capacity (رزرو است، ولی پرچم passive برای گزارش)
```
`hasRoom` جمع `units` اشغال‌های متداخل را با `capacity` مقایسه می‌کند. با این مدل،
اتاق تزریق سه‌تخته با یک ردیف کار می‌کند و شمارش خودکار است.
## ۴. منبع مشترک بین بخش‌ها
مثال مستند: اپراتور در بخش ۱ (۰-۵) و بخش ۳ (۳۵-۵۵) لازم است، در بخش ۲ نه.
- **باید همان اپراتور باشد** → `groupKey` در `ResourceAllocator`
- **در بخش ۲ نباید اشغال بماند** → دو ردیف اشغال جدا، نه یکی از ۰ تا ۵۵
اگر ردیف را یکی کنی، کل ارزش این پروژه از بین می‌رود: همان ۳۰ دقیقه‌ای که می‌خواستیم
آزاد کنیم دوباره قفل می‌شود. تست پذیرش «آزادسازی ظرفیت» دقیقاً همین را می‌سنجد.
## ۵. مسیر قدیمی دست‌نخورده
`SlotCalculatorService` **هیچ تغییری نمی‌کند**. `AvailabilityEngine` یک کلاس جدید کنارش است.
انتخاب بین این دو فقط در کنترلر و بر اساس `booking_mode`:
```php
$mode = $schedule?->getMeta()['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
return match ($mode) {
WeeklySchedule::MODE_RESOURCE => $this->availabilityEngine->search($req),
WeeklySchedule::MODE_SERVICE => $this->slotCalculator->getServiceStartTimes(), // بدون تغییر
default => $this->slotCalculator->getAvailableSlots(), // بدون تغییر
};
```
هر endpoint فقط حالت خودش را می‌پذیرد و بقیه را با `ERR_WRONG_BOOKING_MODE` رد می‌کند —
نه fallback خاموش. fallback خاموش یعنی کلینیکی که فکر می‌کند حالت جدید دارد، بی‌صدا
روی حالت قدیم کار می‌کند و هیچ‌کس نمی‌فهمد چرا ظرفیتش باز نشد.
## ۶. ارتقای حالت — یک‌طرفه و با شرط
```
POST /api/v1/appointment-settings/upgrade-booking-mode
{ "schedule_uuid": "…", "confirm": true }
شرایط:
- حالت فعلی slot یا service باشد
- هیچ نوبت pending/confirmed آینده‌ای وجود نداشته باشد
- حداقل یک منبع فعال در شعبه باشد
- سرویس‌های bookable حداقل یک SegmentTemplate یا duration معتبر داشته باشند
بازگشت به حالت قبلی: ممنوع (پاسخ 422)
```
دلیل ممنوعیت بازگشت: نوبت‌های ثبت‌شده در حالت `resource` بخش و اشغال چندمنبعی دارند و
مدل قدیمی نمی‌تواند نمایششان دهد.
## ۷. سقف‌ها و پیش‌فرض‌ها
| پارامتر | پیش‌فرض | سقف |
|---|---|---|
| بازهٔ جستجو | ۳۰ روز | ۹۰ روز (مستند بند ۱۰) |
| `limit` نتایج | ۵۰ | ۲۰۰ |
| گام کاندید | ۱۵ دقیقه | حداقل ۵ |
| منابع کاندید per نیازمندی | — | ۵۰ (بیشتر → `422` با پیشنهاد استفاده از استخر) |
## ۸. edge case ها
| حالت | رفتار درست |
|---|---|
| هیچ نتیجه‌ای در بازه | `data: []` + `reason` (`no_resource`, `fully_booked`, `no_calendar`) — نه ۴۰۴ |
| نقطهٔ شروع دقیقاً روی لبهٔ پنجرهٔ آزاد | معتبر — بازه‌ها نیم‌باز `[s, e)` |
| نوبتی که تازه لغو شده | با کش پنجره‌ای تداخل ندارد چون اشغال کش نمی‌شود |
| `hold` منقضی‌شده در `resource_occupancy` | در کوئری `WHERE status='hold' AND expires_at > :now` رد شود |
| برنامهٔ ۶۰ دقیقه‌ای و پنجرهٔ آزاد ۵۹ دقیقه | هیچ کاندیدی — هرس گام ۳ |
| منبعِ استخری که وسط بازه غیرفعال شده | `findEligible` فقط `active=true` می‌دهد؛ اشغال‌های قبلی‌اش می‌مانند |
| دو نیازمندی هم‌شکل با `count=1` در یک بخش | `groupKey` یکسان → همان منبع دوبار انتخاب می‌شود ← **باگ**. `count=2` بنویس یا `groupKey` را با اندیس نیازمندی درون همان بخش متمایز کن |
| تغییر ساعت رسمی (تغییر ساعت تابستانی) | ایران از ۱۴۰۱ ندارد؛ ولی محاسبات با timestamp انجام شود نه ساعت محلی |
سطر ماقبل آخر یک تلهٔ واقعی است: `groupKey` باید بین **بخش‌ها** یکی باشد ولی درون یک
بخش، دو نیازمندی مجزا دو منبع بگیرند. کلید = `(role, skills, constraints, indexInSegment)`
و تطبیق بین‌بخشی روی سه جزء اول.
## ۹. تست
```
tests/Appointment/Availability/OccupancyIndexTest.php ← واحد، بدون DB
- capacity=3 با ۲ اشغال → جا دارد؛ با ۳ → ندارد
- بازهٔ مماس (end == start) → تداخل نیست
- shared vs exclusive
tests/Appointment/Availability/CandidateGeneratorTest.php
- هرس با تنگ‌ترین منبع
- نقطهٔ گذشته حذف
- برنامه‌ای که در پنجره جا نمی‌شود → هیچ کاندید
tests/Appointment/Availability/ResourceAllocatorTest.php
- منبع مشترک بین بخش ۱ و ۳ → یک نفر
- دو نیازمندی هم‌شکل در یک بخش → دو منبع
- تخصیص ناموفق → null، نه استثنا
tests/Appointment/Availability/CapacityReleaseTest.php ← ⭐ تست پذیرش اصلی
- نوبت الف ۱۰:۰۰-۱۱:۰۰، اپراتور فقط ۱۰:۰۰-۱۰:۰۵ و ۱۰:۳۵-۱۱:۰۰
- جستجوی بیمار ب → زمانی در ۱۰:۰۵-۱۰:۳۵ پیدا شود
tests/Appointment/Availability/StrategyTest.php
- least_gap کمترین شکاف را می‌سازد
- balanced کم‌کارترین را می‌دهد
- preserve_specialists کمترین level کافی را می‌دهد
tests/Appointment/Availability/BookingModeGuardTest.php
- حالت slot روی endpoint جدید → 422 ERR_WRONG_BOOKING_MODE
- endpoint قدیمی در حالت resource → 422
- ارتقا با نوبت فعال آینده → 422
tests/Appointment/AvailabilityPerformanceTest.php
- < 500ms و <= 5 کوئری
tests/Appointment/LegacyBookingUnchangedTest.php
- همهٔ تست‌های موجود appointment-slots و appointment-service-slots سبز بمانند
```
## ۱۰. مستندات
`docs/api/appointment-availability.md` بساز — شامل جدول استراتژی‌ها، توضیح تخصیص حریصانه
و محدودیتش، و ماتریس «کدام endpoint در کدام حالت کار می‌کند».
`docs/api/appointment.md` را با بخش «حالت‌های نوبت‌دهی» به‌روز کن.
@@ -0,0 +1,78 @@
# تسک ۰۶ — موتور جستجوی وقت چندمنبعی
**فاز:** ۱ (هسته) · **وابستگی:** ۰۳، ۰۵ · **زمان:** ۲۰-۲۴ ساعت
---
## هدف
مستند بند ۱۰: برنامهٔ نوبت (تسک ۰۵) را روی تقویم منابع (تسک ۰۳) بلغزان و بگو چه
ساعت‌هایی واقعاً ممکن‌اند — با پیشنهاد اینکه کدام منبع استفاده شود.
**هدف کارایی: جستجوی یک ماهه زیر نیم ثانیه.**
## وضعیت فعلی
```php
// SlotCalculatorService::getServiceStartTimes() — تک‌منبعی، یک بلوک پیوسته
$busy = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400);
while ($t + $durSec <= $winEnd) {
$conflict = $this->firstOverlap($t, $t + $needSec, $busy);
}
```
فقط تداخل **پزشک** بررسی می‌شود. اتاق، دستگاه و اپراتور اصلاً وجود ندارند.
## دامنه
**هست:**
- `AvailabilityEngine` — ورودی: برنامهٔ نوبت + بازهٔ تاریخ + شعبه؛ خروجی: وقت‌های معتبر
همراه با تخصیص منبع پیشنهادی
- تولید نقطه‌های شروع کاندید (پیش‌فرض هر ۱۵ دقیقه، قابل تنظیم per محیط)
- هرس زودهنگام کاندیدهای قطعاً ناممکن
- تخصیص منبع: تطبیق نیازمندی‌های هر بخش به منابع آزاد
- استراتژی انتخاب منبع: `least_gap` (پیش‌فرض) · `balanced` · `preserve_specialists` · `same_as_previous`
- کش روزانهٔ پنجرهٔ آزاد هر منبع
- endpoint عمومی و پنلی
- حالت `booking_mode = resource` روی `WeeklySchedule` و مسیر ارتقای داوطلبانه
**نیست:** ثبت اشغال و رزرو موقت (تسک ۰۷)، قوانین فاصلهٔ زمانی (تسک ۰۹ — قلاب اینجا گذاشته می‌شود).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| POST | `/api/v1/appointment-availability` | جستجوی وقت با برنامه (بدنه: سرویس، آیتم‌ها، شعبه، بازهٔ تاریخ) |
| GET | `/api/v1/appointment-availability/month` | روزهای دارای ظرفیت در یک ماه (سبک — فقط بولین per روز) |
## معیار پذیرش
- ✅ موفق: سناریوی مستند — سرویس لیزر با چهار بخش، شعبه با ۳ اتاق / ۲ اپراتور / ۳ دستگاه.
جستجوی یک روز → لیست زمان‌های شروع، و برای هر زمان `assignment` شامل اتاق، اپراتور و
دستگاه انتخابی.
- ✅ موفق (**آزادسازی ظرفیت — قلب کل پروژه**): بیمار الف نوبت ۱۰:۰۰-۱۱:۰۰ دارد
(اپراتور فقط ۱۰:۰۰-۱۰:۰۵ و ۱۰:۳۵-۱۱:۰۰ درگیر است). جستجو برای بیمار ب باید زمانی
در بازهٔ ۱۰:۰۵-۱۰:۳۵ پیدا کند اگر اتاق دومی آزاد باشد.
**تست بدون این سناریو، تسک را تأیید نمی‌کند.**
- ✅ موفق: کارایی — جستجوی ۳۰ روزه با ۲۰ منبع و ۵۰۰ نوبت ثبت‌شده، **زیر ۵۰۰ms**.
تست کارایی بخشی از تسک است، نه اختیاری.
- ✅ موفق: پزشکی که در حالت `slot` یا `service` است → این endpoint `422` با
`ERR_WRONG_BOOKING_MODE` می‌دهد و مسیر قدیمی دست‌نخورده کار می‌کند.
- ❌ خطا: بازهٔ بزرگ‌تر از ۹۰ روز → `422`.
- ❌ خطا: شعبهٔ محیط دیگر → `404`.
- ❌ خطا: نیازمندی بدون منبع واجد شرایط → `422` با پیام انسانی (از تسک ۰۵).
- ⚠️ مرزی: منبع با `capacity=3` و دو نوبت هم‌زمان → سومی هنوز جا دارد، چهارمی نه.
- ⚠️ مرزی: `setup/cleanup` منبع → بازهٔ اشغال گسترده‌تر از بازهٔ بخش است و باید در
بررسی تداخل لحاظ شود.
- ⚠️ مرزی: بخش با `occupancy=passive` → منبع را می‌گیرد ولی در گزارش بهره‌وری «کار» نیست.
- ⚠️ مرزی: نقطهٔ شروع در گذشته → حذف.
- ⚠️ مرزی: هیچ روزی ظرفیت ندارد → آرایهٔ خالی + `reason` قابل فهم، نه ۴۰۴.
- ⚠️ مرزی: منبع مشترک بین دو بخش غیرمجاور یک نوبت → **همان** منبع باید انتخاب شود
(اپراتور بخش ۱ و بخش ۳ یکی است، نه دو نفر).
## خروجی
- `src/Appointment/Availability/`
- `docs/api/appointment-availability.md`
- تست کارایی با داده مصنوعی: `tests/Appointment/AvailabilityPerformanceTest.php`
- توسعهٔ `AppointmentSettingsPage.tsx` برای انتخاب حالت `resource` و استراتژی
@@ -0,0 +1,134 @@
# جریان کاربری — تسک ۰۶
## الف) بیمار وقت انتخاب می‌کند (سایت عمومی)
```
[از تسک ۰۵] برنامهٔ نوبت ساخته شد: ۶۸ دقیقه، ۵ بخش
GET /api/v1/appointment-availability/month?…&month=1405-05
→ { "1405-05-03": true, "1405-05-04": false, … }
تقویم شمسی: روزهای بدون ظرفیت خاکستری
بیمار روز ۳ مرداد را می‌زند
POST /api/v1/appointment-availability
{
"doctor_uuid": "…", "branch_uuid": "…",
"service_item_uuid": "…", "option_uuids": ["…","…"],
"from": "1405-05-03", "to": "1405-05-03", "limit": 50
}
{
"data": {
"total_minutes": 68,
"patient_facing_minutes": 63,
"slots": [
{ "start": 1754…, "start_time": "09:00", "end_time": "10:08",
"assignment": { "room": "اتاق ۲", "operator": "مریم …", "device": "کندلا ۱" } },
{ "start": 1754…, "start_time": "10:15", … }
]
}
}
```
**بیمار `assignment` را نمی‌بیند.** فقط ساعت. تخصیص برای پنل و برای مرحلهٔ رزرو موقت است.
(استثنا: اگر کلینیک «انتخاب پزشک/اپراتور توسط بیمار» را فعال کرده باشد — خارج از دامنهٔ
این تسک.)
```
بیمار ۰۹:۰۰ را می‌زند → تسک ۰۷ (رزرو موقت)
```
---
## ب) هیچ وقتی نیست — سه پیام متفاوت
```
POST /appointment-availability → data.slots = []
data.reason = ?
```
| `reason` | پیام فارسی | دکمهٔ پیشنهادی |
|---|---|---|
| `no_resource` | «برای این خدمت، منبع لازم در این شعبه تعریف نشده است» | (پنل) «افزودن منبع» |
| `no_calendar` | «برای منابع این خدمت ساعت کاری تعریف نشده است» | (پنل) «تنظیم تقویم» |
| `fully_booked` | «در بازهٔ انتخابی وقت خالی نیست» | «جستجو در ۳۰ روز آینده» |
| `outside_window` | «رزرو آنلاین فقط تا ۳ ماه آینده ممکن است» | — |
پیام واحد «وقتی موجود نیست» بدترین حالت است: بیمار فکر می‌کند کلینیک پر است در حالی که
کلینیک اصلاً تقویم تعریف نکرده.
---
## ج) منشی از پنل — با انتخاب دستی منبع
```
پنل نوبت جدید
├─ بیمار (جستجو یا ثبت جدید)
├─ شعبه · سرویس · آیتم‌ها
│ └─ اعتبارسنجی زنده (تسک ۰۴)
POST /appointment-availability با forManagement=true
│ (بازهٔ رزرو آنلاین و خاموش‌بودن نوبت‌دهی اعمال نمی‌شود — رفتار امروزی)
جدول وقت‌ها با ستون «منابع پیشنهادی»
ساعت مدت اتاق اپراتور دستگاه
─────────────────────────────────────────────
۰۹:۰۰ ۶۸' اتاق ۲ ▾ مریم ▾ کندلا ۱ ▾
۱۰:۱۵ ۶۸' اتاق ۱ ▾ سارا ▾ کندلا ۲ ▾
هر ▾ یک SearchableSelect است با فقط منابع آزادِ همان بازه.
عوض کردن یکی → درخواست دوباره برای اعتبارسنجی همان زمان (نه کل لیست).
«ثبت نوبت» → تسک ۰۷
```
---
## د) کلینیک به حالت چندمنبعی ارتقا می‌دهد
```
پنل › تنظیمات نوبت‌دهی
وضعیت فعلی: «نوبت‌دهی سرویسی» (قفل‌شده)
├─ بنر: «ارتقا به نوبت‌دهی چندمنبعی»
│ ✓ ۵ منبع فعال دارید
│ ✓ ۳ سرویس با مدت معتبر
│ ✗ ۲ نوبت فعال در آینده دارید — ابتدا تعیین تکلیف کنید
│ [مشاهدهٔ نوبت‌ها]
▼ (بعد از رفع همهٔ شرط‌ها)
├─ ☑ می‌دانم این تغییر برگشت‌ناپذیر است
└─ [ارتقا]
POST /api/v1/appointment-settings/upgrade-booking-mode
حالا تنظیمات جدید فعال می‌شوند:
گام زمانی: ۱۵ دقیقه ▾
استراتژی انتخاب منبع: کمترین شکاف ▾
```
چک‌لیست پیش از ارتقا اجباری است. بدون آن، کلینیک ارتقا می‌دهد، نوبت‌های قدیمی‌اش
نمایش نادرست می‌گیرند و هیچ راه بازگشتی نیست.
---
## ه) چه چیزی در این جریان **تغییر نمی‌کند**
```
پزشک در حالت slot → GET /api/v1/appointment-slots بدون تغییر
پزشک در حالت service → GET /api/v1/appointment-service-slots بدون تغییر
تقویم ماهانهٔ قدیمی → GET /api/v1/appointment-settings/month-availability/{uuid} بدون تغییر
```
سایت عمومی و اپ دسکتاپ تا وقتی کلینیک ارتقا نداده، هیچ کد جدیدی لازم ندارند.
پس از ارتقا، `GET /appointment-booking-services` مقدار `booking_mode: "resource"` می‌دهد و
کلاینت باید مسیر جدید را صدا بزند — **این تنها نقطه‌ای است که کلاینت‌ها باید به‌روز شوند**
و باید در `docs/api/appointment.md` برجسته نوشته شود.
@@ -0,0 +1,182 @@
# معماری — تسک ۰۷
## ساختار فایل
```
src/Appointment/Booking/
├── Entity/
│ ├── ResourceOccupancy.php
│ └── AppointmentSegment.php
├── Service/
│ ├── HoldService.php # رزرو موقت
│ ├── BookingService.php # ثبت نهایی
│ ├── RescheduleService.php
│ └── OccupancyWriter.php # تنها نویسندهٔ resource_occupancy
├── Repository/ResourceOccupancyRepository.php
├── Controller/BookingController.php
└── Exception/{SlotTakenException, HoldExpiredException}.php
```
## تضمین یکتایی در MariaDB
MariaDB نه `EXCLUDE USING gist` دارد نه `tsrange`. سه گزینه بررسی شد:
| گزینه | مشکل |
|---|---|
| `SELECT … FOR UPDATE` سپس `INSERT` | درست است ولی قفل بازه‌ای نیست؛ با `gap lock` در InnoDB کار می‌کند ولی به سطح ایزولاسیون و ایندکس وابسته است و شکننده |
| قفل توزیع‌شده (Redis / `GET_LOCK`) | تضمین را از دیتابیس به کد برمی‌گرداند — همان چیزی که مستند رد می‌کند |
| **کلید یکتای سطل زمانی** ✅ | یکتایی واقعی در سطح schema، بدون قفل صریح |
### راه‌حل: سطل زمانی
هر ردیف اشغال، به ازای هر «سطل» زمانی که اشغال می‌کند، یک ردیف در جدول کمکی می‌نویسد:
```
resource_occupancy ← بازهٔ واقعی [start_at, end_at)
resource_occupancy_slot ← یک ردیف per (resource_id, bucket, unit_index)
UNIQUE(resource_id, bucket, unit_index)
```
`bucket` = `floor(timestamp / BUCKET_SECONDS)`، با `BUCKET_SECONDS = 300` (۵ دقیقه).
`unit_index` از ۰ تا `capacity-1` — ظرفیت هم‌زمان را بدون قفل مدل می‌کند.
```php
// OccupancyWriter::write() — داخل یک تراکنش
foreach ($this->buckets($start, $end) as $bucket) {
for ($u = 0; $u < $unitsNeeded; $u++) {
// اولین unit_index آزاد را با INSERT پیدا کن، نه با SELECT
$inserted = false;
for ($idx = 0; $idx < $capacity; $idx++) {
try {
$this->conn->insert('resource_occupancy_slot', [
'resource_id' => $resourceId, 'bucket' => $bucket,
'unit_index' => $idx, 'occupancy_id' => $occupancyId,
]);
$inserted = true; break;
} catch (UniqueConstraintViolationException) { continue; }
}
if (!$inserted) throw new SlotTakenException();
}
}
```
**چرا این درست است:** دو تراکنش هم‌زمان که هر دو `unit_index = 0` را می‌خواهند، یکی
`UniqueConstraintViolationException` می‌گیرد. تضمین از دیتابیس می‌آید، نه از کد. هیچ
پنجرهٔ زمانی بین بررسی و نوشتن وجود ندارد چون بررسی‌ای انجام نمی‌شود — فقط `INSERT`.
**هزینه:** نوبت ۶۸ دقیقه‌ای با ۳ منبع ≈ ۳ منبع × ۱۴ سطل = ۴۲ ردیف. با ۱۰۰ نوبت در روز
۴٬۲۰۰ ردیف روزانه. جدول قابل پارتیشن‌بندی روی `bucket` و ردیف‌های گذشته آرشیو می‌شوند
(دستور `app:occupancy:prune --older-than=90d`).
**گرانولاریتی ۵ دقیقه:** یعنی نوبت‌ها به مضرب ۵ دقیقه گرد می‌شوند. با `slot_granularity`
پیش‌فرض ۱۵ دقیقه (تسک ۰۶) هیچ محدودیت عملی نیست. اگر کلینیکی گام ۱ دقیقه بخواهد، این
راه‌حل جواب نمی‌دهد و باید به `SELECT FOR UPDATE` رفت — در `docs/api/appointment-booking.md`
صریح نوشته شود.
## `ResourceOccupancy`
```php
class ResourceOccupancy
{
use TenantOwnedTrait;
public const STATUS_HOLD = 'hold';
public const STATUS_BOOKED = 'booked';
public const STATUS_RELEASED = 'released';
private ClinicResource $resource;
private ?Appointment $appointment = null; // null فقط برای مسدودسازی دستی
private ?AppointmentSegment $segment = null;
private int $startAt; // شامل setup منبع
private int $endAt; // شامل cleanup منبع
private int $units = 1;
private string $occupancyKind; // exclusive | shared | passive
private string $status;
private ?int $expiresAt = null; // فقط برای hold
}
```
**یک ردیف per (بخش × منبع)** — نه per نوبت. این همان چیزی است که آزادسازی ظرفیت را
ممکن می‌کند: اپراتور در بخش انتظار هیچ ردیفی ندارد.
## `HoldService`
```php
public function hold(HoldRequest $req): Hold
{
return $this->em->wrapInTransaction(function () use ($req) {
// ۱. برنامه را دوباره بساز — به assignment کلاینت اعتماد نکن
$plan = $this->planBuilder->build($req->toPlanRequest());
// ۲. assignment ارسالی را اعتبارسنجی کن: هر منبع واقعاً کاندید آن نیازمندی است؟
$assignment = $this->validateAssignment($plan, $req->assignment);
// ۳. نوبت pending با expires_at
$appointment = $this->createPendingAppointment($req, $plan);
// ۴. بخش‌ها را ذخیره کن
$segments = $this->persistSegments($appointment, $plan, $req->start);
// ۵. اشغال‌ها — اینجا SlotTakenException ممکن است پرت شود
$this->occupancyWriter->writeForHold($appointment, $segments, $assignment);
return new Hold($appointment->getUuid(), $appointment->getExpiresAt());
});
}
```
مرحلهٔ ۱ و ۲ حیاتی‌اند: `assignment` از کلاینت می‌آید و اگر بی‌بررسی نوشته شود، کلاینت
می‌تواند منبعِ محیط دیگر یا منبع نامناسب را به نوبت بچسباند — همان کلاس نشتی که در
`POST /api/v1/my/appointment` پیدا شد (`docs/architecture/tenancy.md`).
## `BookingService`
```php
public function confirm(string $holdUuid, User $user): Appointment
{
return $this->em->wrapInTransaction(function () use ($holdUuid, $user) {
$appointment = $this->loadOwnHold($holdUuid, $user); // ۱ (404 اگر مال دیگری)
$this->assertHoldAlive($appointment); // ۲ (409 اگر منقضی)
$this->policies->assertEligibility($appointment); // ۳ قلاب تسک ۰۹
$this->transition($appointment, Appointment::STATUS_CONFIRMED);// ۴
$this->occupancyWriter->promoteToBooked($appointment); // ۵
$this->pricing->snapshot($appointment); // ۶ قلاب تسک ۰۸
$this->events->dispatch(new AppointmentBooked($appointment)); // ۷ تسک ۱۴
return $appointment;
});
}
```
هفت مرحله، یک تراکنش. مستند بند ۱۱: «اگر یکی شکست بخورد، همه لغو می‌شوند».
`dispatch` باید **بعد از** commit اجرا شود — با `messenger` و
`DispatchAfterCurrentBusStamp` یا با یک `postFlush` صف کوچک.
## سازگاری با `active_slot_key`
`active_slot_key` موجود **حذف نمی‌شود**. برای نوبت‌های حالت `slot`/`service` همان
تضمین‌کننده می‌ماند. برای حالت `resource`:
- `active_slot_key` همچنان پر می‌شود (پزشک یک منبع است و یکتایی‌اش مفید)
- ردیف‌های `resource_occupancy` هم نوشته می‌شوند
دو تور ایمنی موازی. هزینه‌اش ناچیز، سودش این است که مهاجرت هیچ لحظه‌ای بدون حفاظ نیست.
⚠️ یک استثنا: در حالت `resource`، ممکن است دو نوبت **مجاز** با همان `doctor + slot_start`
وجود داشته باشد؟ نه — پزشک هم‌زمان دو بیمار ندارد و `capacity` منبعِ `type=doctor` طبق
تسک ۰۲ اجباراً ۱ است. پس تضاد ندارند.
## انقضای hold
`ExpireAppointmentsHandler` موجود توسعه می‌یابد:
```php
// قبل: فقط status را expired می‌کرد
// بعد: + آزادسازی ردیف‌های اشغال و حذف ردیف‌های سطل
$this->occupancyWriter->releaseExpiredHolds($now);
```
`resource_occupancy_slot` ردیف‌های hold منقضی باید **حذف فیزیکی** شوند، وگرنه سطل اشغال
می‌ماند. `resource_occupancy` خودش `status='released'` می‌گیرد و می‌ماند (برای آدیت).
زمان‌بندی: `symfony/scheduler` موجود، هر دقیقه. علاوه بر آن، `hasRoom` تسک ۰۶ شرط
`expires_at > now` را دارد پس hold منقضی حتی پیش از cron هم مانع نمی‌شود.
@@ -0,0 +1,145 @@
# دیتابیس — تسک ۰۷
## `resource_occupancy` — مهم‌ترین جدول سیستم
| ستون | نوع | توضیح |
|---|---|---|
| `id` | BIGINT PK AI | BIGINT چون پرحجم‌ترین جدول می‌شود |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `resource_id` | INT NOT NULL | FK → `clinic_resources.id` ON DELETE RESTRICT |
| `appointment_id` | INT NULL | FK → `appointments.id` ON DELETE CASCADE؛ NULL = مسدودسازی دستی |
| `appointment_segment_id` | INT NULL | FK ON DELETE CASCADE |
| `start_at` | INT NOT NULL | **شامل `setup_minutes` منبع** |
| `end_at` | INT NOT NULL | **شامل `cleanup_minutes` منبع** |
| `units` | SMALLINT NOT NULL DEFAULT 1 | |
| `occupancy_kind` | VARCHAR(10) NOT NULL | `exclusive`\|`shared`\|`passive` |
| `status` | VARCHAR(10) NOT NULL | `hold`\|`booked`\|`released` |
| `expires_at` | INT NULL | فقط برای `hold` |
| `created_at` | INT NOT NULL | |
```sql
KEY idx_occ_resource_range (resource_id, start_at, end_at, status) -- کوئری داغ تسک ۰۶
KEY idx_occ_tenant_range (entity_type, entity_id, start_at) -- لیست‌های پنل
KEY idx_occ_appointment (appointment_id)
KEY idx_occ_expiry (status, expires_at) -- cron انقضا
```
## `resource_occupancy_slot` — تضمین یکتایی
```sql
CREATE TABLE resource_occupancy_slot (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
resource_id INT NOT NULL,
bucket INT NOT NULL, -- floor(timestamp / 300)
unit_index SMALLINT NOT NULL, -- 0 .. capacity-1
occupancy_id BIGINT NOT NULL,
UNIQUE KEY uniq_occ_slot (resource_id, bucket, unit_index), -- ← کل تضمین اینجاست
KEY idx_occ_slot_occupancy (occupancy_id),
CONSTRAINT fk_occ_slot_occupancy FOREIGN KEY (occupancy_id)
REFERENCES resource_occupancy(id) ON DELETE CASCADE
) ENGINE=InnoDB;
```
`BUCKET_SECONDS = 300` در `OccupancyWriter::BUCKET_SECONDS` ثابت است. تغییرش بعد از
تولید داده، migration کامل می‌خواهد — در کد کامنت هشدار بگذار.
سطل‌های یک بازه:
```php
// [start, end) نیم‌باز → سطل آخر شامل نمی‌شود اگر دقیقاً روی مرز باشد
$first = intdiv($start, self::BUCKET_SECONDS);
$last = intdiv($end - 1, self::BUCKET_SECONDS);
```
بدون `-1` نوبت ۱۰:۰۰-۱۰:۳۰ و نوبت ۱۰:۳۰-۱۱:۰۰ سطل مشترک می‌گیرند و دومی بی‌دلیل رد می‌شود.
## `appointment_segments`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `appointment_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `sequence` | SMALLINT NOT NULL | |
| `name` | VARCHAR(150) NOT NULL | snapshot نام بخش در لحظهٔ ثبت |
| `segment_type` | VARCHAR(40) NOT NULL | |
| `start_at` | INT NOT NULL | مطلق |
| `end_at` | INT NOT NULL | |
| `patient_present` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at` | INT NOT NULL | |
```sql
KEY idx_appt_seg_appointment (appointment_id, sequence)
KEY idx_appt_seg_tenant (entity_type, entity_id, start_at)
```
`name` و `segment_type` عمداً کپی می‌شوند نه FK: قانون پنجم مستند — «هر چیزی که ثبت شد
باید همان‌طور بماند». اگر کلینیک فردا الگو را عوض کند، نوبت دیروز نباید تغییر معنا دهد.
## تغییر `appointments`
```sql
ALTER TABLE appointments
ADD COLUMN branch_id INT NULL,
ADD COLUMN plan_total_minutes SMALLINT NULL,
ADD COLUMN patient_facing_minutes SMALLINT NULL,
ADD CONSTRAINT fk_appointments_branch FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE SET NULL,
ADD KEY idx_appointments_branch (branch_id, slot_start);
```
`slot_start` / `slot_end` **می‌مانند** و در حالت `resource` برابر شروع اولین بخش و پایان
آخرین بخش‌اند. دلیل: `AppointmentRepository`، لیست‌های پنل، سایت عمومی و اپ دسکتاپ همه
روی این دو ستون کوئری می‌زنند. برداشتنشان یعنی بازنویسی همه‌جا.
وضعیت جدید:
```php
public const STATUS_RESCHEDULED = 'rescheduled';
// ALLOWED_TRANSITIONS: confirmed → rescheduled ; pending → rescheduled
```
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
⚠️ **دو ایندکس را دستی در migration بنویس**`doctrine:migrations:diff` ترتیب ستون‌های
ایندکس ترکیبی را گاهی متفاوت تولید می‌کند و ترتیب اینجا حیاتی است
(`resource_id` اول در `idx_occ_resource_range`).
## backfill نوبت‌های موجود
```bash
ddev exec php bin/console app:occupancy:backfill --force
```
برای هر نوبت `pending`/`confirmed` آینده در حالت `slot`/`service`:
- یک `appointment_segment` واحد بساز (کل بازه)
- یک `resource_occupancy` روی منبع `type=doctor` همان پزشک
- ردیف‌های `resource_occupancy_slot` متناظر
اگر منبع `type=doctor` وجود ندارد (backfill تسک ۰۲ اجرا نشده)، آن نوبت رد شود و در
خروجی گزارش شود — نه خطا.
بدون این backfill، اولین رزرو در حالت جدید ممکن است روی نوبت قدیمی بنشیند.
## نگهداشت
```bash
ddev exec php bin/console app:occupancy:prune --older-than=90d --force
```
ردیف‌های `resource_occupancy_slot` مربوط به بازه‌های گذشته را حذف می‌کند.
`resource_occupancy` می‌ماند (آدیت و گزارش بهره‌وری تسک ۱۴).
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `resource_occupancy` | جفت tenant |
| `appointment_segments` | جفت tenant (uuid ممکن است از request بیاید) |
| `resource_occupancy_slot` | `AGGREGATE_CHILDREN` → ریشه `ResourceOccupancy`**هرگز مستقیم کوئری نشود** |
@@ -0,0 +1,183 @@
# نکات پیاده‌سازی — تسک ۰۷
## ۱. تست همزمانی واقعی، نه mock
این تسک بدون یک تست همزمانی واقعی تمام نیست. mock کردن `UniqueConstraintViolationException`
هیچ چیزی را اثبات نمی‌کند — چیزی که باید ثابت شود این است که **دیتابیس** جلویش را می‌گیرد.
```php
// tests/Appointment/ConcurrentHoldTest.php
$conn1 = $this->newConnection(); // دو اتصال مجزا، نه دو EntityManager روی یک اتصال
$conn2 = $this->newConnection();
$conn1->beginTransaction();
$conn2->beginTransaction();
$r1 = $this->tryHold($conn1, $resourceId, $bucket, 0);
$r2 = $this->tryHold($conn2, $resourceId, $bucket, 0); // باید بلاک یا شکست بخورد
$conn1->commit();
// دقیقاً یکی موفق
self::assertSame(1, (int) $r1['ok'] + (int) $r2['ok']);
```
اگر اجرای موازی واقعی در محیط CI سخت است، حداقل دو اتصال DBAL مجزا با تراکنش‌های
باز هم‌زمان استفاده کن. `assertSame(1, …)` تنها معیار قبولی است.
## ۲. به `assignment` کلاینت اعتماد نکن
```php
// ❌ فاجعه
foreach ($request['assignment'] as $role => $resourceUuid) {
$occupancy->setResource($this->resourceRepo->findByUuid($resourceUuid));
}
// ✅
$plan = $this->planBuilder->build(); // برنامه را خودت بساز
foreach ($plan->requirements() as $req) {
$chosen = $request['assignment'][$req->key()] ?? null;
if ($chosen === null || !in_array($chosen, $req->candidateUuids, true)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'منبع انتخابی برای این خدمت معتبر نیست', 422);
}
}
```
سه نشتی ثبت‌شده در `docs/architecture/tenancy.md` همگی از همین شکل بودند: uuid از بدنه
آمد و کسی محیطش را نسنجید. اینجا حتی سنجیدن محیط کافی نیست — منبع باید **کاندید همان
نیازمندی** باشد.
## ۳. ترتیب نوشتن سطل‌ها — جلوگیری از deadlock
دو تراکنش که سطل‌ها را به ترتیب متفاوت `INSERT` کنند، deadlock می‌سازند.
قاعده: **همیشه مرتب بر اساس `(resource_id, bucket, unit_index)` صعودی**.
```php
$rows = $this->buildSlotRows($assignment, $segments);
usort($rows, fn($a, $b) => [$a['resource_id'], $a['bucket'], $a['unit_index']]
<=> [$b['resource_id'], $b['bucket'], $b['unit_index']]);
foreach ($rows as $row) { $this->insertOrFail($row); }
```
بدون این، تست همزمانی گاهی `Deadlock found when trying to get lock` می‌دهد و کسی
فکر می‌کند تست flaky است.
## ۴. `unit_index` — جستجوی خطی، نه `SELECT`
```php
for ($idx = 0; $idx < $capacity; $idx++) {
try { $this->conn->insert(, ['unit_index' => $idx]); return $idx; }
catch (UniqueConstraintViolationException) { continue; }
}
throw new SlotTakenException();
```
وسوسه می‌شود اول `SELECT` بزنی که کدام index آزاد است. نکن — بین `SELECT` و `INSERT`
پنجرهٔ رقابت باز می‌شود و کل مزیت این طراحی از بین می‌رود. با `capacity` معمول (۱ تا ۵)
حلقه ارزان است.
## ۵. `setup/cleanup` در بازهٔ اشغال، نه بخش
```php
$occStart = $segment->getStartAt() - $resource->getSetupMinutes() * 60;
$occEnd = $segment->getEndAt() + $resource->getCleanupMinutes() * 60;
```
و `appointment_segments.start_at/end_at` بدون آن‌ها. بیمار ساعت ۱۰:۰۰ می‌آید؛ یونیت از
۹:۵۵ اشغال است. دو عدد متفاوت، دو ستون متفاوت.
## ۶. رویدادها بعد از commit
```php
// ❌ اگر تراکنش rollback شود، پیامک رفته و نوبتی وجود ندارد
$this->bus->dispatch(new AppointmentBooked($appointment));
$this->em->flush();
// ✅
$this->bus->dispatch(
(new Envelope(new AppointmentBooked($appointment->getUuid())))
->with(new DispatchAfterCurrentBusStamp())
);
```
و در payload رویداد **uuid** بفرست، نه entity — تسک ۱۴ همین قرارداد را دارد.
## ۷. لغو = آزادسازی، نه حذف
```php
// همهٔ ردیف‌های اشغال نوبت
$occupancy->setStatus(ResourceOccupancy::STATUS_RELEASED);
// ولی ردیف‌های سطل حذف فیزیکی می‌شوند تا جا آزاد شود
$this->conn->delete('resource_occupancy_slot', ['occupancy_id' => $occupancy->getId()]);
```
`resource_occupancy` برای آدیت و گزارش بهره‌وری می‌ماند. `resource_occupancy_slot` فقط
مکانیزم قفل است و ردیف مرده در آن یعنی ظرفیت مسدود.
## ۸. `reschedule` اتمی
```php
$this->em->wrapInTransaction(function () use ($appointment, $newStart) {
$newHold = $this->holdService->hold(); // ۱ اگر شکست بخورد، همه‌چیز rollback
$this->occupancyWriter->release($appointment); // ۲
$this->transition($appointment, STATUS_RESCHEDULED);// ۳
$this->linkReschedule($appointment, $newHold); // ۴
});
```
ترتیب مهم است: **اول hold جدید، بعد آزادسازی قدیم**. برعکسش یعنی اگر hold جدید شکست
بخورد، بیمار هم نوبت قدیم را از دست داده هم جدید نگرفته.
## ۹. edge case ها
| حالت | رفتار درست |
|---|---|
| hold روی نوبتی که همان لحظه cron منقضی‌اش کرد | `409 ERR_HOLD_EXPIRED` — نه ۵۰۰ |
| `confirm` دوباره روی همان hold | idempotent: نوبت قبلاً `confirmed` → همان را برگردان، نه خطا |
| منبع بین hold و confirm غیرفعال شد | `confirm` موفق — اشغال گرفته شده و کلینیک باید دستی حل کند. لاگ هشدار |
| نوبت `is_reserve=true` (لیست رزرو موجود) | هیچ ردیف اشغالی نمی‌سازد — روزی است، نه ساعتی |
| ظرفیت ۳، سه hold، یکی منقضی | سطل آزاد می‌شود و چهارمی می‌تواند بگیرد |
| بازهٔ اشغال دقیقاً روی مرز سطل | `intdiv($end - 1, 300)` — تست مرزی اجباری |
| نوبت گذشته | hold روی زمان گذشته → `422` |
| `capacity` منبع بعد از ثبت کم شد | اشغال‌های موجود می‌مانند (over-subscription موقت)، جدید رد می‌شود. در پنل هشدار |
| دو بخش مجاور یک نوبت روی یک منبع | دو ردیف اشغال، سطل‌های متمایز (به لطف `-1`) |
## ۱۰. تست
```
tests/Appointment/Booking/OccupancyWriterTest.php
- سطل‌های [10:00, 10:30) و [10:30, 11:00) تداخل ندارند
- capacity=3 → سه unit_index، چهارمی SlotTakenException
- ترتیب مرتب INSERT
tests/Appointment/ConcurrentHoldTest.php ← ⭐ اجباری
- دو تراکنش موازی → دقیقاً یکی موفق
tests/Appointment/HoldLifecycleTest.php
- hold → زمان از availability حذف می‌شود
- انقضا → دوباره ظاهر می‌شود
- DELETE hold → فوری آزاد
tests/Appointment/BookingConfirmTest.php
- confirm موفق → همهٔ اشغال‌ها booked و expires_at null
- confirm hold دیگری → 404
- confirm منقضی → 409
- confirm دوباره → idempotent
tests/Appointment/CapacityReleaseIntegrationTest.php ← ⭐
- بعد از ثبت نوبت لیزر، اپراتور در بازهٔ انتظار هیچ ردیف اشغالی ندارد
- و جستجوی بیمار دوم آن بازه را پیدا می‌کند
tests/Appointment/RescheduleTest.php
- شکست hold جدید → نوبت قدیم دست‌نخورده
tests/Appointment/OccupancyBackfillTest.php
- نوبت‌های موجود ردیف اشغال می‌گیرند؛ idempotent
tests/Appointment/LegacyBookingUnchangedTest.php
- POST /api/v1/appointment قدیمی دقیقاً مثل قبل کار کند
tests/Appointment/BookingTenantTest.php ← موجود، باید سبز بماند
```
## ۱۱. مستندات
`docs/api/appointment-booking.md` بساز. حتماً بنویس:
- گرانولاریتی ۵ دقیقه‌ای و محدودیتش
- قرارداد `hold_uuid` و TTL
- کدهای خطا: `ERR_SLOT_TAKEN` (409)، `ERR_HOLD_EXPIRED` (409)
- در `ErrorCodes.php` هر دو کد با پیام فارسی ثبت شوند
`docs/architecture/` یک سند جدید `booking-concurrency.md` بگیرد که راه‌حل سطل زمانی و
دلیل رد گزینه‌های دیگر را ثبت کند — این تصمیمی است که شش ماه بعد کسی زیر سؤال می‌برد.
@@ -0,0 +1,81 @@
# تسک ۰۷ — رزرو موقت و ثبت نهایی چندمنبعی
**فاز:** ۱ (هسته) · **وابستگی:** ۰۶ · **زمان:** ۱۶-۲۰ ساعت
---
## هدف
مستند بند ۱۱ و قانون سوم جمع‌بندی: «جلوگیری از رزرو تکراری کار دیتابیس است، نه کار کد».
سه مرحلهٔ `جستجو → رزرو موقت → ثبت نهایی` روی **چند منبع** پیاده شود، با تضمین یکتایی
در سطح دیتابیس.
## وضعیت فعلی — نقطهٔ قوت پروژه
```php
// src/Appointment/Entity/Appointment.php
public const PAYMENT_TTL = 900;
#[ORM\Column(name: 'active_slot_key', length: 64, nullable: true, unique: true)]
private ?string $activeSlotKey = null; // "{doctorId}:{slotStart}" یا NULL
private function refreshActiveSlotKey(): void {
$this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
}
```
سه مرحله و تضمین دیتابیسی **از قبل درست پیاده شده‌اند**. محدودیت: کلید فقط
`doctor + slot_start` است و هیچ منبع دیگری را نمی‌پوشاند، و مدل «یک ردیف = یک بازهٔ پیوسته»
است.
## دامنه
**هست:**
- `resource_occupancy` — تنها مرجع حقیقت اشغال منابع
- `appointment_segments` — بخش‌های نوبت ثبت‌شده
- `HoldService` — رزرو موقت چندمنبعی با TTL
- `BookingService` — ثبت نهایی اتمی
- تضمین یکتایی در MariaDB (بدون `EXCLUDE` — راه‌حل «سطل زمانی»)
- انقضای خودکار hold ها (توسعهٔ `ExpireAppointmentsHandler` موجود)
- وضعیت `rescheduled` و رویداد جابه‌جایی
**نیست:** قیمت‌گذاری تفکیک‌شده (تسک ۰۸)، سیاست لغو و جریمه (تسک ۱۳).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| POST | `/api/v1/appointment-hold` | رزرو موقت یک زمان + منابعش |
| DELETE | `/api/v1/appointment-hold/{uuid}` | آزادسازی زودهنگام |
| POST | `/api/v1/appointment-confirm` | ثبت نهایی از یک hold معتبر |
| POST | `/api/v1/appointment/{uuid}/reschedule` | جابه‌جایی (hold جدید + آزادسازی قدیم، اتمی) |
## معیار پذیرش
- ✅ موفق: `POST /appointment-hold` با زمان و `assignment` معتبر → `201` با
`hold_uuid` و `expires_at`؛ ردیف‌های `resource_occupancy` با `status='hold'` برای
**هر بخش × هر منبع** ثبت می‌شوند.
- ✅ موفق: بلافاصله بعد از hold، `POST /appointment-availability` همان زمان را **برنمی‌گرداند**.
- ✅ موفق: `POST /appointment-confirm``200`، وضعیت `confirmed`، همهٔ ردیف‌های اشغال
`status='booked'` و `expires_at = NULL`.
- ✅ موفق (**تست همزمانی — اصلی‌ترین**): دو درخواست hold هم‌زمان روی همان منبع و بازه →
دقیقاً **یکی** `201` و دیگری `409` با `ERR_SLOT_TAKEN`. تست باید با تراکنش واقعی
موازی اجرا شود، نه mock.
- ✅ موفق: hold منقضی‌شده → `POST /appointment-confirm` با `409 ERR_HOLD_EXPIRED` و آن
زمان دوباره در جستجو ظاهر می‌شود.
- ✅ موفق: آزادسازی ظرفیت حفظ می‌شود — اپراتور در بخش انتظار ردیف اشغال **ندارد**.
- ❌ خطا: `confirm` با hold متعلق به کاربر دیگر → `404`.
- ❌ خطا: hold با منبعی که در `assignment` نیست ولی نیازمندی دارد → `422`.
- ⚠️ مرزی: منبع با `capacity=3` → سه hold هم‌زمان موفق، چهارمی `409`.
- ⚠️ مرزی: لغو نوبت → همهٔ ردیف‌های اشغالش آزاد (`status='released'`)، نه حذف فیزیکی.
- ⚠️ مرزی: `reschedule` که hold جدیدش شکست بخورد → نوبت قدیمی **دست‌نخورده** بماند.
- ⚠️ مرزی: نوبت‌های حالت `slot`/`service``active_slot_key` قدیمی همچنان کار می‌کند و
ردیف اشغال هم برایشان ساخته می‌شود (تور ایمنی دوگانه).
## خروجی
- `src/Appointment/Booking/`
- توسعهٔ `Appointment` entity با `segments` و رابطهٔ اشغال
- `docs/api/appointment-booking.md` + به‌روزرسانی `docs/api/appointment.md`
- تست همزمانی واقعی
@@ -0,0 +1,150 @@
# جریان کاربری — تسک ۰۷
## الف) مسیر موفق — بیمار از سایت عمومی
```
[تسک ۰۶] بیمار ساعت ۰۹:۰۰ را انتخاب می‌کند
POST /api/v1/appointment-hold
{ "doctor_uuid":"…", "branch_uuid":"…", "service_item_uuid":"…",
"option_uuids":["…"], "start": 1754…, "assignment": { "operator":"…", "room":"…", "device":"…" } }
├─ سرور: برنامه را دوباره می‌سازد (به assignment اعتماد نمی‌کند)
├─ نوبت pending با expires_at = now + 900
├─ appointment_segments × ۵
└─ resource_occupancy × (بخش × منبع) — بخش انتظار فقط اتاق
201 { "hold_uuid":"…", "expires_at": 1754…, "total_price_rials": … }
│ ⏱ تایمر ۱۵ دقیقه‌ای در UI: «۱۴:۵۹ برای تکمیل رزرو»
پرداخت بیعانه (اگر deposit_required) → درگاه → بازگشت
POST /api/v1/appointment-confirm { "hold_uuid":"…" }
├─ ۱ hold معتبر است؟
├─ ۲ قوانین صلاحیت و فاصله (تسک ۰۹)
├─ ۳ وضعیت → confirmed
├─ ۴ اشغال‌ها hold → booked
├─ ۵ snapshot قیمت (تسک ۰۸)
└─ ۶ رویداد AppointmentBooked (بعد از commit)
200 { "appointment_uuid":"…", "status":"confirmed" }
پیامک تأییدیه (از راه رویداد، async)
```
---
## ب) رقابت روی ساعت پرتقاضا
```
بیمار الف بیمار ب
│ ۰۹:۰۰ را می‌بیند │ ۰۹:۰۰ را می‌بیند
│ │
▼ POST /appointment-hold ▼ POST /appointment-hold
│ │
│ INSERT slot (res=7,b=…,u=0) ✓ │ INSERT slot (res=7,b=…,u=0) ✗ duplicate
▼ ▼
201 hold_uuid 409 ERR_SLOT_TAKEN
UI: «این ساعت همین لحظه رزرو شد.»
+ لیست به‌روزشدهٔ وقت‌های نزدیک
(خودکار، بدون کلیک دوباره)
```
پیام «این ساعت همین لحظه رزرو شد» + پیشنهاد جایگزین، همان کاری است که مستند بند ۱۷
برای ریسک «رقابت روی ساعت‌های پرتقاضا» می‌خواهد. `409` خالی بدون جایگزین یعنی بیمار می‌رود.
---
## ج) hold منقضی می‌شود
```
hold ساخته شد ─── ۱۵ دقیقه ───▶ منقضی
┌─────────────────────────┴──────────────────────────┐
│ │
cron هر دقیقه یا: بیمار confirm می‌زند
ExpireAppointmentsHandler │
│ ▼
├─ status → expired 409 ERR_HOLD_EXPIRED
├─ occupancy → released │
└─ occupancy_slot → DELETE ▼
▼ UI: «زمان رزرو شما به پایان رسید»
زمان دوباره در جستجو ظاهر می‌شود + بازگشت به لیست وقت‌ها
```
نکته: حتی پیش از اجرای cron، `hasRoom` تسک ۰۶ شرط `expires_at > now` را دارد، پس
hold مردهٔ چند ثانیه‌ای هم مانع کسی نمی‌شود. cron فقط تمیزکاری است.
---
## د) منشی نوبت را جابه‌جا می‌کند
```
پنل › نوبت‌ها › جزئیات نوبت › «جابه‌جایی»
انتخاب تاریخ/ساعت جدید (همان UI تسک ۰۶، با forManagement=true)
POST /api/v1/appointment/{uuid}/reschedule { "start": … , "assignment": {…} }
├─ ۱ hold جدید ساخته می‌شود ← اگر شکست: rollback کامل، نوبت قدیم سالم
├─ ۲ اشغال‌های قدیم released
├─ ۳ نوبت قدیم → rescheduled
└─ ۴ لینک قدیم ↔ جدید در appointment_events
200 { "new_appointment_uuid": "…" }
پیامک اطلاع‌رسانی جابه‌جایی
```
اگر hold جدید `409` بدهد:
```
422 { "errors": [{ "code":"ERR_SLOT_TAKEN",
"message":"زمان جدید در دسترس نیست. نوبت فعلی تغییری نکرد." }] }
```
جملهٔ دوم پیام اجباری است — منشی باید بداند وضعیت فعلی امن است و لازم نیست چیزی را
درست کند.
---
## ه) لغو نوبت
```
لغو (بیمار یا پزشک یا منشی)
├─ status → cancelled_by_user / cancelled_by_doctor
├─ active_slot_key → NULL (مکانیزم موجود، دست‌نخورده)
├─ resource_occupancy → released
└─ resource_occupancy_slot → DELETE
ظرفیت فوری آزاد می‌شود
[تسک ۱۳] لیست انتظار همان بازه اطلاع می‌گیرد
```
---
## و) مسدودسازی دستی یک منبع
حالت خاصی که `appointment_id = NULL` را توضیح می‌دهد:
```
پنل › منابع › دستگاه کندلا ۲ › «مسدودسازی بازه»
تاریخ/ساعت + دلیل: «سرویس دوره‌ای»
یک ردیف resource_occupancy با appointment_id=NULL و status='booked'
(یا معادلاً یک resource_exception از تسک ۰۳ — هر دو کار می‌کنند)
```
**تصمیم:** مسدودسازی **بلندمدت و تکرارشونده**`resource_exception` (تسک ۰۳).
مسدودسازی **موردی و کوتاه**`resource_occupancy` با `appointment_id=NULL`.
دلیل: دومی در همان ایندکس داغ می‌نشیند و در `OccupancyIndex` بدون کد اضافه دیده می‌شود.
این تفکیک را در `docs/api/appointment-booking.md` بنویس، وگرنه دو راه انجام یک کار
گیج‌کننده می‌شود.
@@ -0,0 +1,145 @@
# معماری — تسک ۰۸
## ساختار فایل
```
src/Pricing/
├── Entity/
│ ├── PriceList.php
│ ├── PriceListItem.php
│ ├── PriceSnapshot.php
│ ├── PriceSnapshotLine.php
│ └── DepositPolicy.php
├── Service/
│ ├── PricingEngine.php # ارکستراتور هفت‌مرحله‌ای
│ ├── PriceResolver.php # قیمت پایه: لیست → تعرفه → سرویس
│ ├── SnapshotWriter.php
│ └── DepositCalculator.php
├── Dto/{PriceQuote, PriceLine}.php
├── Controller/{PriceListController, PricingController}.php
└── Repository/…
```
## `PricingEngine` — هفت مرحلهٔ مستند
```php
public function quote(QuoteRequest $req): PriceQuote
{
$lines = [];
// ۱ قیمت پایهٔ سرویس (از لیست قیمت معتبر در تاریخ رزرو)
$lines[] = PriceLine::base($this->resolver->servicePrice($req->service, $req->branch, $req->at));
// ۲ جمع قیمت آیتم‌های انتخابی
foreach ($req->options as $option) {
$lines[] = PriceLine::option($option, $this->resolver->optionPrice($option, $req->branch, $req->at));
}
// ۳ قوانین قیمت به ترتیب اولویت ← DiscountEngine موجود، بعداً تسک ۰۹
$lines = $this->discounts->apply($lines, $req);
// ۴ کسر از اعتبار پکیج ← قلاب تسک ۱۱ (فعلاً no-op)
$lines = $this->packages->consume($lines, $req);
// ۵ مالیات و سهم بیمه ← AppointmentInsuranceService موجود
$lines = $this->insurance->apply($lines, $req);
$lines = $this->tax->apply($lines, $req);
// ۶ بیعانه
$deposit = $this->depositCalculator->forQuote($lines, $req);
// ۷ خروجی تفکیک‌شده (ذخیره فقط در confirm انجام می‌شود)
return new PriceQuote($lines, $deposit);
}
```
هر مرحله یک سرویس مستقل با اینترفیس خودش. مرحلهٔ ۳ و ۴ از روز اول در زنجیره هستند حتی
وقتی خالی‌اند — همان دلیل تسک ۰۵: امضای عمومی بعداً عوض نشود.
## `PriceResolver` — ترتیب اولویت
```
۱. ServiceBranchOverride.price_rials (تسک ۰۴ — اختصاصی‌ترین)
۲. PriceListItem از PriceList فعالی که تاریخ رزرو را می‌پوشاند و شعبه‌اش مطابق است
۳. PriceListItem از PriceList فعال محیط (بدون شعبه)
۴. Tariff::findForServiceYear(سال شمسی تاریخ رزرو) ← موجود، دست‌نخورده
۵. ServiceItem.price_rials ← آخرین fallback
```
هیچ‌وقت خطا یا صفر برنمی‌گرداند. سطر ۴ و ۵ تضمین می‌کنند همهٔ داده‌های موجود بدون هیچ
لیست قیمتی درست کار کنند.
**تاریخ مبنا:** تاریخ **رزرو** (`slot_start`)، نه تاریخ ثبت. مستند بند ۱۲: «از لیست قیمت
معتبر در تاریخ رزرو». اگر بیمار امروز برای سه ماه بعد نوبت بگیرد، قیمت آن روز اعمال می‌شود.
این تصمیم را در `docs/api/pricing.md` صریح بنویس — دو تفسیر دارد و پشتیبانی از هر دو
غیرممکن است.
## `PriceSnapshot` — فاکتور منجمد
```php
class PriceSnapshot
{
use TenantOwnedTrait;
private Appointment $appointment;
private int $baseRials;
private int $optionsRials;
private int $discountRials;
private int $insuranceBaseRials;
private int $insuranceSupplementaryRials;
private int $taxRials;
private int $finalRials;
private int $depositRials;
private array $appliedPolicyIds = []; // قانون‌های اعمال‌شده — قانون پنجم مستند
private int $createdAt;
private Collection $lines; // PriceSnapshotLine
}
```
`appliedPolicyIds` از روز اول: مستند بند ۸ می‌گوید «هر نوبت فهرست قانون‌هایی که رویش
اعمال شده را ذخیره می‌کند». تسک ۰۹ نسخهٔ قانون‌ها را هم اضافه می‌کند؛ فعلاً شناسهٔ
`DiscountRule` ها ثبت می‌شود.
`PriceSnapshotLine` ردیف‌های تفکیک‌شده: نوع (`base`|`option`|`discount`|`insurance`|`tax`
نام، مبلغ، ارجاع اختیاری به منبع (سرویس/آیتم/قانون).
## رابطه با `Invoice` موجود
`Invoice`/`InvoiceItem` (دامنهٔ `Billing`) **باقی می‌مانند** و کارشان صورتحساب مراجعهٔ
انجام‌شده است. `PriceSnapshot` کار متفاوتی می‌کند: قیمت **لحظهٔ رزرو**.
| | `PriceSnapshot` | `Invoice` |
|---|---|---|
| کِی ساخته می‌شود | `confirm` نوبت | پایان مراجعه |
| چه چیزی را ثبت می‌کند | آن‌چه قرار بود پرداخت شود | آن‌چه واقعاً انجام و صورتحساب شد |
| تغییر می‌کند | هرگز | تا تسویه |
اگر بیمار سر نوبت خدمت اضافه بگیرد، `Invoice` فرق می‌کند و `PriceSnapshot` نه — و همین
تفاوت، منبع گزارش «اختلاف پیش‌بینی و واقعیت» است.
این جدول را در `docs/architecture/insurance-billing-system.md` اضافه کن، وگرنه اولین
کسی که هر دو را می‌بیند یکی را حذف می‌کند.
## سیاست بیعانه
```php
class DepositPolicy
{
use TenantOwnedTrait;
private ?ServiceItem $service = null; // null = پیش‌فرض محیط
private string $mode; // none | fixed | percent
private int $value = 0;
private ?int $minRials = null;
private ?int $maxRials = null;
}
```
`DepositCalculator` اختصاصی‌ترین سیاست را می‌گیرد (سرویس بر محیط) و مقدار را به
`Appointment.deposit_required/deposit_amount_rials` موجود می‌نویسد — ستون‌های جدید لازم نیست.
## پنل ادمین
- `PriceListsPage.tsx` — لیست با بازهٔ شمسی و وضعیت (پیش‌نویس/فعال/منقضی)
- `PriceListFormPage.tsx` — بازهٔ تاریخ با `PersianDatePicker`، شعبه با `SearchableSelect`،
جدول سرویس‌ها با `PriceInput`
- در `AppointmentDetailPage.tsx` یک کارت «فاکتور» با ردیف‌های snapshot
- «کپی از لیست قیمت قبلی» — بدون آن، کلینیک با ۲۰۰ سرویس هرگز لیست جدید نمی‌سازد
@@ -0,0 +1,143 @@
# دیتابیس — تسک ۰۸
## `price_lists`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `branch_id` | INT NULL | NULL = همهٔ شعب محیط |
| `name` | VARCHAR(150) NOT NULL | «نیمهٔ دوم ۱۴۰۵» |
| `valid_from` | INT NOT NULL | نیمه‌شب روز شروع |
| `valid_to` | INT NULL | NULL = بی‌پایان |
| `status` | VARCHAR(10) NOT NULL DEFAULT 'draft' | `draft`\|`active`\|`archived` |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_price_lists_tenant (entity_type, entity_id, status, valid_from)
KEY idx_price_lists_branch (branch_id, status, valid_from)
```
تداخل بازه در سطح اپلیکیشن بررسی می‌شود (`activate`)، نه DB — MariaDB محدودیت بازه‌ای ندارد
و راه سطل زمانی تسک ۰۷ اینجا بی‌مورد است چون تعداد لیست‌ها کم و تغییرشان نادر است.
## `price_list_items`
```sql
CREATE TABLE price_list_items (
id INT PRIMARY KEY AUTO_INCREMENT,
price_list_id INT NOT NULL,
service_item_id INT NULL, -- قیمت سرویس
service_option_id INT NULL, -- قیمت آیتم
price_rials INT NOT NULL,
UNIQUE KEY uniq_pli_service (price_list_id, service_item_id),
UNIQUE KEY uniq_pli_option (price_list_id, service_option_id),
KEY idx_pli_list (price_list_id),
CONSTRAINT fk_pli_list FOREIGN KEY (price_list_id) REFERENCES price_lists(id) ON DELETE CASCADE,
CONSTRAINT fk_pli_service FOREIGN KEY (service_item_id) REFERENCES service_items(id) ON DELETE CASCADE,
CONSTRAINT fk_pli_option FOREIGN KEY (service_option_id) REFERENCES service_options(id) ON DELETE CASCADE
);
```
دقیقاً یکی از `service_item_id` / `service_option_id` غیر-NULL (قید اپلیکیشنی).
فرزند aggregate با ریشهٔ `PriceList`.
## `price_snapshots`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `appointment_id` | INT NOT NULL UNIQUE | FK ON DELETE CASCADE — یک snapshot per نوبت |
| `base_rials` | INT NOT NULL | |
| `options_rials` | INT NOT NULL DEFAULT 0 | |
| `discount_rials` | INT NOT NULL DEFAULT 0 | |
| `insurance_base_rials` | INT NOT NULL DEFAULT 0 | |
| `insurance_supplementary_rials` | INT NOT NULL DEFAULT 0 | |
| `tax_rials` | INT NOT NULL DEFAULT 0 | |
| `final_rials` | INT NOT NULL | |
| `deposit_rials` | INT NOT NULL DEFAULT 0 | |
| `applied_policy_ids` | JSON NULL | `[{id, version}]` |
| `price_list_id` | INT NULL | FK SET NULL — کدام لیست مبنا بود |
| `created_at` | INT NOT NULL | |
```sql
UNIQUE KEY uniq_snapshot_appointment (appointment_id)
KEY idx_snapshot_tenant (entity_type, entity_id, created_at)
```
`UNIQUE` روی `appointment_id`: یک نوبت یک فاکتور رزرو دارد. `reschedule` نوبت **جدید**
می‌سازد (تسک ۰۷) پس snapshot جدید هم می‌گیرد و قدیمی سالم می‌ماند.
⚠️ همهٔ مبالغ `INT` ریال. `DECIMAL` یا `FLOAT` ننویس — بقیهٔ پروژه (`price_rials`,
`visit_price_rials`, `deposit_amount_rials`) همه `INT` ریال‌اند و قاطی کردن دو نوع یعنی
خطای گردکردن در جمع فاکتور.
## `price_snapshot_lines`
```sql
CREATE TABLE price_snapshot_lines (
id INT PRIMARY KEY AUTO_INCREMENT,
snapshot_id INT NOT NULL,
kind VARCHAR(15) NOT NULL, -- base|option|discount|insurance|tax|package
label VARCHAR(200) NOT NULL, -- snapshot متنی — نام لحظهٔ ثبت
amount_rials INT NOT NULL, -- منفی برای تخفیف و سهم بیمه
source_type VARCHAR(20) NULL, -- service|option|discount_rule|policy|insurance
source_id INT NULL, -- بدون FK — منبع ممکن است حذف شود
sort_order SMALLINT NOT NULL DEFAULT 0,
KEY idx_psl_snapshot (snapshot_id, sort_order),
CONSTRAINT fk_psl_snapshot FOREIGN KEY (snapshot_id) REFERENCES price_snapshots(id) ON DELETE CASCADE
);
```
`source_id` **بدون FK** عمدی: قانون تخفیف ممکن است فردا حذف شود ولی فاکتور دیروز باید
همان‌طور بماند. `label` هم به همین دلیل کپی متنی است، نه JOIN.
## `deposit_policies`
```sql
CREATE TABLE deposit_policies (
id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
service_item_id INT NULL, -- NULL = پیش‌فرض محیط
mode VARCHAR(10) NOT NULL, -- none|fixed|percent
value INT NOT NULL DEFAULT 0,
min_rials INT NULL,
max_rials INT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
UNIQUE KEY uniq_deposit_scope (entity_type, entity_id, service_item_id),
KEY idx_deposit_tenant (entity_type, entity_id, active)
);
```
## تغییر جدول موجود
هیچ. `appointments.visit_price_rials`, `deposit_required`, `deposit_amount_rials`,
`insurance_base_id`, `insurance_supplementary_id` همه استفاده می‌شوند و کافی‌اند.
`Tariff` هم دست‌نخورده می‌ماند و در زنجیرهٔ `PriceResolver` سطر ۴ است.
## 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:pricing:backfill-snapshots --force
```
`app:pricing:backfill-snapshots` برای نوبت‌های `confirmed` آینده که snapshot ندارند، یکی
از `visit_price_rials` موجود می‌سازد (یک ردیف `base`). بدون آن، صفحهٔ فاکتور برای
نوبت‌های موجود خالی است.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `price_lists`, `price_snapshots`, `deposit_policies` | جفت tenant |
| `price_list_items`, `price_snapshot_lines` | `AGGREGATE_CHILDREN` |
@@ -0,0 +1,136 @@
# نکات پیاده‌سازی — تسک ۰۸
## ۱. تاریخ مبنا: تاریخ رزرو، نه تاریخ ثبت
```php
$at = $appointment->getSlotStart(); // ✅
// نه: time()
```
دو تفسیر ممکن است و باید یکی انتخاب شود. مستند بند ۱۲ صریح می‌گوید «لیست قیمت معتبر در
تاریخ رزرو». پیامدش: بیمار که امروز برای مهر نوبت می‌گیرد، قیمت مهر را می‌پردازد.
این را در `docs/api/pricing.md` و در UI («قیمت بر اساس تاریخ نوبت محاسبه شده است») بنویس.
## ۲. عدد صحیح ریال، همه‌جا
```php
// درصد تخفیف روی مبلغ صحیح
$discount = intdiv($amount * $percent, 100); // ✅ گردکردن به پایین، قطعی
// نه: (int) round($amount * $percent / 100) // ❌ float در مسیر پول
```
`intdiv` قطعی است و در همهٔ پلتفرم‌ها یکسان. یک ریال اختلاف در جمع فاکتور، ساعت‌ها
دیباگ حسابداری می‌آورد.
## ۳. سقف تخفیف
مستند بند ۸: «تخفیف درصدی: به ترتیب اولویت پشت سر هم، با یک سقف قابل تنظیم».
```php
// SiteConfig یا تنظیمات محیط
$maxPercent = $this->config->maxTotalDiscountPercent($ctx) ?? 100;
$cap = intdiv($subtotal * $maxPercent, 100);
$discountTotal = min($discountTotal, $cap);
```
بدون سقف، سه قانون ۴۰٪ پشت‌سرهم مبلغ را به ۲۱٪ می‌رسانند و کلینیک صبح روز بعد
متوجه می‌شود.
**پشت سر هم، نه جمع:** ۴۰٪ سپس ۱۰٪ یعنی `0.9 × 0.6 = 0.54`، نه `1 - 0.5 = 0.5`.
این تفاوت باید در تست باشد.
## ۴. مبلغ نهایی هرگز منفی نیست
```php
$final = max(0, $subtotal - $discount - $insuranceBase - $insuranceSupplementary + $tax);
```
و اگر `max(0, …)` فعال شد، یک ردیف `price_snapshot_lines` با `kind='adjustment'` و
مبلغ اصلاحی ثبت شود — وگرنه جمع ردیف‌ها با `final_rials` نمی‌خواند و اولین کسی که
فاکتور را audit کند فکر می‌کند باگ محاسباتی است.
## ۵. جمع ردیف‌ها باید با مبلغ نهایی بخواند
تست ثابت (invariant):
```php
$sum = array_sum(array_map(fn($l) => $l->getAmountRials(), $snapshot->getLines()));
self::assertSame($snapshot->getFinalRials(), $sum, 'جمع ردیف‌ها باید با مبلغ نهایی برابر باشد');
```
با علامت‌گذاری درست (تخفیف و سهم بیمه منفی) این همیشه برقرار است. اگر نبود، یکی از
مراحل ردیف ننوشته — که یعنی فاکتور غیرقابل‌توضیح.
## ۶. بیمه: از موجود استفاده کن، دوباره نساز
`AppointmentInsuranceService` و `TenantServiceCoverage` و `TenantInsuranceCategoryCoverage`
از قبل هستند و منطق «تکمیلی روی باقیماندهٔ بعد از پایه» را دارند
(`docs/architecture/insurance-billing-system.md`). `PricingEngine` مرحلهٔ ۵ فقط آن را صدا
می‌زند و نتیجه را به ردیف تبدیل می‌کند.
قاعدهٔ پروژه: «API جدید فقط وقتی هیچ اندپوینت موجودی کافی نباشد». اینجا سرویس موجود
کافی است — بازنویسی‌اش یعنی دو منبع حقیقت برای پوشش بیمه.
## ۷. تداخل بازهٔ لیست قیمت
```php
// PriceListService::activate()
$overlap = $this->repo->findActiveOverlapping($ctx, $branch, $validFrom, $validTo);
if ($overlap !== []) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, sprintf(
'لیست قیمت «%s» بازهٔ مشترک دارد', $overlap[0]->getName()
), 422);
}
```
نکتهٔ ظریف: لیست بدون شعبه (`branch_id = NULL`) با لیست شعبه‌دار تداخل **ندارد**
دومی اختصاصی‌تر است و اولویت دارد. فقط لیست‌های هم‌سطح با هم تداخل دارند.
## ۸. edge case ها
| حالت | رفتار درست |
|---|---|
| هیچ لیست قیمتی تاریخ را نمی‌پوشاند | fallback: تعرفهٔ سال → قیمت سرویس |
| سرویس در لیست قیمت نیست | همان fallback per سرویس، نه رد کل quote |
| `valid_to = null` و لیست جدید با `valid_from` وسط آن | `activate` باید لیست قبلی را با `valid_to = new.valid_from - 1` ببندد و پیام بدهد، نه `422` خشک |
| نوبت حالت `slot` بدون سرویس | snapshot با `visit_price_rials` و یک ردیف `base` |
| تخفیف بیشتر از مبلغ | `final = 0` + ردیف `adjustment` |
| بیعانه درصدی وقتی مبلغ صفر است | بیعانه صفر، `deposit_required = false` |
| `reschedule` | نوبت جدید، snapshot جدید با قیمت **تاریخ جدید** |
| snapshot موجود و `confirm` دوباره (idempotent تسک ۰۷) | snapshot دست‌نخورده بماند، دوباره ساخته نشود |
| مبلغ بزرگ‌تر از `INT_MAX` ریال (۲.۱ میلیارد) | `BIGINT` لازم؟ — ۲۱۴ میلیون تومان. برای پکیج‌های بزرگ ممکن است. **تصمیم: `BIGINT` برای `final_rials` و `amount_rials`** |
آخرین سطر را جدی بگیر: پکیج ۸ جلسه لیزر فول‌بادی می‌تواند از سقف `INT` عبور کند.
`price_snapshots.final_rials` و `price_snapshot_lines.amount_rials` را `BIGINT` بگیر.
(بقیهٔ ستون‌های `price_rials` پروژه `INT` می‌مانند — قیمت واحد از سقف عبور نمی‌کند.)
## ۹. تست
```
tests/Pricing/PriceResolverTest.php
- ترتیب پنج‌گانه: override شعبه > لیست شعبه > لیست محیط > تعرفه > قیمت سرویس
- تاریخ بدون لیست → fallback
tests/Pricing/PricingEngineTest.php
- تخفیف پشت‌سرهم: ۴۰٪ سپس ۱۰٪ → ۵۴٪ باقی، نه ۵۰٪
- سقف تخفیف اعمال می‌شود
- مبلغ منفی → صفر + ردیف adjustment
- جمع ردیف‌ها = مبلغ نهایی (invariant، در همهٔ سناریوها)
tests/Pricing/PriceSnapshotImmutabilityTest.php ← ⭐ قانون پنجم
- ثبت نوبت → تغییر قیمت سرویس → snapshot بدون تغییر
- حذف قانون تخفیف → label و مبلغ ردیف سالم
tests/Pricing/PriceListActivationTest.php
- بازهٔ هم‌پوشان هم‌سطح → 422
- لیست شعبه با لیست محیط → تداخل نیست
tests/Pricing/DepositCalculatorTest.php
- درصدی با min/max
- سیاست سرویس بر سیاست محیط اولویت دارد
tests/Pricing/QuoteTenantTest.php
- سرویس محیط دیگر → 404
```
## ۱۰. مستندات
`docs/api/pricing.md` بساز. `docs/architecture/insurance-billing-system.md` را با جدول
`PriceSnapshot` vs `Invoice` به‌روز کن — این تنها راه جلوگیری از حذف یکی از آن‌ها در
آیندهٔ نزدیک است.
@@ -0,0 +1,81 @@
# تسک ۰۸ — لیست قیمت بازه‌دار و snapshot فاکتور
**فاز:** ۱ (هسته) · **وابستگی:** ۰۴، ۰۷ · **زمان:** ۱۲-۱۴ ساعت
---
## هدف
مستند بند ۱۲: قیمت یک لایهٔ جداست با زندگی خودش (تاریخ اعتبار، مالیات، بیمه، بیعانه) و
قانون پنجم: **تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.**
## وضعیت فعلی
زنجیرهٔ قیمت امروز واقعاً وجود دارد و کار می‌کند:
```
ServiceItem.price_rials
→ Tariff (سال‌محور: findForServiceYear)
→ TenantServiceCoverage / TenantInsurance (بیمهٔ پایه و تکمیلی)
→ DiscountRule + DiscountEngine
→ Invoice / InvoiceItem
→ Payment
```
روی نوبت هم `visit_price_rials`, `deposit_required`, `deposit_amount_rials`,
`insurance_base_id`, `insurance_supplementary_id` هست.
**دو شکاف:**
1. `Tariff` فقط **سال** دارد، بازهٔ دقیق تاریخ ندارد. تغییر تعرفه وسط سال قابل بیان نیست.
2. `visit_price_rials` یک عدد است. فاکتور **تفکیک‌شده** روی نوبت ذخیره نمی‌شود، پس بعد از
تغییر قیمت یا تخفیف، نمی‌شود گفت آن ۲٬۴۰۰٬۰۰۰ ریال از چه تشکیل شده بود.
## دامنه
**هست:**
- `PriceList` (بازهٔ تاریخ + شعبه) و `PriceListItem`
- `PriceSnapshot` — فاکتور تفکیک‌شدهٔ لحظهٔ ثبت نوبت
- `PricingEngine` — زنجیرهٔ هفت‌مرحله‌ای مستند بند ۱۲
- سیاست بیعانه per سرویس/محیط
- اتصال به `BookingService::confirm()` (قلاب مرحلهٔ ۶ تسک ۰۷)
**نیست:** پکیج و دفتر اعتبار (تسک ۱۱)، قوانین قیمت پیشرفته (تسک ۰۹ — `DiscountRule`
موجود فعلاً کافی است).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET/POST | `/api/v1/price-lists` | لیست قیمت با بازهٔ تاریخ |
| GET/PATCH/DELETE | `/api/v1/price-list/{uuid}` | |
| PUT | `/api/v1/price-list/{uuid}/items` | قیمت سرویس‌ها و آیتم‌ها |
| POST | `/api/v1/price-list/{uuid}/activate` | فعال‌سازی (بررسی تداخل بازه) |
| POST | `/api/v1/pricing/quote` | محاسبهٔ قیمت بدون ثبت |
| GET | `/api/v1/appointment/{uuid}/price-snapshot` | فاکتور تفکیک‌شدهٔ نوبت |
## معیار پذیرش
- ✅ موفق: لیست قیمت «نیمهٔ دوم ۱۴۰۵» با بازهٔ ۱۴۰۵/۰۷/۰۱ تا ۱۴۰۵/۱۲/۲۹ فعال می‌شود؛
`POST /pricing/quote` برای تاریخ مهر قیمت جدید و برای شهریور قیمت قبلی می‌دهد.
- ✅ موفق: `confirm` نوبت → `price_snapshots` یک ردیف با تفکیک کامل دارد:
قیمت پایه، جمع آیتم‌ها، تخفیف‌های اعمال‌شده (با نام و مبلغ هر کدام)، سهم بیمهٔ پایه،
سهم تکمیلی، مالیات، مبلغ نهایی، بیعانه.
- ✅ موفق (**قانون پنجم**): بعد از ثبت نوبت، قیمت سرویس دو برابر می‌شود →
`GET /appointment/{uuid}/price-snapshot` **همان اعداد قبلی** را می‌دهد.
- ✅ موفق: قیمت override شعبه (تسک ۰۴) بر لیست قیمت محیط اولویت دارد.
- ❌ خطا: دو لیست قیمت فعال با بازهٔ هم‌پوشان برای یک شعبه → `422` هنگام `activate`.
- ❌ خطا: `quote` با سرویس محیط دیگر → `404`.
- ⚠️ مرزی: تاریخی که هیچ لیست قیمتی نمی‌پوشاند → fallback به `Tariff` سال، بعد به
`ServiceItem.price_rials`. هرگز صفر یا خطا.
- ⚠️ مرزی: تخفیف بیشتر از مبلغ → مبلغ نهایی صفر، نه منفی.
- ⚠️ مرزی: سقف جمع تخفیف‌ها (`max_total_discount_percent` per محیط) → اعمال شود.
- ⚠️ مرزی: بیعانه بیشتر از مبلغ نهایی → `422` هنگام تنظیم سیاست.
- ⚠️ مرزی: نوبت بدون سرویس (نوبت ویزیت ساده در حالت `slot`) → snapshot با
`visit_price_rials` موجود ساخته شود، نه خالی.
## خروجی
- `src/Pricing/`
- `assets/admin/pages/PriceListsPage.tsx` + `PriceListFormPage.tsx`
- `docs/api/pricing.md`
- به‌روزرسانی `docs/architecture/insurance-billing-system.md`
@@ -0,0 +1,202 @@
# معماری — تسک ۰۹
## ساختار فایل
```
src/Policy/
├── Entity/
│ ├── Policy.php
│ └── PolicyVersionLog.php
├── Condition/
│ ├── ConditionEvaluator.php # ارزیابی شرط
│ ├── FieldRegistry.php # فهرست بستهٔ فیلدها
│ ├── OperatorRegistry.php # فهرست بستهٔ عملگرها
│ └── PolicyContext.php # داده‌های در دسترس قانون
├── Effect/
│ ├── EffectRegistry.php
│ └── Combiner.php # جدول ترکیب اثرها (مستند بند ۸)
├── Engine/
│ ├── PolicyResolver.php # انتخاب قوانین مرتبط + حل تناقض
│ ├── SelectionPolicyEngine.php
│ ├── EligibilityPolicyEngine.php
│ ├── ResourcePolicyEngine.php
│ ├── TimingPolicyEngine.php
│ ├── SpacingPolicyEngine.php # ← تنها موتوری که SQL تولید می‌کند
│ └── PricingPolicyEngine.php
├── Controller/PolicyController.php
└── Repository/PolicyRepository.php
```
شش موتور جدا، نه یک `PolicyEngine` بزرگ. هر کدام ورودی و خروجی نوع‌دار خودش را دارد
و در نقطهٔ متفاوتی از زنجیره صدا زده می‌شود — ادغامشان یعنی یک کلاس با شش دلیل تغییر.
## `Policy`
```php
class Policy
{
use TenantOwnedTrait;
public const CATEGORIES = ['selection','eligibility','resource','timing','spacing','pricing'];
private string $name;
private string $category;
private ?Branch $branch = null; // null = همهٔ شعب محیط
private int $priority = 0;
private int $version = 1;
private ?int $validFrom = null;
private ?int $validTo = null;
private bool $active = false; // ← پیش‌فرض غیرفعال، تا آزمایش شود
private array $conditions = []; // JSON — فهرست بسته
private array $effects = []; // JSON — فهرست بسته
private int $specificity = 0; // محاسبه‌شده، برای حل تناقض
}
```
`active = false` پیش‌فرض عمدی است — مستند بند ۸: «قبل از اینکه یک قانون واقعاً فعال شود،
باید بشود آن را روی داده واقعی اجرا کرد». تسک ۱۰ همین را می‌سازد.
## شرط — فهرست بسته
```json
{
"all": [
{ "field": "service.category_path", "op": "contains", "value": "جراحی" },
{ "field": "patient.age", "op": "gte", "value": 18 }
],
"any": [
{ "field": "patient.tags", "op": "in", "value": ["vip", "gold"] }
]
}
```
فقط `all` و `any` در **یک** سطح. تودرتویی ممنوع — مستند بند ۸: قانون باید قابل تبدیل به
کوئری باشد و تودرتویی دلخواه یعنی همان کد دلخواهی که ممنوع شده.
### `FieldRegistry` — فهرست کامل
| دامنه | فیلدها |
|---|---|
| `service` | `id`, `category_path`, `tags`, `session_count` |
| `options` | `ids`, `group_ids`, `count` |
| `patient` | `age`, `gender`, `tags`, `last_session_at`, `completed_sessions_count` |
| `booking` | `at`, `day_of_week`, `branch_id`, `channel` (`online`\|`panel`\|`phone`) |
| `plan` | `total_minutes`, `total_price_rials` |
هر فیلد جدید باید **صریحاً** اینجا اضافه شود. `FieldRegistry` هم schema را برای
`GET /policy-schema` تولید می‌کند و هم استخراج مقدار از `PolicyContext` را انجام می‌دهد —
یک منبع حقیقت، پس فیلدی که در فرم هست و در ارزیابی نیست، ممکن نمی‌شود.
### `OperatorRegistry`
`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `between`, `contains`, `days_since`
`days_since` عملگر ویژهٔ مستند است («تعداد روز گذشته») و روی فیلدهای زمانی کار می‌کند.
## اثر — فهرست بسته per دسته
| دسته | اثرهای مجاز |
|---|---|
| `selection` | `require_option`, `forbid_option`, `auto_add_option` |
| `eligibility` | `deny` (با پیام), `require_flag` |
| `resource` | `add_requirement`, `restrict_requirement` |
| `timing` | `min_duration`, `add_duration` |
| `spacing` | `min_days_since_last`, `max_days_since_last`, `deny_weekday` |
| `pricing` | `discount_percent`, `discount_fixed`, `surcharge_percent`, `surcharge_fixed` |
اثر خارج از دستهٔ خودش → `422` هنگام ذخیره. این جدول عیناً در `GET /policy-schema` می‌آید.
## حل تناقض — `PolicyResolver`
```php
usort($policies, function (Policy $a, Policy $b) {
return [$b->getPriority(), $b->getSpecificity(), $a->getCreatedAt()]
<=> [$a->getPriority(), $a->getSpecificity(), $b->getCreatedAt()];
});
```
سه معیار به ترتیب مستند: اولویت بالاتر → اختصاصی‌تر → قدیمی‌تر.
`specificity` هنگام ذخیره محاسبه و ذخیره می‌شود (نه در زمان اجرا):
```
+8 branch مشخص
+4 service مشخص در شرط
+2 service_category مشخص در شرط
+1 هر شرط اضافه
```
## ترکیب اثرها — `Combiner` (جدول مستند بند ۸)
| نوع اثر | قاعده | پیاده‌سازی |
|---|---|---|
| `min_duration` | بیشترین برنده | `max()` |
| `add_duration` | جمع | `array_sum()` |
| `add_requirement` | همه، تکراری حذف | union روی `groupKey` |
| `restrict_requirement` | اشتراک محدودیت‌ها | `array_intersect` روی کاندیدها |
| `discount_percent` | پشت‌سرهم به ترتیب اولویت، با سقف | حلقهٔ ضربی + `min($total, $cap)` |
| `deny` / `forbid_option` | یکی کافی است | short-circuit |
`Combiner` یک کلاس خالص و بدون I/O — تست واحد کامل بدون دیتابیس.
## `SpacingPolicyEngine` — تنها موتور SQL-ساز
مستند بند ۸: «قوانین مربوط به فاصله زمانی باید قابل تبدیل به کوئری دیتابیس باشند، وگرنه
جستجوی وقت آزاد کند می‌شود».
```php
/** به‌جای فیلتر کردن ۹۰ روز اسلات در PHP، یک بازهٔ ممنوعه برمی‌گرداند. */
public function forbiddenRanges(PolicyContext $ctx): array
{
// قانون: min_days_since_last = 21
// آخرین جلسهٔ بیمار برای این سرویس: 1405/05/01
// → بازهٔ ممنوعه: [آخرین جلسه, آخرین جلسه + 21 روز)
// یک کوئری برای «آخرین جلسه»، بعد محاسبهٔ بازه در PHP
}
```
`AvailabilityEngine` این بازه‌ها را **پیش از** تولید کاندیدها به `CandidateGenerator` می‌دهد
تا آن نقطه‌ها هرگز ساخته نشوند — نه اینکه بعد فیلتر شوند.
## نسخه‌بندی
```
POST /policy/{uuid}/version { valid_from: …, conditions: …, effects: … }
├─ نسخهٔ فعلی: valid_to = new.valid_from - 1
├─ ردیف جدید در policy_version_log با snapshot کامل نسخهٔ قبلی
└─ policy.version++ و مقادیر جدید روی همان ردیف
```
قانون **ویرایش نمی‌شود** — هر تغییر نسخهٔ جدید با تاریخ شروع می‌سازد. نوبت‌ها
`{policy_id, version}` را ذخیره می‌کنند (`applied_policy_ids` تسک ۰۸)، پس فاکتور دیروز
با تغییر امروز خراب نمی‌شود.
`policy_version_log` snapshot کامل نگه می‌دارد نه diff: بازسازی نسخهٔ قدیم باید یک
`SELECT` باشد، نه اعمال زنجیرهٔ diff.
## نقاط اتصال — همه از قبل آماده‌اند
```php
// تسک ۰۵ — AppointmentPlanBuilder::build() مرحلهٔ ۷
return $this->policies->applyToPlan($withResources);
// ↑ ResourcePolicyEngine + TimingPolicyEngine
// تسک ۰۶ — AvailabilityEngine::search() مرحلهٔ ۶
return $this->policies->filterSlots($result, $req);
// ↑ SpacingPolicyEngine (به‌صورت forbiddenRanges، پیش از تولید کاندید)
// تسک ۰۷ — BookingService::confirm() مرحلهٔ ۳
$this->policies->assertEligibility($appointment);
// ↑ EligibilityPolicyEngine
// تسک ۰۸ — PricingEngine::quote() مرحلهٔ ۳
$lines = $this->discounts->apply($lines, $req);
// ↑ PricingPolicyEngine + DiscountEngine موجود
// تسک ۰۴ — ServiceSelectionValidator::validate()
$errors = array_merge($errors, $this->policies->validateSelection($ctx, ));
// ↑ SelectionPolicyEngine
```
هیچ امضایی عوض نمی‌شود — این دقیقاً دلیلی است که آن قلاب‌ها از روز اول گذاشته شدند.
@@ -0,0 +1,126 @@
# دیتابیس — تسک ۰۹
## `policies`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `branch_id` | INT NULL | NULL = همهٔ شعب — FK ON DELETE CASCADE |
| `name` | VARCHAR(200) NOT NULL | «خدمات جراحی به جراح نیاز دارند» |
| `category` | VARCHAR(20) NOT NULL | یکی از شش دسته |
| `priority` | SMALLINT NOT NULL DEFAULT 0 | |
| `specificity` | SMALLINT NOT NULL DEFAULT 0 | محاسبه‌شده هنگام ذخیره |
| `version` | SMALLINT NOT NULL DEFAULT 1 | |
| `valid_from` | INT NULL | |
| `valid_to` | INT NULL | |
| `active` | TINYINT(1) NOT NULL DEFAULT 0 | **پیش‌فرض غیرفعال** |
| `conditions` | JSON NOT NULL | |
| `effects` | JSON NOT NULL | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_policies_lookup (entity_type, entity_id, category, active, valid_from)
KEY idx_policies_branch (branch_id, category, active)
```
`idx_policies_lookup` کوئری داغ است: «قوانین فعال دستهٔ X این محیط که امروز معتبرند».
با ۶ دسته و معمولاً < ۵۰ قانون per محیط، این کوئری همیشه ارزان است — و باید بماند.
اگر روزی قوانین به هزاران رسیدند، کش per (محیط، دسته) اضافه شود، نه ایندکس پیچیده‌تر.
## `policy_version_log`
```sql
CREATE TABLE policy_version_log (
id INT PRIMARY KEY AUTO_INCREMENT,
policy_id INT NOT NULL,
version SMALLINT NOT NULL,
snapshot JSON NOT NULL, -- کل قانون در آن نسخه، نه diff
valid_from INT NULL,
valid_to INT NULL,
changed_by INT NULL, -- FK users ON DELETE SET NULL
changed_at INT NOT NULL,
UNIQUE KEY uniq_policy_version (policy_id, version),
KEY idx_pvl_policy (policy_id, version),
CONSTRAINT fk_pvl_policy FOREIGN KEY (policy_id) REFERENCES policies(id) ON DELETE CASCADE
);
```
`snapshot` کامل است، نه diff: بازسازی نسخهٔ ۳ از یک قانون که الان نسخهٔ ۹ است، باید یک
`SELECT` باشد. حجم ناچیز (JSON چند کیلوبایتی × چند ده نسخه).
## هیچ تغییری در `discount_rules`
جدول و entity موجود دست‌نخورده می‌مانند. `PricingPolicyEngine` **هر دو** را می‌خواند:
```php
$effects = array_merge(
$this->discountEngine->evaluate($ctx), // DiscountRule موجود
$this->pricingPolicies->evaluate($ctx), // Policy دستهٔ pricing
);
$combined = $this->combiner->combine($effects); // ترکیب واحد
```
دلیل عدم مهاجرت در implementation_notes بند ۱.
## `applied_policy_ids` — تسک ۰۸
ستون از قبل در `price_snapshots` هست. قرارداد مقدارش اینجا تعیین می‌شود:
```json
[
{ "source": "policy", "id": 12, "version": 3, "name": "تخفیف VIP" },
{ "source": "discount_rule", "id": 5, "version": 1, "name": "تخفیف تولد" }
]
```
`name` کپی متنی — همان دلیل `price_snapshot_lines.label`: قانون ممکن است حذف شود.
## ذخیرهٔ قوانین اعمال‌شده روی خودِ نوبت
قوانین غیرقیمتی هم باید ثبت شوند (مستند: «هر نوبت فهرست قانون‌هایی که رویش اعمال شده را
ذخیره می‌کند»)، ولی `price_snapshots` جای قوانین منبع و زمان نیست.
```sql
ALTER TABLE appointments
ADD COLUMN applied_policies JSON NULL;
```
همان قرارداد بالا. یک ستون JSON کافی است — هیچ کوئری‌ای روی آن زده نمی‌شود، فقط برای
آدیت و پاسخ به «چرا این نوبت ۷۵ دقیقه شد؟» خوانده می‌شود.
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
بدون backfill. هیچ قانونی از قبل وجود ندارد و `DiscountRule` ها سر جایشان می‌مانند.
## نمونهٔ داده برای تست
```bash
ddev exec php bin/console app:policy:seed-examples --tenant=clinic:12 --force
```
پنج قانون نمونه (یکی از هر دسته جز `selection`)، همه با `active=false` تا کلینیک اول
آزمایششان کند:
```
resource | خدمات جراحی به جراح نیاز دارند
timing | حداقل مدت درمان پیچیده یک ساعت است
spacing | حداقل ۲۱ روز از جلسهٔ قبلی لیزر
eligibility | بیمار زیر ۱۸ سال بدون رضایت والدین نمی‌شود
pricing | بیمار VIP ده درصد تخفیف
```
این نمونه‌ها هم مستندات زنده‌اند و هم داده تست تسک ۱۰.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `policies` | جفت tenant |
| `policy_version_log` | `AGGREGATE_CHILDREN` → ریشه `Policy` |
@@ -0,0 +1,187 @@
# نکات پیاده‌سازی — تسک ۰۹
## ۱. چرا `DiscountRule` مهاجرت نمی‌کند
وسوسه‌اش زیاد است: `DiscountRule` عملاً یک `Policy` دستهٔ `pricing` است. ولی:
- `DiscountEngine` روی پرونده (`PatientRecord`) و مراجعه هم اجرا می‌شود، نه فقط نوبت
- `DiscountsPage.tsx` و `docs/api/discount.md` و تست‌های موجود روی همان قرارداد‌ند
- شش نوع تخفیفش (`patient_tag`, `occasion`, `visit_count`, …) دقیقاً همان شرط‌های
`FieldRegistry` نیستند و نگاشت یک‌به‌یک ندارند
هزینهٔ مهاجرت بالا و سودش صفر است. `PricingPolicyEngine` هر دو را می‌خواند و
`Combiner` نتیجه‌شان را یکجا ترکیب می‌کند. **ولی** یک قاعده لازم است:
> تخفیف جدید در `DiscountRule` ساخته می‌شود اگر روی مراجعه هم کار می‌کند؛ در `Policy`
> اگر فقط قیمت نوبت را عوض می‌کند. این را در `docs/api/policy.md` بنویس.
## ۲. `FieldRegistry` تنها منبع حقیقت
```php
final class FieldRegistry
{
private const FIELDS = [
'patient.age' => ['type' => 'int', 'ops' => ['eq','gt','gte','lt','lte','between']],
'patient.tags'=> ['type' => 'array', 'ops' => ['in','not_in','contains']],
// …
];
public function schema(): array; // برای GET /policy-schema
public function extract(string $field, PolicyContext $ctx): mixed; // برای ارزیابی
public function assertValid(string $field, string $op): void; // برای ذخیره
}
```
سه مسئولیت روی یک آرایه. اگر schema و extract جدا باشند، فیلدی در فرم ظاهر می‌شود که
ارزیابی نمی‌شود — و قانونی که همیشه false است، بدترین باگ این سیستم است چون خطا نمی‌دهد.
## ۳. قانون خاموش هرگز نباید بی‌صدا false باشد
```php
// ❌
$value = $ctx->get($field) ?? null;
if ($value === null) return false; // قانون بی‌صدا رد می‌شود
// ✅
if (!$this->registry->has($field)) {
throw new \LogicException("فیلد ناشناخته در قانون: {$field}"); // نباید ممکن باشد؛ ذخیره جلویش را گرفته
}
$value = $this->registry->extract($field, $ctx);
if ($value === self::UNAVAILABLE) {
$this->logger->warning('policy_field_unavailable', ['policy' => $id, 'field' => $field]);
return false; // با لاگ، نه سکوت
}
```
مثال واقعی: قانون روی `patient.last_session_at` برای بیمار جدید. مقدار وجود ندارد و
قانون باید رد شود — ولی با لاگ، تا اگر کلینیک گفت «قانونم کار نمی‌کند» جواب داشته باشیم.
## ۴. `spacing` — کوئری، نه حلقه
بدترین اشتباه ممکن در این تسک:
```php
// ❌ فاجعهٔ کارایی — ۹۰ روز × ده‌ها اسلات × یک کوئری
foreach ($slots as $slot) {
$last = $this->appointmentRepo->findLastSession($patient, $service);
if ($slot['start'] - $last < $minDays * 86400) continue;
}
```
```php
// ✅ یک کوئری، بعد بازهٔ ممنوعه
$last = $this->appointmentRepo->findLastCompletedAt($patient, $service); // ۱ کوئری
if ($last !== null) {
$forbidden[] = ['start' => $last, 'end' => $last + $minDays * 86400];
}
// بازه به CandidateGenerator داده می‌شود → آن نقطه‌ها ساخته نمی‌شوند
```
`AvailabilityPerformanceTest` تسک ۰۶ باید **با قوانین فعال** هم سبز بماند. اگر بعد از این
تسک قرمز شد، دلیلش همین است.
## ۵. ترتیب اعمال تخفیف — پشت‌سرهم
```php
$remaining = $subtotal;
foreach ($sortedPolicies as $policy) { // به ترتیب اولویت
$amount = intdiv($remaining * $policy->percent(), 100);
$remaining -= $amount;
$lines[] = PriceLine::discount($policy->getName(), -$amount);
}
```
نه جمع درصدها. ۴۰٪ سپس ۱۰٪ = ۴۶٪ کل، نه ۵۰٪. مستند بند ۸ صریح: «به ترتیب اولویت پشت
سر هم».
## ۶. `combinable` و short-circuit
```php
foreach ($sorted as $policy) {
if (!$this->conditions->matches($policy, $ctx)) continue;
$applied[] = $policy;
if (!$policy->isCombinable()) break; // ← اولین غیرترکیب‌شدنی، پایان
}
```
قانون غیرترکیب‌شدنی با اولویت بالا، بقیه را می‌بلعد. این همان رفتار `DiscountRule` موجود
است و باید یکسان بماند، وگرنه دو دستهٔ تخفیف دو رفتار متفاوت می‌گیرند.
اثر `deny` استثناست: **همیشه** short-circuit، مستقل از `combinable`.
## ۷. نسخه‌بندی — ویرایش ممنوع
```php
// PolicyController: PATCH وجود ندارد. فقط:
POST /policy/{uuid}/version
```
`PATCH` روی محتوای قانون عمداً نیست. تنها چیزهایی که بدون نسخهٔ جدید تغییر می‌کنند:
`active`، `name`. شرط و اثر و اولویت → نسخهٔ جدید.
اگر کاربر گفت «فقط می‌خواهم غلط املایی نام را درست کنم» — `name` مجاز است. هر چیزی که
روی **محاسبه** اثر دارد، نه.
## ۸. edge case ها
| حالت | رفتار درست |
|---|---|
| هیچ قانونی وجود ندارد | همه‌چیز مثل قبل — تست سازگاری اجباری |
| قانون فعال با `valid_from` آینده | اعمال نمی‌شود |
| دو قانون `deny` | یکی کافی است؛ پیام اولی (بالاترین اولویت) نمایش داده می‌شود |
| قانون `add_requirement` که هیچ منبع واجد شرایطی ندارد | `NoEligibleResourceException` با پیام شامل نام قانون: «قانون X جراح می‌خواهد ولی جراحی در این شعبه نیست» |
| قانون `min_duration` کمتر از مدت فعلی | بی‌اثر (`max`) |
| قانون `spacing` برای بیمار مهمان بدون سابقه | رد نمی‌کند، اعمال نمی‌شود |
| قانون روی `booking.channel = online` و ثبت از پنل | اعمال نمی‌شود |
| `conditions` خالی (`{}`) | همیشه true — مجاز، ولی در UI هشدار «این قانون روی همهٔ نوبت‌ها اعمال می‌شود» |
| قانون دستهٔ `resource` با اثر `discount_percent` | `422` هنگام ذخیره |
| نسخهٔ جدید با `valid_from` گذشته | `422` — نسخه گذشته را عوض نمی‌کند |
| حذف قانونی که در `applied_policy_ids` نوبت‌هاست | مجاز — `name` کپی شده و فاکتور سالم است |
سطر ماقبل آخر مهم است: `valid_from` گذشته یعنی بازنویسی تاریخ، که قانون پنجم مستند را
نقض می‌کند.
## ۹. تست
```
tests/Policy/ConditionEvaluatorTest.php ← واحد، بدون DB
- همهٔ عملگرها روی همهٔ نوع‌ها
- all/any
- فیلد ناموجود → false با لاگ
tests/Policy/CombinerTest.php ← واحد
- min_duration: max برنده
- add_duration: جمع
- add_requirement: union بدون تکرار
- restrict_requirement: اشتراک
- discount: پشت‌سرهم (۴۰ سپس ۱۰ → ۴۶ کل)
- deny: یکی کافی
tests/Policy/PolicyResolverTest.php
- اولویت > اختصاصی‌بودن > قدمت (سه سناریوی جدا)
- combinable=false short-circuit
tests/Policy/SpacingPolicyEngineTest.php
- بازهٔ ممنوعه درست
- بیمار بدون سابقه → بی‌اثر
- تعداد کوئری ثابت (نه per slot)
tests/Policy/PolicyVersioningTest.php ← ⭐ قانون پنجم
- نوبت با نسخهٔ ۱ ثبت شد → نسخهٔ ۲ ساخته شد → فاکتور نوبت تغییر نکرد
- policy_version_log snapshot کامل دارد
- valid_from گذشته → 422
tests/Policy/PolicyIntegrationTest.php
- resource: نیازمندی اضافه در preview
- timing: مدت افزایش می‌یابد
- eligibility: confirm رد می‌شود با پیام فارسی
- pricing: ردیف تخفیف در snapshot
tests/Policy/PolicySchemaTest.php
- هر فیلد schema قابل extract است (نه فیلد نمایشی بی‌ارزیابی)
- اثر خارج از دسته → 422
tests/Policy/NoPolicyRegressionTest.php ← ⭐
- بدون هیچ قانون: خروجی preview/availability/quote بیت‌به‌بیت مثل تسک ۰۸
tests/Appointment/AvailabilityPerformanceTest.php ← باید با قوانین فعال هم سبز باشد
```
## ۱۰. مستندات
- `docs/api/policy.md` — endpoint ها + فهرست کامل فیلد/عملگر/اثر + قاعدهٔ
«`DiscountRule` یا `Policy`؟»
- `docs/architecture/policy-engine.md` — جدول حل تناقض، جدول ترکیب، دلیل ممنوعیت کد
دلخواه، و دلیل عدم مهاجرت `DiscountRule`
@@ -0,0 +1,97 @@
# تسک ۰۹ — موتور قوانین شش‌دسته‌ای
**فاز:** ۲ (قوانین) · **وابستگی:** ۰۵، ۰۶، ۰۸ · **زمان:** ۲۰-۲۴ ساعت
---
## هدف
مستند بند ۸: شش دستهٔ قانون، هر کدام با نقطهٔ اجرای مشخص، شرط از فهرست بسته،
اولویت‌دار، نسخه‌بندی‌شده، و **بدون کد دلخواه**.
| دسته | کِی اجرا می‌شود | نقطهٔ اتصال در کد |
|---|---|---|
| انتخاب (`selection`) | موقع انتخاب آیتم | `ServiceSelectionValidator` (تسک ۰۴) |
| صلاحیت بیمار (`eligibility`) | قبل از جستجوی وقت | `AvailabilityEngine::search` ابتدا · `BookingService::confirm` مرحلهٔ ۳ |
| منبع (`resource`) | موقع ساخت برنامه | `AppointmentPlanBuilder` مرحلهٔ ۷ (تسک ۰۵) |
| زمان (`timing`) | موقع ساخت برنامه | همان |
| فاصلهٔ زمانی (`spacing`) | موقع جستجوی وقت | `AvailabilityEngine` مرحلهٔ ۶ |
| قیمت (`pricing`) | بعد از نهایی شدن برنامه | `PricingEngine` مرحلهٔ ۳ (تسک ۰۸) |
همهٔ این قلاب‌ها در تسک‌های ۰۴ تا ۰۸ از قبل به‌صورت no-op گذاشته شده‌اند. این تسک
پُرشان می‌کند.
## وضعیت فعلی
`DiscountRule` + `DiscountEngine` تنها موتور قانون موجود است و **الگوی درستی** دارد:
```php
// src/Discount/Entity/DiscountRule.php
public const TYPES = [TYPE_PATIENT_TAG, TYPE_INVOICE_AMOUNT, TYPE_SPECIFIC_PATIENT,
TYPE_OCCASION, TYPE_SERVICE, TYPE_VISIT_COUNT]; // enum بسته ✅
private int $priority; // ✅
private bool $combinable; // ✅
private ?int $validFrom; // ✅
private ?int $validTo; // ✅
```
فقط دستهٔ «قیمت» را پوشش می‌دهد، نسخه‌بندی ندارد، و محیط آزمایش ندارد.
## دامنه
**هست:**
- `Policy` — قانون با دسته، شرط، نتیجه، اولویت، بازهٔ اعتبار، نسخه
- `PolicyVersionLog` — تاریخچهٔ نسخه‌ها (قانون ویرایش نمی‌شود، نسخه می‌گیرد)
- `ConditionEvaluator` — ارزیابی شرط از فهرست بسته
- شش موتور دسته، هر کدام یک کلاس
- حل تناقض: اولویت → اختصاصی‌بودن → قدمت
- ترکیب اثرها طبق جدول مستند بند ۸
- ثبت قانون‌های اعمال‌شده روی نوبت (`applied_policy_ids` تسک ۰۸)
**نیست:** فرم ساخت قانون و محیط آزمایش (تسک ۱۰ — همان تسک UI است).
`DiscountRule` موجود **مهاجرت نمی‌کند**؛ کنار `Policy` دستهٔ `pricing` زندگی می‌کند
(دلیل در implementation_notes).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/v1/policies` | لیست با فیلتر دسته/وضعیت |
| POST | `/api/v1/policy` | ساخت (نسخهٔ ۱) |
| GET | `/api/v1/policy/{uuid}` | جزئیات + تاریخچهٔ نسخه |
| POST | `/api/v1/policy/{uuid}/version` | نسخهٔ جدید با تاریخ شروع |
| POST | `/api/v1/policy/{uuid}/activate` \| `/deactivate` | |
| GET | `/api/v1/policy-schema` | فهرست بستهٔ فیلدها، عملگرها و اثرها per دسته |
`GET /policy-schema` برای تسک ۱۰ حیاتی است: فرم ساخت قانون از همین schema ساخته می‌شود،
نه از کد hard-coded در فرانت.
## معیار پذیرش
- ✅ موفق: قانون «خدمات دسته جراحی به جراح نیاز دارند» (دسته `resource`) →
`POST /appointment-plan/preview` برای سرویسی در آن دسته، یک نیازمندی اضافه دارد.
- ✅ موفق: قانون «حداقل ۷ روز از جلسهٔ قبلی» (دسته `spacing`) → جستجوی وقت برای بیماری که
۳ روز پیش جلسه داشته، روزهای زودتر از روز هفتم را برنمی‌گرداند.
- ✅ موفق: قانون «بیمار زیر ۱۸ سال بدون رضایت والدین نمی‌شود» (دسته `eligibility`) →
`confirm` با `422` و پیام قابل فهم رد می‌شود.
- ✅ موفق: قانون «بیمار VIP ۱۰٪ تخفیف» (دسته `pricing`) → در `price_snapshot_lines` یک
ردیف `discount` با نام قانون ظاهر می‌شود و شناسه+نسخه در `applied_policy_ids` ثبت می‌شود.
- ✅ موفق (**قانون پنجم مستند**): بعد از ثبت نوبت، قانون نسخهٔ ۲ می‌گیرد →
نوبت قبلی همان نسخهٔ ۱ را در `applied_policy_ids` دارد و فاکتورش تغییر نمی‌کند.
- ✅ موفق: دو قانون «حداقل مدت» با مقادیر ۴۵ و ۶۰ دقیقه → **بیشترین** برنده است (۶۰).
- ✅ موفق: دو قانون «اضافه کردن زمان» ۱۰ و ۵ دقیقه → **جمع** می‌شوند (۱۵).
- ✅ موفق: یک قانون «ممنوعیت» → کل عملیات رد می‌شود، حتی اگر ده قانون مجازکننده باشند.
- ❌ خطا: شرط با فیلد خارج از فهرست بسته → `422` با نام فیلد مجاز.
- ❌ خطا: نتیجه با نوع اثر ناسازگار با دسته → `422`.
- ⚠️ مرزی: دو قانون با اولویت مساوی → اختصاصی‌تر (شعبه بر محیط، سرویس بر دسته) برنده.
- ⚠️ مرزی: باز هم مساوی → قانون **قدیمی‌تر** برنده (مستند بند ۸).
- ⚠️ مرزی: قانون با `valid_from` آینده → در محاسبهٔ امروز اعمال نمی‌شود.
- ⚠️ مرزی: هیچ قانونی وجود ندارد → همه‌چیز مثل قبل کار می‌کند (تست سازگاری).
- ⚠️ مرزی: قانون `spacing` باید **قابل تبدیل به کوئری** باشد؛ ارزیابی per-slot در PHP
برای ۹۰ روز غیرقابل قبول است (مستند بند ۸ صریح).
## خروجی
- `src/Policy/`
- `docs/api/policy.md` + سند معماری `docs/architecture/policy-engine.md`
- پر کردن همهٔ قلاب‌های no-op تسک‌های ۰۴ تا ۰۸
@@ -0,0 +1,161 @@
# معماری — تسک ۱۰
## ساختار فایل
```
src/Policy/
├── Simulation/
│ ├── PolicySimulator.php # اجرای dry-run
│ ├── SimulationSampler.php # انتخاب نمونهٔ نوبت‌های واقعی
│ └── Dto/{SimulationReport, SimulationRow}.php
├── Entity/PolicySimulationRun.php
├── Template/PolicyTemplateRegistry.php
└── Controller/PolicySimulationController.php
assets/admin/
├── pages/PoliciesPage.tsx
├── pages/PolicyFormPage.tsx
├── pages/PolicySimulationPage.tsx
└── components/PolicyConditionBuilder.tsx # از schema ساخته می‌شود
```
## `PolicySimulator` — dry-run واقعی
```php
public function simulate(Policy $policy, int $sampleSize = 50): SimulationReport
{
$sample = $this->sampler->recentAppointments($policy, $sampleSize);
$rows = [];
foreach ($sample as $appointment) {
$ctx = PolicyContext::fromAppointment($appointment);
$before = $this->snapshotOf($appointment); // وضعیت واقعی ثبت‌شده
$after = $this->engineFor($policy->getCategory())
->evaluateIsolated($policy, $ctx); // فقط همین قانون
if ($before->equals($after)) continue; // بی‌تأثیر
$rows[] = new SimulationRow($appointment, $before, $after);
}
return new SimulationReport($policy, count($sample), $rows);
}
```
### تضمین «هیچ چیزی ثبت نمی‌شود»
سه لایه، نه یکی:
1. `evaluateIsolated()` روی DTO کار می‌کند، نه entity — هیچ entity ای تغییر نمی‌کند
2. کل شبیه‌سازی داخل تراکنشی اجرا می‌شود که **همیشه** rollback می‌شود:
```php
$this->em->beginTransaction();
try { $report = $this->runInternal($policy, $size); }
finally { $this->em->rollback(); $this->em->clear(); }
```
3. تست تعداد ردیف‌های جدول‌های حساس را قبل و بعد مقایسه می‌کند
لایهٔ ۲ حتی اگر کسی روزی سهواً یک `flush` اضافه کرد، جلویش را می‌گیرد. `em->clear()`
اجباری است، وگرنه entity های کثیف در identity map می‌مانند و درخواست بعدی همان request
آن‌ها را flush می‌کند.
`PolicySimulationRun` **بعد از** rollback و در یک تراکنش جدا ثبت می‌شود.
## `evaluateIsolated` — چرا فقط همین قانون
شبیه‌سازی باید بگوید «**این** قانون چه تغییری می‌دهد»، نه «نتیجهٔ نهایی با همهٔ قوانین چه
می‌شود». دومی مفید است ولی سؤال کاربر نیست: کاربر دارد یک قانون می‌سازد و می‌خواهد اثر
همان را ببیند.
هر یک از شش موتور تسک ۰۹ باید یک متد `evaluateIsolated(Policy, PolicyContext)` داشته باشد
که بدون `PolicyResolver` و بدون `Combiner` فقط همان قانون را ارزیابی کند.
## `PolicyTemplateRegistry` — الگوهای آماده
```php
private const TEMPLATES = [
'min_days_between_sessions' => [
'title' => 'حداقل فاصله بین جلسات',
'category' => 'spacing',
'inputs' => [
['key' => 'service_uuid', 'type' => 'service_select', 'label' => 'سرویس'],
['key' => 'days', 'type' => 'int', 'label' => 'حداقل روز', 'min' => 1, 'max' => 365],
],
'build' => /* callable که conditions و effects را می‌سازد */,
],
'surgery_needs_surgeon' => […], // resource
'vip_discount' => […], // pricing
'minor_needs_consent' => […], // eligibility
'complex_min_duration' => […], // timing
];
```
کاربر ۹۰٪ موارد از الگو استفاده می‌کند و هرگز شرط خام نمی‌نویسد. حالت پیشرفته
(`PolicyConditionBuilder`) برای بقیه است.
## فرم از schema، نه hard-code
```tsx
// PolicyConditionBuilder.tsx
const { data: schema } = useQuery({ queryKey: ['policy-schema'], queryFn: … });
// هر شرط: [فیلد ▾] [عملگر ▾] [مقدار]
// - فیلدها از schema.fields
// - عملگرهای مجاز از schema.fields[field].ops ← فیلتر می‌شود، نه همه
// - نوع ورودی مقدار از schema.fields[field].type
// int → عدد · array → SearchableSelect چندانتخابی · enum → SearchableSelect
```
اگر عملگرها را فیلتر نکنی، کاربر `patient.tags > 5` می‌سازد و `422` می‌گیرد بدون فهمیدن چرا.
هرگز `<select>` بومی — `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()` مهم است: آزمایش نسخهٔ ۱ اجازهٔ فعال‌سازی
نسخهٔ ۲ را نمی‌دهد.
@@ -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 |
@@ -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` یک بخش «چرا آزمایش اجباری است»
اضافه کن با ارجاع به ریسک دوم مستند بند ۱۷.
@@ -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` به‌روزرسانی
@@ -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 — و همین به کاربر ثابت می‌کند
عدد از کجا آمده
@@ -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` کنار توضیح کیف پول اضافه کن.
@@ -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)» و «کیف پول (سراسری + انتساب)» به‌روز کن —
این دو شبیه‌اند و اشتباه گرفتنشان نشتی مالی می‌سازد.
@@ -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`
@@ -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`
- نوار پیشرفت باید فاصلهٔ واقعی بین جلسات را هم نشان دهد (۲۸ · ۳۱ · ۲۶ روز) — کلینیک از
همان می‌فهمد بیمار منظم است یا نه
@@ -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` |
@@ -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` نوبت‌ها را لغو نمی‌کند.
@@ -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`
@@ -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 (تسک ۱۴)
کارت بیمار: «دورهٔ لیزر فول‌بادی تکمیل شد — ۸ جلسه در ۲۳۱ روز»
[شروع دورهٔ نگهدارنده]
```
@@ -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` موجود می‌ماند (نوبت رزرو روزی) — مفهوم متفاوتی است و
ادغامشان با لیست انتظار خارج از دامنهٔ این تسک است
@@ -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 |
@@ -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 و دلیلش.
@@ -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`
@@ -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`.
@@ -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 |
@@ -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 مصرف‌کننده
@@ -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`