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

7.4 KiB
Raw Blame History

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   # ۱۴ تست