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>
199 lines
8.2 KiB
Markdown
199 lines
8.2 KiB
Markdown
# 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 # ۱۷ تست
|
||
```
|