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>
182 lines
7.4 KiB
Markdown
182 lines
7.4 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` معنا دارد |
|
||
|
||
---
|
||
|
||
## 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 # ۱۴ تست
|
||
```
|