# رویدادهای دامنه سیستم وقتی چیزی اتفاق می‌افتد یک **رویداد** ثبت می‌کند تا بقیه (پیامک، حسابداری، گزارش) واکنش نشان بدهند — بدون اینکه دامنهٔ نوبت‌دهی از وجودشان خبر داشته باشد. مرجع: بند ۱۶ مستند طراحی. اندپوینت عیب‌یابی: [../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` همان شناسه‌ای است که با آن تکراری را می‌شناسد. تکرار مسئلهٔ مصرف‌کننده است، نه رویداد: تضمین «دقیقاً یک بار» در صف توزیع‌شده وجود ندارد.