# دیتابیس — تسک ۱۴ ## `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 |