# دیتابیس — تسک ۱۴

## `domain_events` — outbox

| ستون | نوع | توضیح |
|---|---|---|
| `id` | BIGINT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | شناسهٔ idempotency برای مصرف‌کننده |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | محیط رویداد |
| `name` | VARCHAR(60) NOT NULL | `AppointmentBooked` |
| `payload` | JSON NOT NULL | فقط uuid و اسکالر |
| `occurred_at` | INT NOT NULL | زمان وقوع (نه انتشار) |
| `published_at` | INT NULL | NULL = منتشر نشده |
| `attempts` | SMALLINT NOT NULL DEFAULT 0 | |
| `last_error` | VARCHAR(255) NULL | |

```sql
KEY idx_de_pending (published_at, occurred_at)          -- worker: WHERE published_at IS NULL
KEY idx_de_tenant  (entity_type, entity_id, occurred_at)
KEY idx_de_name    (name, occurred_at)
```

`idx_de_pending` کوئری worker است:

```sql
SELECT * FROM domain_events
WHERE published_at IS NULL AND attempts < 5
ORDER BY occurred_at ASC
LIMIT 100
```

`attempts < 5` سقف تلاش. ردیف مرده با `last_error` باقی می‌ماند تا ادمین ببیند —
حذف خاموش یعنی رویداد گم‌شدهٔ بی‌رد.

## تغییر جدول موجود

هیچ. `resource_occupancy` (تسک ۰۷) ستون‌های لازم برای گزارش بهره‌وری را دارد:
`start_at`, `end_at`, `occupancy_kind`, `status`, `resource_id`.
`appointments.plan_total_minutes` (تسک ۰۷) پیش‌بینی را دارد.

`AppointmentEvent` موجود دست‌نخورده می‌ماند — تاریخچهٔ وضعیت نوبت است، رویداد دامنه نیست.
تفاوتشان را در `docs/architecture/domain-events.md` بنویس:

| | `AppointmentEvent` | `DomainEventLog` |
|---|---|---|
| دامنه | فقط نوبت | همهٔ دامنه‌ها |
| مصرف‌کننده | UI تاریخچه | سیستم‌های دیگر (پیامک، حسابداری) |
| انتشار | ندارد | messenger |

## کوئری گزارش بهره‌وری

```sql
SELECT ro.resource_id,
       SUM(ro.end_at - ro.start_at) AS occupied_seconds,
       SUM(CASE WHEN ro.occupancy_kind <> 'passive'
                THEN ro.end_at - ro.start_at ELSE 0 END) AS active_seconds,
       COUNT(DISTINCT ro.appointment_id) AS appointment_count
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.start_at < :to
GROUP BY ro.resource_id
```

`ro.start_at < :to` (نه `end_at <= :to`) — نوبتی که در بازه شروع شده ولی بیرون تمام شده،
باید شمرده شود. جزئی است ولی روی گزارش هفتگی چند درصد اختلاف می‌سازد.

ایندکس `idx_occ_tenant_range (entity_type, entity_id, start_at)` تسک ۰۷ این را پوشش می‌دهد.

## کوئری دقت برنامه

```sql
SELECT a.service_item_id,
       AVG(a.plan_total_minutes)                     AS planned_avg,
       AVG((ps.ended_at - ps.started_at) / 60)       AS actual_avg,
       COUNT(*)                                      AS sample
FROM appointments a
JOIN patient_sessions ps ON ps.appointment_id = a.id
WHERE a.entity_type = :type AND a.entity_id = :id
  AND a.status = 'completed'
  AND a.slot_start >= :from AND a.slot_start < :to
  AND a.plan_total_minutes IS NOT NULL
  AND ps.ended_at IS NOT NULL
GROUP BY a.service_item_id
HAVING sample >= 10
```

⚠️ **بررسی لازم پیش از پیاده‌سازی:** `patient_sessions` باید `appointment_id` و
`started_at`/`ended_at` داشته باشد. اگر ندارد، دو گزینه:

1. از `appointment_events` استفاده کن: فاصلهٔ بین انتقال به `salon` و انتقال به `completed`
2. اگر آن هم نیست، این گزارش به یک تسک جدا موکول شود و فقط گزارش بهره‌وری در این تسک بماند

**تصمیم را بگیر و بنویس** — نه یک گزارش با داده حدسی.

## نگهداشت

```bash
ddev exec php bin/console app:events:prune --older-than=180d --force
```

ردیف‌های `published_at IS NOT NULL` قدیمی‌تر از ۶ ماه. ردیف‌های شکست‌خورده
(`published_at IS NULL AND attempts >= 5`) **هرگز** حذف نمی‌شوند.

## Migration

```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```

worker انتشار در `config/packages/messenger.yaml` و `scheduler`:

```yaml
# هر ۱۰ ثانیه
App\Shared\Event\Message\FlushOutboxMessage: { frequency: 10 }
```

⚠️ طبق حافظهٔ عملیاتی پروژه، worker های Coolify باید loop-wrap شوند تا کانتینر خارج نشود.
دستور جدید را با همان الگو اضافه کن.

## طبقه‌بندی tenant

| جدول | وضعیت |
|---|---|
| `domain_events` | جفت tenant |
