# Cancellation — سیاست لغو، جریمه و عدم حضور اندپوینت‌های `src/Cancellation/*`. مستند بند ۱۱: «هر کلینیک تنظیم می‌کند تا چند ساعت قبل لغو رایگان است، جریمه چقدر است، بیعانه برمی‌گردد یا نه، و بعد از چند بار عدم حضور بیمار پرریسک علامت بخورد.» همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به محیط جاری محدودند (`404` برای محیط دیگر). --- ## دو قاعده‌ای که شکستنشان گران است ۱. **لغو توسط کلینیک هرگز جریمه ندارد.** این شرط اولین خط محاسبه است، نه جایی وسط آن — اگر بعد از بررسی پنجرهٔ زمانی می‌آمد، یک refactor می‌توانست ترتیب را عوض کند و کلینیک از بیمار برای لغو خودش جریمه بگیرد. ۲. **جریمه هرگز از مبلغ پرداختی بیشتر نمی‌شود.** جریمهٔ بیشتر یعنی بدهی، و بدهی مسئلهٔ صورتحساب است نه لغو. برای نوبت نقدی (پرداختی صفر) جریمه صفر می‌شود و پاسخ توضیحش را در `notes` می‌دهد. **پیش‌فرض بدون جریمه است.** اگر پیش‌فرض جریمه‌دار بود، لحظهٔ deploy همهٔ بیماران با نوبت نزدیک مشمول جریمه می‌شدند و کلینیک خبر نداشت. فعال‌کردن جریمه یک تصمیم کسب‌وکاری صریح است. --- ## GET `/api/v1/cancellation-policy` ```json { "success": true, "data": { "default": { "uuid": "…", "service_uuid": null, "free_window_hours": 24, "penalty_mode": "percent", "penalty_value": 50, "deposit_refundable": false, "credit_refundable": true, "no_show_threshold": 3, "risk_tag_uuid": null, "active": true, "created_at": 1785486000 }, "overrides": [] } } ``` ## PUT `/api/v1/cancellation-policy` · PUT `/api/v1/service-item/{uuid}/cancellation-policy` | Field | Type | Description | |---|---|---| | `free_window_hours` | int | تا چند ساعت قبل، لغو رایگان است | | `penalty_mode` | string | `none` \| `percent` \| `fixed` | | `penalty_value` | int | درصد ۰..۱۰۰ یا مبلغ ریالی | | `deposit_refundable` | bool | پس از پنجرهٔ رایگان | | `credit_refundable` | bool | اعتبار پکیج (تسک ۱۱) | | `no_show_threshold` | int | بعد از چند بار عدم حضور، برچسب پرریسک | | `risk_tag_uuid` | string\|null | برچسبی از `tenant_tags` | | `active` | bool | | سیاستِ سرویس و سیاستِ محیط **ترکیب نمی‌شوند**: اگر سرویس سیاست فعال دارد، همان کامل برنده است. «۲۴ ساعت از محیط ولی ۵۰٪ از سرویس» چیزی است که هیچ اپراتوری نمی‌تواند در ذهنش شبیه‌سازی کند. ### Errors | Code | HTTP | Description | |---|---|---| | `ERR_VALIDATION_001` | 422 | درصد بیرون بازهٔ ۰..۱۰۰ یا حالت جریمهٔ ناشناخته | --- ## GET `/api/v1/appointment/{uuid}/cancellation-preview` | Query | Type | Description | |---|---|---| | `by` | string | `doctor` برای لغو از سمت کلینیک؛ پیش‌فرض بیمار | ```json { "success": true, "data": { "penalty_rials": 2000000, "deposit_refundable": false, "credit_refundable": true, "within_free_window": false, "notes": [], "paid_rials": 4000000 } } ``` همان محاسبه‌ای که خودِ لغو انجام می‌دهد — بیمار نباید عددی ببیند که با آنچه کسر می‌شود فرق دارد. --- ## POST `/api/v1/appointment/{uuid}/cancel` ```json { "by": "doctor", "reason": "بیمار تماس گرفت" } ``` `by` اختیاری است؛ نبودنش یعنی لغو از سمت بیمار. `reason` هم اختیاری است و روی تایم‌لاین نوبت (`AppointmentEvent`) می‌نشیند — همان چیزی که اپراتور در صفحهٔ نوبت می‌بیند. بدون آن، لغو از مسیر سیاست هیچ ردی در تاریخچهٔ نوبت نمی‌گذاشت. پنل این اندپوینت را از دکمهٔ «لغو نوبت» صدا می‌زند و **پیش از تأیید** نتیجهٔ `cancellation-preview` را نشان می‌دهد؛ عددی که اپراتور می‌بیند همان است که کسر می‌شود. ### Response `200` ```json { "success": true, "data": { "appointment_uuid": "…", "status": "cancelled_by_user", "released_resources": 3, "waitlist_notified": 2, "penalty_charged": true, "penalty_rials": 1000000, "deposit_refundable": false, "credit_refundable": true, "within_free_window": false, "notes": [] } } ``` ترتیب کارها: اعتبارسنجی → آزادسازی ظرفیت و بازگشت اعتبار → کسر جریمه → اطلاع به لیست انتظار. اگر اطلاع‌رسانی اول بود، ده نفر برای ظرفیتی خبر می‌شدند که هنوز آزاد نشده. **موجودی ناکافی لغو را شکست نمی‌دهد:** `penalty_charged: false` برمی‌گردد ولی نوبت آزاد می‌شود. نوبت نباید گروگان پول بماند. سیاستی که `credit_refundable: false` دارد، اعتبارِ برگشتهٔ پکیج را با یک ردیف `adjustment` منفی پس می‌گیرد — ردیف `refund` حذف نمی‌شود، چون دفتر append-only است. ### Errors | Code | HTTP | Description | |---|---|---| | `ERR_SLOT_TAKEN` | 409 | نوبت قبلاً لغو شده | | `ERR_VALIDATION_001` | 422 | نوبت گذشته — برای گذشته `no_show` یا `completed` معنا دارد | --- ## GET `/api/v1/patient/{uuid}/no-shows` خلاصهٔ عدم حضور یک بیمار — همان چیزی که پروندهٔ او نشان می‌دهد. ```json { "success": true, "data": { "count": 3, "threshold": 3, "window_days": 365, "at_risk": true } } ``` `at_risk` یک **نشانه** است، نه مانع. مسدودسازی کارِ قانون `eligibility` تسک ۰۹ روی همین برچسب است؛ کلینیکی که می‌خواهد بیمار پرریسک را ببیند ولی بیعانه بگیرد، نباید مجبور شود این شمارش را خاموش کند. تست `testATaggedPatientCanStillBook` همین را پین می‌کند. پنل نشان را فقط وقتی می‌آورد که `count > 0` باشد — «۰ غیبت» روی پروندهٔ هر بیمار سالم، اتهام بی‌جاست. **۴۰۴** برای بیمار محیط دیگر. ## POST `/api/v1/appointment/{uuid}/no-show` ```json { "success": true, "data": { "recorded": true, "count": 3, "threshold": 3, "tagged": true } } ``` هر عدم حضور یک **ردیف** است نه یک شمارنده: شمارنده «چه زمانی و کدام نوبت» را از دست می‌دهد و پنجرهٔ ۱۲ ماهه را غیرقابل محاسبه می‌کند. بیماری که سه سال پیش سه بار نیامده، امروز پرریسک نیست. ثبت دوباره روی همان نوبت `recorded: false` می‌دهد و شمارش را بالا نمی‌برد. ⚠️ **برچسب پرریسک مسدود نمی‌کند.** مسدودسازی یک قانون `eligibility` (تسک ۰۹) روی همین برچسب است. تفکیکش عمدی است: کلینیکی که می‌خواهد بیمار پرریسک را ببیند ولی بیعانه بگیرد، نباید مجبور شود برچسب را خاموش کند. --- ## طبقه‌بندی محیط | جدول | وضعیت | |---|---| | `cancellation_policies` · `no_show_records` | جفت محیط | تراکنش جریمه در کیف پول با `entity_type`/`entity_id` نوبت ثبت می‌شود، وگرنه کلینیک الف جریمهٔ ثبت‌شده در کلینیک ب را می‌دید. ## تست‌ها ```bash ddev exec php bin/phpunit tests/Cancellation # ۱۷ تست ```