Files
clinicpro/docs/api/cancellation.md
T
hamedandClaude Opus 5 4049daf071 feat: close the last four domain events, and the panel paths they describe
Every one of the fourteen named events now has an emit point. The four that
were missing all sat on paths owned by earlier tasks:

- AppointmentCompleted fires from both status-change routes, after the row is
  saved. A rejected transition or a version conflict leaves no event; otherwise
  the completed count runs ahead of the appointments themselves.
- AppointmentRescheduled is a third event, not a replacement. A rebook is a
  confirm plus a cancel, and a consumer that only hears the cancel messages a
  patient who still has an appointment.
- ResourceBlocked / ResourceReleased are a pair. Capacity coming back has to be
  as audible as capacity going away, or the resource reads as permanently taken.

Publishing is now on the scheduler rather than an unregistered command: the
logic moved out of PublishDomainEventsCommand into OutboxPublisher so the
recurring message and the manual command share it, and the existing
worker-scheduler container consumes it. The scheduler message carries no data
on purpose — what to publish is read from the table, so an event recorded
between two ticks is not skipped. DomainEventMessage routes to async, since a
slow consumer was otherwise slowing the drain itself and its failure marked a
row failed that had in fact been delivered.

Panel work that these paths made reachable:

- Cancelling from the appointment page now goes through the policy-aware
  endpoint and shows the penalty preview before the confirm, so the operator
  does not discover the patient's penalty after the fact. The cancellation
  service writes the timeline entry itself and accepts a reason, which that
  path previously dropped on the floor.
- Rescheduling reuses the booking page under ?rebook=<uuid> — the search and
  hold steps are identical and only the final step differs. The doctor picker
  is hidden there: a reschedule is not an invitation to change doctors.
- A new GET /appointment/{uuid}/segments exposes the recorded plan. An empty
  list is not an error, it means the appointment is slot-based, and that is
  exactly what gates the resource-mode reschedule button.

AppointmentInvoiceCard no longer crashes the whole detail page when an older
invoice has no discount breakdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:27:55 +03:30

182 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` معنا دارد |
---
## 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 # ۱۴ تست
```