# رویدادهای دامنه

سیستم وقتی چیزی اتفاق می‌افتد یک **رویداد** ثبت می‌کند تا بقیه (پیامک، حسابداری، گزارش)
واکنش نشان بدهند — بدون اینکه دامنهٔ نوبت‌دهی از وجودشان خبر داشته باشد.

مرجع: بند ۱۶ مستند طراحی. اندپوینت عیب‌یابی: [../api/reports.md](../api/reports.md)

---

## سه قاعدهٔ غیرقابل‌مذاکره

۱. **payload فقط uuid و اسکالر است.** هیچ entity ای در رویداد نیست؛ مصرف‌کننده خودش
   واکشی می‌کند. entity در پیام async یعنی سریال‌سازی، detach شدن، و دادهٔ کهنه.
   `DomainEventLog` مقادیر غیراسکالر را **حذف** می‌کند، نه اینکه سریال‌شان کند.
۲. **انتشار بعد از commit.** ردیف رویداد در همان تراکنشی نوشته می‌شود که تغییر را
   انجام می‌دهد؛ انتشار جداست.
۳. **هر رویداد محیط دارد.** بدون `entity_type`/`entity_id`، پیامک کلینیک الف به شمارهٔ
   کلینیک ب می‌رود.

---

## چرا صندوق خروجی (outbox)

بدون آن دو حالت شکست ممکن است:

| حالت | نتیجه |
|---|---|
| انتشار پیش از commit، بعد rollback | پیامک رفته، نوبتی وجود ندارد |
| commit موفق، انتشار شکست خورد (Redis down) | نوبت هست، هیچ‌کس مطلع نشد |

با outbox ردیف رویداد **در همان تراکنش** ثبت می‌شود و بعداً منتشر می‌شود. حداکثر **تأخیر**
داریم، هرگز گم‌شدن.

### چه چیزی آن را در تولید اجرا می‌کند

انتشار روی زمان‌بند نشسته است، نه cron جدا: `PublishDomainEventsMessage` هر دقیقه در
`src/Schedule.php` صادر می‌شود و `worker-scheduler` همان کانتینر همیشگی مصرفش می‌کند.
سرویس تازه‌ای لازم نیست.

| لایه | چه می‌کند |
|---|---|
| `OutboxPublisher::publish()` | منطق واقعی؛ ردیف‌های منتشرنشده را از جدول می‌خواند |
| `PublishDomainEventsHandler` | هر تیک زمان‌بند صدایش می‌زند |
| `app:events:publish --limit=100` | اجرای دستی، برای وقتی که صف عقب افتاده |

پیامِ زمان‌بند عمداً **بی‌داده** است: «چه چیزی منتشر شود» از جدول خوانده می‌شود نه از پیام،
وگرنه رویدادی که بین دو تیک ثبت شده جا می‌ماند. و چون `Schedule` روی `stateful` است، تیکِ
ازدست‌رفته بعد از ری‌استارت جبران می‌شود — یک اجرا کافی است تا همهٔ عقب‌ماندگی برود.

`DomainEventMessage` به `async` می‌رود، نه `sync`: یک مصرف‌کنندهٔ کند وگرنه خودِ تخلیهٔ صندوق
را کند می‌کرد، و شکستش ردیفی را «ناموفق» علامت می‌زد که در واقع تحویل شده بود.

پاکسازی (`app:events:prune`) روی زمان‌بند **نیست** و باید cron جدا باشد؛ حذف داده تصمیمی است
که باید صریح و با پنجرهٔ نگه‌داریِ انتخاب‌شده اجرا شود، نه اثر جانبیِ یک worker همیشه‌روشن.

`DomainEventPublisher::record()` عمداً flush نمی‌کند — همان چیزی که تضمین می‌کند رویداد
با تراکنشِ برگشته از بین برود. جایی که فراخوان تراکنش باز ندارد، `recordAndFlush()` هست.

### ردیف مرده

بعد از پنج تلاش ناموفق، ردیف با `last_error` **باقی می‌ماند** و دیگر برداشته نمی‌شود.
حذف خاموش یعنی رویداد گم‌شدهٔ بی‌رد؛ ادمین باید بتواند ببیند چه چیزی منتشر نشد و چرا.

---

## `AppointmentEvent` یا `DomainEventLog`؟

هر دو ماندند و کارشان یکی نیست:

| | `AppointmentEvent` | `DomainEventLog` |
|---|---|---|
| چیست | تاریخچهٔ تغییر وضعیت **یک نوبت** | اعلان تغییر به بیرونِ دامنه |
| مخاطب | خودِ صفحهٔ نوبت | پیامک، حسابداری، گزارش |
| دامنه | فقط نوبت | همهٔ دامنه‌ها |
| مصرف | خوانده می‌شود | منتشر می‌شود |

ادغامشان یعنی تاریخچهٔ نوبت به صف پیام تبدیل شود، یا صف پیام پر از جزئیاتی که فقط یک
صفحه لازم دارد.

---

## فهرست رویدادها

```
HoldCreated              AppointmentBooked
AppointmentCancelled     AppointmentRescheduled
PatientNoShow            AppointmentCompleted
ResourceBlocked          ResourceReleased
CourseStarted            CourseSessionCompleted
CourseCompleted          PackagePurchased
CreditConsumed           CreditRefunded
```

فهرست **بسته** است (`DomainEvents::ALL`) و نام ناشناخته استثنا می‌دهد: مصرف‌کننده روی
رشته شرط می‌گذارد، و تایپوی یک حرفی یعنی رویدادی که هیچ‌کس نمی‌شنود و هیچ خطایی هم
نمی‌دهد.

### وضعیت فعلی انتشار

| رویداد | کجا ثبت می‌شود |
|---|---|
| `HoldCreated` | `HoldService::hold()` — بعد از گرفتن همهٔ منابع |
| `AppointmentBooked` | `BookingService::confirm()` |
| `AppointmentCancelled` | `BookingService::cancel()` |
| `PatientNoShow` | `NoShowService::record()` |
| `CourseStarted` | `CourseStarter::start()` |
| `CourseSessionCompleted` · `CourseCompleted` | `CourseSessionLinker::complete()` |
| `PackagePurchased` | `PackageSalesService::sell()` |
| `CreditConsumed` · `CreditRefunded` | `CreditLedgerService` |
| `AppointmentRescheduled` | `BookingController::rebook()` |
| `AppointmentCompleted` | `AppointmentController` — هر دو مسیر تغییر وضعیت |
| `ResourceBlocked` · `ResourceReleased` | `ResourceBlockController` |

هر چهارده رویداد نقطهٔ ثبت دارند.

دو نکته که موقع خواندن این جدول به‌درد می‌خورند:

- **`AppointmentRescheduled` سومین رویداد است، نه جایگزین.** جابه‌جایی از درون یک `confirm`
  و یک `cancel` است و هر کدام رویداد خودشان را می‌گذارند. مصرف‌کننده‌ای که فقط
  `AppointmentCancelled` را بشنود، برای بیماری که هنوز نوبت دارد پیام لغو می‌فرستد؛ این
  رویداد همان چیزی است که آن دو را به هم وصل می‌کند.
- **`AppointmentCompleted` بعد از ذخیرهٔ موفق ثبت می‌شود، نه هنگام درخواست.** انتقالی که
  `canTransitionTo` رد می‌کند یا `saveWithLock` روی تداخل نسخه می‌شکند، هیچ رویدادی
  نمی‌گذارد — وگرنه شمارِ «انجام‌شده» از خودِ نوبت‌ها جلو می‌زند.

---

## مصرف‌کنندهٔ تازه

`DomainEventHandler` فقط لاگ می‌کند و **نباید بیشتر بکند**؛ درزِ اتصال است. مصرف‌کنندهٔ
تازه کنارش ثبت می‌شود و باید **idempotent** باشد: messenger ممکن است پیام را دوباره
تحویل بدهد، و `DomainEventMessage::$uuid` همان شناسه‌ای است که با آن تکراری را می‌شناسد.

تکرار مسئلهٔ مصرف‌کننده است، نه رویداد: تضمین «دقیقاً یک بار» در صف توزیع‌شده وجود ندارد.
