# معماری — تسک ۱۴ ## ساختار فایل ``` src/Shared/Event/ ├── DomainEvent.php # کلاس پایه — payload فقط اسکالر و uuid ├── DomainEventPublisher.php # تنها نقطهٔ انتشار ├── Entity/DomainEventLog.php # outbox └── MessageHandler/PublishDomainEventHandler.php src/Report/ ├── Service/ │ ├── ResourceUtilizationReporter.php │ └── PlanAccuracyReporter.php ├── Dto/{UtilizationRow, AccuracyRow}.php └── Controller/ReportController.php ``` ## قرارداد رویداد ```php abstract class DomainEvent { public function __construct( public readonly string $entityType, // محیط — همهٔ رویدادها tenant دارند public readonly int $entityId, public readonly array $payload, // فقط اسکالر و uuid public readonly int $occurredAt, ) {} abstract public function name(): string; // 'AppointmentBooked' } ``` سه قاعدهٔ غیرقابل‌مذاکره: 1. **payload فقط uuid و اسکالر** — هیچ entity ای در رویداد نیست. مصرف‌کننده خودش واکشی می‌کند. entity در پیام async یعنی سریال‌سازی، detach شدن، و داده‌ی کهنه. 2. **انتشار بعد از commit** — با `DispatchAfterCurrentBusStamp` یا از راه outbox. 3. **هر رویداد محیط دارد** — مصرف‌کننده باید بداند رویداد مال کدام محیط است، وگرنه پیامک کلینیک الف به شمارهٔ کلینیک ب می‌رود. ## outbox — چرا لازم است ``` تراکنش: [ثبت نوبت] + [درج ردیف در domain_events] → commit اتمی بعد: PublishDomainEventHandler ردیف را برمی‌دارد و به messenger می‌دهد ``` بدون outbox دو حالت شکست ممکن است: | حالت | نتیجه | |---|---| | dispatch قبل از commit، تراکنش rollback | پیامک رفته، نوبتی وجود ندارد | | commit موفق، dispatch شکست خورد (Redis down) | نوبت هست، هیچ‌کس مطلع نشد | با outbox، ردیف رویداد **در همان تراکنش** ثبت می‌شود. یک worker (یا `scheduler` هر ۱۰ ثانیه) ردیف‌های `published_at IS NULL` را برمی‌دارد و منتشر می‌کند. حداکثر یک بار تأخیر، هرگز گم‌شدن. ```php final class DomainEventPublisher { /** داخل تراکنش کاری صدا زده می‌شود — فقط درج، بدون I/O خارجی. */ public function record(DomainEvent $event): void { $this->em->persist(DomainEventLog::from($event)); } } ``` تسک‌های ۰۷ تا ۱۳ به‌جای `bus->dispatch()` باید `publisher->record()` صدا بزنند. اگر آن تسک‌ها تمام شده‌اند، این تسک شامل جایگزینی آن فراخوانی‌ها هم است. ## `ResourceUtilizationReporter` ```php /** @return UtilizationRow[] */ public function report(EntityContext $ctx, int $from, int $to, ?Branch $branch): array ``` چهار عدد per منبع: | عدد | از کجا | معنی | |---|---|---| | `available_minutes` | `ResourceAvailabilityService::rawWindows()` (تسک ۰۳) | ظرفیت تقویمی | | `occupied_minutes` | `SUM(end_at - start_at)` روی `resource_occupancy` با `status='booked'` | زمان اشغال، شامل setup/cleanup و passive | | `active_minutes` | همان، ولی `occupancy_kind != 'passive'` | زمان کار واقعی | | `wasted_minutes` | `occupied - active` | زمانی که منبع رزرو بود ولی کار نمی‌کرد | ``` utilization = occupied / available → «چقدر از ظرفیت فروخته شد» active_ratio = active / occupied → «چقدر از اشغال، کار واقعی بود» ``` `active_ratio` پایین دقیقاً همان چیزی است که مستند بند ۱۷ می‌خواهد کشف کند: منبعی که ۷۰٪ زمانش «رزرو ولی بی‌کار» است، یعنی بخش‌های نوبت اشتباه تعریف شده‌اند — مثلاً اپراتور به بخش «انتظار» نسبت داده شده که نباید. ## `PlanAccuracyReporter` مقایسهٔ پیش‌بینی و واقعیت per سرویس: ```php // پیش‌بینی: appointments.plan_total_minutes (تسک ۰۷) // واقعیت: patient_sessions یا appointment_events (زمان بین ورود و پایان) $deviation = intdiv(($actualAvg - $plannedAvg) * 100, max(1, $plannedAvg)); ``` | انحراف | شدت | معنی | |---|---|---| | ±۱۰٪ | `ok` | تعریف درست است | | ±۱۰..۳۰٪ | `medium` | بازبینی بخش‌ها | | > ۳۰٪ | `high` | تعریف اشتباه — ظرفیت غلط محاسبه می‌شود | انحراف **منفی** بزرگ هم مشکل است: سرویسی که ۹۰ دقیقه پیش‌بینی شده و ۴۵ دقیقه طول می‌کشد، نصف ظرفیت کلینیک را الکی می‌بلعد — همان مسئله‌ای که کل این پروژه برای حلش است. حداقل نمونه: ۱۰ مراجعهٔ `completed`. کمتر از آن، `severity: 'insufficient_data'`. ## کارایی گزارش‌ها هر دو گزارش کوئری تجمعی‌اند، نه پیمایش: ```sql SELECT ro.resource_id, SUM(ro.end_at - ro.start_at) AS occupied, SUM(CASE WHEN ro.occupancy_kind <> 'passive' THEN ro.end_at - ro.start_at ELSE 0 END) AS active FROM resource_occupancy ro WHERE ro.entity_type = :type AND ro.entity_id = :id AND ro.status = 'booked' AND ro.start_at >= :from AND ro.end_at <= :to GROUP BY ro.resource_id ``` `idx_occ_tenant_range` تسک ۰۷ همین را پوشش می‌دهد. `available_minutes` جدا محاسبه می‌شود (از تقویم، کش‌شده). سقف بازه ۹۰ روز. ## پنل ادمین - `ResourceUtilizationPage.tsx` — جدول منابع + نمودار میله‌ای با `Recharts` (در استک هست). ستون‌ها: منبع، ظرفیت، اشغال، کار فعال، بهره‌وری، نسبت فعال. ردیف‌های `active_ratio < 0.3` با نشان هشدار. - `PlanAccuracyPage.tsx` — جدول سرویس‌ها با انحراف و شدت + لینک به «ویرایش بخش‌های این سرویس» (تسک ۰۵) لینک به ویرایش بخش‌ها مهم‌ترین بخش این صفحه است: گزارشی که مشکل را نشان می‌دهد ولی راه اصلاح را نمی‌دهد، خوانده نمی‌شود. بازهٔ زمانی با `PersianDatePicker`، وضعیت در URL با `useUrlState`.