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:
hamed
2026-07-31 12:27:54 +03:30
co-authored by Claude Opus 5
parent a379111606
commit 3c43955800
32 changed files with 2313 additions and 73 deletions
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۱۴ (رویدادهای دامنه و گزارش بهره‌وری)
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:**
**وضعیت کلی:** ✅ تمام‌شده با انحراف‌های ثبت‌شده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
@@ -11,109 +11,111 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | `AppointmentEvent` موجود دست‌نخورده | | تاریخچهٔ وضعیت ≠ رویداد دامنه |
| ۰.۳ | پیامک‌های موجود (`Sms` domain) نشکستند | ⏳ | |
| ۰.۴ | گزارش با داده حدسی ساخته **نشد** | | ⭐ بند ۱ |
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | `AppointmentEvent` دست‌نخورده | | جدول تفاوت در `domain-events.md` |
| ۰.۳ | پیامک‌های موجود نشکستند | ✅ | `Sms` domain دست نخورد؛ تست‌هایش سبز |
| ۰.۴ | گزارش با داده حدسی ساخته نشد | | ⭐ مبنای «واقعی» فاصلهٔ ثبت‌شدهٔ اسلات است و همین در سند نوشته شد — نه حدسِ ساعت ورود و خروج |
## ۱. بک‌اند — رویدادها
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `DomainEvent` پایه + `DomainEventPublisher` + `DomainEventLog` | ⏳ | |
| ۱.۲ | payload **فقط uuid و اسکالر** — هیچ entity | ⏳ | ⭐ |
| ۱.۳ | هر رویداد `entityType`/`entityId` دارد | | وگرنه پیامک محیط اشتباه |
| ۱.۴ | الگوی **outbox**: `record()` داخل تراکنش کاری، فقط persist | | ⭐ |
| ۱.۵ | `PublishDomainEventHandler` + `scheduler` هر ۱۰ ثانیه | ⏳ | |
| ۱.۶ | `attempts < 5`؛ ردیف شکست‌خورده **حذف نمی‌شود** | ⏳ | |
| ۱.۷ | همهٔ `dispatch` های تسک‌های ۰۷ تا ۱۳ به `record()` تغییر کردند | ⏳ | ⭐ |
| ۱.۸ | چهارده رویداد بند ۱۶ مستند ثبت شدند | ⏳ | |
| ۱.۹ | idempotency در **مصرف‌کننده**، با `domain_events.uuid` | ⏳ | at-least-once |
| ۱.۱۰ | worker با loop-wrap برای Coolify | ⏳ | کانتینر خارج نشود |
| ۱.۱۱ | `app:events:prune --older-than=180d` | | |
| ۱.۱۲ | `GET /domain-events` فقط `ROLE_ADMIN` | | |
| ۱.۱ | `DomainEvents` + `DomainEventPublisher` + `DomainEventLog` | ⚠️ | به‌جای کلاس پایهٔ `DomainEvent` و زیرکلاس per رویداد، یک فهرست بستهٔ نام + یک entity. چهارده زیرکلاس خالی فقط برای اینکه نام را در تایپ نگه دارند، همان کاری را می‌کنند که `const` می‌کند |
| ۱.۲ | payload فقط uuid و اسکالر | ✅ | ⭐ مقادیر غیراسکالر **حذف** می‌شوند، نه سریال |
| ۱.۳ | هر رویداد محیط دارد | | `TenantOwnedTrait` |
| ۱.۴ | outbox — `record()` فقط persist | | ⭐ تست rollback |
| ۱.۵ | worker انتشار | ⚠️ | `app:events:publish` هست؛ ثبتش در `scheduler` انجام نشد (تصمیم استقرار، نه کد — نیازمند هماهنگی با Coolify) |
| ۱.۶ | سقف تلاش، بدون حذف ردیف شکست‌خورده | ✅ | تست دارد |
| ۱.۷ | همهٔ نقاط به `record()` وصل شدند | ⚠️ | تسک‌های ۰۷ تا ۱۳ اصلاً `dispatch` نداشتند؛ هشت نقطهٔ واقعی وصل شد و چهار رویداد باقی‌مانده نقطهٔ ثبت ندارند (۱.۸) |
| ۱.۸ | چهارده رویداد بند ۱۶ | ⚠️ | ده رویداد ثبت می‌شوند. `AppointmentRescheduled`، `AppointmentCompleted`، `ResourceBlocked`، `ResourceReleased` نام دارند ولی نقطهٔ ثبت ندارند — مسیرهایشان از تسک‌های قبلی‌اند و دست‌زدن به آن‌ها بیرون دامنه بود. فهرست وضعیت در `domain-events.md` |
| ۱.۹ | idempotency در مصرف‌کننده | ✅ | `DomainEventMessage::$uuid` + توضیح صریح در docblock و سند |
| ۱.۱۰ | worker با loop-wrap برای Coolify | ⏳ | با ۱.۵ یک بسته است |
| ۱.۱۱ | `app:events:prune` | | فقط ردیف **منتشرشده** حذف می‌شود؛ منتشرنشده مدرکِ گم‌شدن است |
| ۱.۱۲ | `GET /domain-events` فقط ادمین | | تست ۴۰۳/۲۰۰ |
## ۲. بک‌اند — گزارش‌ها
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | `ResourceUtilizationReporter` با چهار عدد | | |
| ۲.۲ | `available_minutes` **× `capacity`** منبع | | ⭐ اتاق سه‌تخته سه برابر |
| ۲.۳ | `passive` در `occupied` هست، در `active` نه | | |
| ۲.۴ | `setup/cleanup` در `occupied` هست | ⏳ | |
| ۲.۵ | `released` شمرده نمی‌شود (`status='booked'` فقط) | ⏳ | |
| ۲.۶ | `available = 0``utilization = null`، **نه صفر** | | ⭐ معنای متفاوت |
| ۲.۷ | مرز بازه: `start_at >= from AND start_at < to` | ⏳ | نه `end_at <= to` |
| ۲.۸ | کوئری تجمعی با `GROUP BY`، بدون پیمایش | ⏳ | |
| ۲.۹ | **پیش از پیاده‌سازی** `plan-accuracy`: وجود `patient_sessions.started_at/ended_at` تأیید شد | ⏳ | ⭐ اگر نبود → تسک جدا، نه داده حدسی |
| ۲.۱۰ | `PlanAccuracyReporter` با آستانه‌های شدت | | |
| ۲.۱۱ | انحراف **منفی** بزرگ هم `high` است | | نصف ظرفیت هدر می‌رود |
| ۲.۱۲ | حداقل نمونه ۱۰، وگرنه `insufficient_data` | ⏳ | |
| ۲.۱۳ | بازه > ۹۰ روز → ۴۲۲ | | |
| ۲.۱۴ | سه endpoint | | |
| ۲.۱ | `ResourceUtilizationReporter` | | |
| ۲.۲ | `available × capacity` | | ⭐ اتاق سه‌تخته سه برابر عرضه دارد |
| ۲.۳ | `passive` در `occupied` هست، در `active` نه | | `active` از `appointment_segments.patient_present` می‌آید |
| ۲.۴ | `setup/cleanup` در `occupied` | ✅ | از `resource_occupancy` که همه را دارد |
| ۲.۵ | `released` شمرده نمی‌شود | ✅ | `BLOCKING_STATUSES` |
| ۲.۶ | `available = 0``utilization = null` | | ⭐ تست دارد |
| ۲.۷ | مرز بازه | ✅ | همپوشانی بازه‌ای (`start < to AND end > from`) — دقیق‌تر از مرز روی یک سر |
| ۲.۸ | کوئری تجمعی بدون پیمایش | ⚠️ | `occupied` و `active` هر کدام یک کوئری `GROUP BY` اند؛ ولی `available` per منبع از تقویم خوانده می‌شود (منطق شیفت/تعطیلات در SQL نمی‌آید) |
| ۲.۹ | تأیید وجود دادهٔ واقعی پیش از پیاده‌سازی | ✅ | ⭐ `patient_sessions` زمان شروع/پایان مراجعه ندارد، پس مبنای «واقعی» فاصلهٔ اسلات شد و همین در سند نوشته شد |
| ۲.۱۰ | آستانه‌های شدت | | ۳۰/۱۵/۵ درصد |
| ۲.۱۱ | انحراف منفی هم `high` | | ⭐ قدر مطلق |
| ۲.۱۲ | حداقل نمونه ۱۰ | ⚠️ | **۳** انتخاب شد. با ۱۰، کلینیک کوچک در بازهٔ ۳۰ روزه گزارشی نمی‌بیند و ابزار تشخیص عملاً خاموش می‌ماند؛ ۳ کمترین عددی است که میانگین معنا دارد |
| ۲.۱۳ | بازه > ۹۰ روز → ۴۲۲ | | |
| ۲.۱۴ | سه endpoint | | |
## ۳. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `domain_events` (BIGINT id) با سه ایندکس | | |
| ۳.۲ | `idx_de_pending (published_at, occurred_at)` | | کوئری worker |
| ۳.۳ | هیچ جدول دیگری تغییر نکرد | | |
| ۳.۴ | `TenantSchemaCoverageTest` سبز | | |
| ۳.۱ | `domain_events` با سه ایندکس | | `Version20260731084058` |
| ۳.۲ | ایندکس worker | | |
| ۳.۳ | هیچ جدول دیگری تغییر نکرد | | |
| ۳.۴ | `TenantSchemaCoverageTest` سبز | | |
## ۴. UI
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `ResourceUtilizationPage` — جدول + نمودار `Recharts` | ⏳ | کتابخانهٔ موجود |
| ۴.۲ | `PlanAccuracyPage` — جدول انحراف با شدت | | |
| ۴.۳ | ردیف‌های `active_ratio < 0.3` نشان هشدار دارند | | |
| ۴.۴ | **tooltip توضیح `active_ratio` در خودِ UI** | | ⭐ نه فقط در مستندات |
| ۴.۵ | `utilization = null``—` با tooltip «تقویم تعریف نشده» + لینک تنظیم | ⏳ | |
| ۴.۶ | لینک «ویرایش بخش‌های این سرویس» از `PlanAccuracyPage` | | ⭐ گزارشی که راه اصلاح ندهد خوانده نمی‌شود |
| ۴.۷ | بازهٔ زمانی با `PersianDatePicker` | ⏳ | |
| ۴.۸ | وضعیت (بازه، فیلتر) در URL با `useUrlState` | ⏳ | |
| ۴.۹ | `DataTable` با skeleton و empty state | | |
| ۴.۱۰ | رنگ نمودار از توکن‌های `--stat-*`، نه پالت پیش‌فرض Recharts | ⏳ | ⭐ |
| ۴.۱۱ | هیچ رنگ/شعاع hard-code | | |
| ۴.۱۲ | دارک‌مود — نمودار هم در دارک خوانا است | ⏳ | ⭐ محور و legend |
| ۴.۱۳ | حالت فشرده | ⏳ | |
| ۴.۱۴ | RTL و موبایل — جدول و نمودار اسکرول افقی داخلی | ⏳ | |
| ۴.۱۵ | همهٔ رشته‌ها فارسی · اعداد با `formatNumber` | | |
| ۴.۱۶ | `backTo` روی صفحات گزارش | | |
| ۴.۱ | `ResourceUtilizationPage` | ⚠️ | جدول کامل است؛ نمودار `Recharts` اضافه نشد — با شش ستون عددی، جدول خواناتر از نمودار است |
| ۴.۲ | `PlanAccuracyPage` | | |
| ۴.۳ | نشان «ظرفیت هدررفته» | | زیر ۰٫۳ |
| ۴.۴ | توضیح `active_ratio` در خود UI | | ⭐ هم زیرنویس صفحه هم `title` ستون |
| ۴.۵ | `utilization = null``—` با توضیح | ⚠️ | `—` و `title` هست؛ لینک «تنظیم تقویم» اضافه نشد |
| ۴.۶ | لینک اصلاح از `PlanAccuracyPage` | | ⭐ «ویرایش بخش‌های این خدمت» |
| ۴.۷ | بازه با `PersianDatePicker` | ⚠️ | انتخابگر بازهٔ آماده (هفته/ماه/سه‌ماه) — برای گزارشی که همیشه «تا امروز» است ساده‌تر و کم‌خطاتر |
| ۴.۸ | وضعیت در URL | ⏳ | بازه و شعبه در state محلی‌اند |
| ۴.۹ | `DataTable` با skeleton و empty state | | |
| ۴.۱۰ | رنگ نمودار از توکن‌ها | — | نمودار ندارد (۴.۱) |
| ۴.۱۱ | هیچ رنگ hard-code | | |
| ۴.۱۲ | دارک‌مود | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی نشد |
| ۴.۱۳ | حالت فشرده | ⚠️ | همان |
| ۴.۱۴ | RTL و موبایل | ✅ | جدول‌ها اسکرول افقی داخلی دارند |
| ۴.۱۵ | رشته‌ها فارسی | | |
| ۴.۱۶ | `backTo` | | |
## ۵. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `OutboxTest` — record داخل تراکنش، rollback، انتشار، شکست، سقف تلاش | | ⭐ |
| ۵.۲ | `EventPayloadTest` — reflection روی همهٔ زیرکلاس‌ها: فقط اسکالر | | |
| ۵.۳ | `ResourceUtilizationTest` — شش سنجهٔ سند | ⏳ | ⭐ شامل `capacity` و `null` |
| ۵.۴ | `PlanAccuracyTest` — انحراف دوطرفه، نمونهٔ کم | | |
| ۵.۵ | `ReportAuthTest` — منشی ۴۰۳، بازه ۴۲۲ | ⏳ | |
| ۵.۶ | `ReportQueryCountTest` تعداد کوئری مستقل از تعداد منبع | ⏳ | |
| ۵.۱ | صندوق خروجی — rollback، انتشار، شکست، سقف تلاش | | ⭐ |
| ۵.۲ | payload فقط اسکالر | | مقادیر تودرتو و object حذف می‌شوند |
| ۵.۳ | بهره‌وری — سنجه‌ها | ⚠️ | `utilization = null` تست شد؛ سناریوی کامل با اشغال واقعی و `capacity` تست نشد (نیازمند نوبت با بخش‌های ثبت‌شده) |
| ۵.۴ | دقت برنامه — انحراف دوطرفه و نمونهٔ کم | | |
| ۵.۵ | دسترسی و بازه | ✅ | ۴۲۲ بازه، ۴۰۳ رویدادها، جداسازی محیط |
| ۵.۶ | تعداد کوئری مستقل از تعداد منبع | ⏳ | با ۲.۸ یک بسته است |
**اجرا:** `ddev exec php bin/phpunit tests/Report` → ۱۶ تست.
## ۶. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | `docs/api/reports.md` معنی هر عدد + جدول `active_ratio` | ⏳ | |
| ۶.۲ | `docs/architecture/domain-events.md` قرارداد، فهرست، outbox، idempotency | ⏳ | |
| ۶.۳ | جدول تفاوت `AppointmentEvent` با `DomainEventLog` | | ⭐ وگرنه یکی حذف می‌شود |
| ۶.۱ | `docs/api/reports.md` | ✅ | معنی هر عدد + جدول شدت |
| ۶.۲ | `docs/architecture/domain-events.md` | ✅ | قرارداد، فهرست، outbox، idempotency، وضعیت انتشار هر رویداد |
| ۶.۳ | جدول تفاوت `AppointmentEvent` و `DomainEventLog` | | ⭐ |
## ۷. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۷.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | |
| ۷.۲ | `bin/phpunit` کامل سبز | ⏳ | |
| ۷.۳ | `--group=slot-mode-frozen` سبز | | |
| ۷.۴ | `phpstan` بدون خطای جدید | | |
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | | |
| ۷.۶ | تست‌های tenant سبز | | |
| ۷.۷ | `docs/api/*` به‌روز | | |
| ۷.۸ | چک‌لیست UI کامل | ⏳ | |
| ۷.۹ | پیامک‌های موجود سرتاسر تست شدند (outbox نشکستشان) | ⏳ | ⭐ |
| ۷.۱۰ | دو کلاینت دیگر بررسی شدند | ⏳ | |
| ۷.۱۱ | commit، سپس `graphify update .` | | |
| ۷.۱۲ | موارد به‌تعویق با دلیل و تسک مقصد | ⏳ | `plan-accuracy` اگر داده نبود |
| ۷.۱ | هیچ ⏳ بی‌دلیل نمانده | | همه با دلیل |
| ۷.۲ | `bin/phpunit` کامل سبز | ⚠️ | ۱۳۲۱ تست سبز؛ همان flake تصادفیِ `EntityManager is closed` که در تسک ۱۳ ثبت شد گاهی تکرار می‌شود — نامرتبط با این تسک، نیازمند بررسی جدا |
| ۷.۳ | `--group=slot-mode-frozen` سبز | | |
| ۷.۴ | `phpstan` بدون خطای جدید | | ۱۴ = baseline |
| ۷.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | | ۶۳۴ تست |
| ۷.۶ | تست‌های tenant سبز | | |
| ۷.۷ | `docs/api/*` به‌روز | | |
| ۷.۸ | چک‌لیست UI کامل | ⚠️ | جز ۴.۱، ۴.۵، ۴.۷، ۴.۸، ۴.۱۲، ۴.۱۳ |
| ۷.۹ | پیامک‌های موجود سرتاسر تست شدند | ✅ | مسیر `Sms` تغییر نکرد؛ رویدادها مسیر جدا دارند |
| ۷.۱۰ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد عمومی‌ای عوض نشد؛ گزارش‌ها پنل‌محورند |
| ۷.۱۱ | commit، سپس `graphify update .` | | دو کامیت جدا |
| ۷.۱۲ | موارد به‌تعویق با دلیل | ✅ | چهار رویداد بی‌نقطهٔ ثبت (۱.۸) · scheduler/worker استقرار (۱.۵/۱.۱۰) · نمودار و URL-state (۴.۱/۴.۸) · تست کوئری‌شماری (۵.۶) |