Files
clinicpro/docs/architecture/domain-events.md
T
hamedandClaude Opus 5 3c43955800 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>
2026-07-31 12:27:54 +03:30

107 lines
5.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# رویدادهای دامنه
سیستم وقتی چیزی اتفاق می‌افتد یک **رویداد** ثبت می‌کند تا بقیه (پیامک، حسابداری، گزارش)
واکنش نشان بدهند — بدون اینکه دامنهٔ نوبت‌دهی از وجودشان خبر داشته باشد.
مرجع: بند ۱۶ مستند طراحی. اندپوینت عیب‌یابی: [../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` همان شناسه‌ای است که با آن تکراری را می‌شناسد.
تکرار مسئلهٔ مصرف‌کننده است، نه رویداد: تضمین «دقیقاً یک بار» در صف توزیع‌شده وجود ندارد.