feat: implement cancellation policy, no-show tracking, and waitlist management

- Add implementation notes for cancellation and waitlist features.
- Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting.
- Establish architecture for domain events and outbox pattern to ensure reliable event publishing.
- Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports.
- Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
This commit is contained in:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -0,0 +1,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 خطا نمی‌دهد.