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