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