feat(events): domain event outbox and the two reports that close the loop
Tasks 07 through 13 each changed something the rest of the system might want to know about, with no contract for saying so. And task 05 shipped a powerful segment editor with no feedback on whether a clinic defined its segments right. Events - A closed list of names, because a consumer branches on the string and a one-letter typo would produce an event nobody hears and no error either - Payloads carry uuids and scalars only; non-scalars are dropped, not serialised, so a consumer always fetches fresh rather than reading a stale detached entity - record() deliberately does not flush: the event row commits with the change it describes, so a rolled-back transaction leaves no event behind. A test pins exactly that - app:events:publish drains the outbox; five failed attempts park a row with its error rather than deleting it, because a silently dropped event is a loss with no trace. app:events:prune only ever removes published rows Reports - Resource utilisation separates available, occupied and active minutes. The gap between occupied and active is what exposes a bad segment definition, and available is multiplied by capacity so a three-chair room does not read as permanently over 100% - A resource with no calendar reports utilization: null, not zero — dividing by zero means something different from being idle - Plan accuracy compares planned against actual duration per service and flags both directions: running short wastes capacity that could have been sold. Its row links straight to editing that service's segments, because a report with no route to a fix does not get read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
# رویدادهای دامنه
|
||||
|
||||
سیستم وقتی چیزی اتفاق میافتد یک **رویداد** ثبت میکند تا بقیه (پیامک، حسابداری، گزارش)
|
||||
واکنش نشان بدهند — بدون اینکه دامنهٔ نوبتدهی از وجودشان خبر داشته باشد.
|
||||
|
||||
مرجع: بند ۱۶ مستند طراحی. اندپوینت عیبیابی: [../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 ردیف رویداد **در همان تراکنش** ثبت میشود و یک worker بعداً منتشرش میکند:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:events:publish --limit=100
|
||||
```
|
||||
|
||||
حداکثر **تأخیر** داریم، هرگز گمشدن.
|
||||
|
||||
`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`، `AppointmentCompleted`، `ResourceBlocked` و `ResourceReleased`
|
||||
هنوز نقطهٔ ثبت ندارند: مسیرهایشان (جابهجایی نوبت، تکمیل دستی، بلوک منبع) از تسکهای
|
||||
قبلیاند و دستزدن به آنها بیرون از دامنهٔ این تسک بود.
|
||||
|
||||
---
|
||||
|
||||
## مصرفکنندهٔ تازه
|
||||
|
||||
`DomainEventHandler` فقط لاگ میکند و **نباید بیشتر بکند**؛ درزِ اتصال است. مصرفکنندهٔ
|
||||
تازه کنارش ثبت میشود و باید **idempotent** باشد: messenger ممکن است پیام را دوباره
|
||||
تحویل بدهد، و `DomainEventMessage::$uuid` همان شناسهای است که با آن تکراری را میشناسد.
|
||||
|
||||
تکرار مسئلهٔ مصرفکننده است، نه رویداد: تضمین «دقیقاً یک بار» در صف توزیعشده وجود ندارد.
|
||||
Reference in New Issue
Block a user