# معماری — تسک ۱۴

## ساختار فایل

```
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`.
