Writing the query-count test that task 14 owed showed the growth was real: one resource cost 10 queries, six cost 33 — about five per resource, because the available-minutes figure walked each resource's calendar on its own. Holidays, tenant overrides and branch hours are identical for every resource in a report, so they now load once outside the loop; shifts and exceptions load for all resources in one query each. The batched path is a new method rather than a change to rawAvailability, which the booking engine also calls. The test pins the shape of the growth, not an exact count. Also landed: - app:segment:seed-templates with beauty, dental and physio presets. Building four segments and their requirements by hand is the first thing a new clinic must do and the most tedious; this gives them something to edit instead of an empty page. It refuses to touch a service that already has segments unless --force, and it will not invent resource types the tenant never defined. - book-all is all-or-nothing, proven rather than asserted: with a calendar open one day a week and a 1-2 day protocol gap, session one finds a slot and session two cannot, and every session must come back planned. - credit_refundable: false takes the credit back with a negative adjustment and deletes nothing — the ledger stays append-only. - the segments editor has frontend tests, including that it sends back what the user sees and renders read-only without the permission. useBranches now returns [] for a non-array payload instead of throwing "branches.map is not a function" and taking the page down with it. BookingLocationsScanTest built a Clinic around a Doctor loaded from a different manager, which Doctrine treats as a new entity; it flushed fine most runs and failed on cascade in others. It now loads the doctor from the same manager. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8.2 KiB
Cancellation — سیاست لغو، جریمه و عدم حضور
اندپوینتهای src/Cancellation/*. مستند بند ۱۱: «هر کلینیک تنظیم میکند تا چند ساعت قبل
لغو رایگان است، جریمه چقدر است، بیعانه برمیگردد یا نه، و بعد از چند بار عدم حضور بیمار
پرریسک علامت بخورد.»
همهٔ مسیرها IS_AUTHENTICATED_FULLY میخواهند و به محیط جاری محدودند (404 برای محیط دیگر).
دو قاعدهای که شکستنشان گران است
۱. لغو توسط کلینیک هرگز جریمه ندارد. این شرط اولین خط محاسبه است، نه جایی وسط آن —
اگر بعد از بررسی پنجرهٔ زمانی میآمد، یک refactor میتوانست ترتیب را عوض کند و کلینیک
از بیمار برای لغو خودش جریمه بگیرد.
۲. جریمه هرگز از مبلغ پرداختی بیشتر نمیشود. جریمهٔ بیشتر یعنی بدهی، و بدهی مسئلهٔ
صورتحساب است نه لغو. برای نوبت نقدی (پرداختی صفر) جریمه صفر میشود و پاسخ توضیحش را
در notes میدهد.
پیشفرض بدون جریمه است. اگر پیشفرض جریمهدار بود، لحظهٔ deploy همهٔ بیماران با نوبت نزدیک مشمول جریمه میشدند و کلینیک خبر نداشت. فعالکردن جریمه یک تصمیم کسبوکاری صریح است.
GET /api/v1/cancellation-policy
{
"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 برای لغو از سمت کلینیک؛ پیشفرض بیمار |
{
"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
{ "by": "doctor", "reason": "بیمار تماس گرفت" }
by اختیاری است؛ نبودنش یعنی لغو از سمت بیمار. reason هم اختیاری است و روی تایملاین
نوبت (AppointmentEvent) مینشیند — همان چیزی که اپراتور در صفحهٔ نوبت میبیند. بدون آن،
لغو از مسیر سیاست هیچ ردی در تاریخچهٔ نوبت نمیگذاشت.
پنل این اندپوینت را از دکمهٔ «لغو نوبت» صدا میزند و پیش از تأیید نتیجهٔ
cancellation-preview را نشان میدهد؛ عددی که اپراتور میبیند همان است که کسر میشود.
Response 200
{
"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
خلاصهٔ عدم حضور یک بیمار — همان چیزی که پروندهٔ او نشان میدهد.
{ "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
{
"success": true,
"data": { "recorded": true, "count": 3, "threshold": 3, "tagged": true }
}
هر عدم حضور یک ردیف است نه یک شمارنده: شمارنده «چه زمانی و کدام نوبت» را از دست میدهد و پنجرهٔ ۱۲ ماهه را غیرقابل محاسبه میکند. بیماری که سه سال پیش سه بار نیامده، امروز پرریسک نیست.
ثبت دوباره روی همان نوبت recorded: false میدهد و شمارش را بالا نمیبرد.
⚠️ برچسب پرریسک مسدود نمیکند. مسدودسازی یک قانون eligibility (تسک ۰۹) روی همین
برچسب است. تفکیکش عمدی است: کلینیکی که میخواهد بیمار پرریسک را ببیند ولی بیعانه بگیرد،
نباید مجبور شود برچسب را خاموش کند.
طبقهبندی محیط
| جدول | وضعیت |
|---|---|
cancellation_policies · no_show_records |
جفت محیط |
تراکنش جریمه در کیف پول با entity_type/entity_id نوبت ثبت میشود، وگرنه کلینیک الف
جریمهٔ ثبتشده در کلینیک ب را میدید.
تستها
ddev exec php bin/phpunit tests/Cancellation # ۱۷ تست