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:
@@ -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 قانون
|
||||
│
|
||||
۱۱ پکیج و دفتر اعتبار ── ۱۲ دوره درمان ── ۱۳ لغو/عدمحضور/لیست انتظار
|
||||
│
|
||||
۱۴ رویدادها و گزارش بهرهوری
|
||||
```
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user