# نکات پیاده‌سازی — تسک ۱۴ ## ۱. outbox، نه dispatch مستقیم تسک‌های ۰۷ تا ۱۳ هر کدام یک `bus->dispatch()` دارند. این تسک همه را به `publisher->record()` تغییر می‌دهد: ```php // قبل $this->bus->dispatch((new Envelope($event))->with(new DispatchAfterCurrentBusStamp())); // بعد $this->publisher->record($event); // فقط persist — داخل همان تراکنش کاری ``` `DispatchAfterCurrentBusStamp` مشکل «rollback بعد از پیامک» را حل می‌کند ولی مشکل «commit موفق، Redis پایین» را نه. outbox هر دو را حل می‌کند. اگر تسک‌های قبلی هنوز اجرا نشده‌اند، از روز اول `record()` بنویس. ## ۲. payload فقط uuid ```php // ❌ entity در پیام async new AppointmentBooked($appointment); // ✅ new AppointmentBooked(['appointment_uuid' => $appointment->getUuid()]); ``` entity در پیام یعنی: سریال‌سازی سنگین، detach شدن از EntityManager، و داده‌ای که تا لحظهٔ مصرف کهنه شده. مصرف‌کننده با uuid خودش واکشی می‌کند و تازه‌ترین حالت را می‌بیند. ## ۳. idempotency در مصرف‌کننده، نه در انتشار messenger ممکن است یک پیام را دوبار تحویل دهد (at-least-once). پس **مصرف‌کننده** باید idempotent باشد: ```php public function __invoke(AppointmentBooked $event): void { if ($this->smsLogRepo->alreadySent($event->uuid, 'booking_confirmation')) { return; } … } ``` `domain_events.uuid` همان کلید idempotency است. تلاش برای تضمین exactly-once در سمت انتشار، مسئله‌ای است که حل نمی‌شود؛ idempotent بودن مصرف‌کننده حل می‌شود. ## ۴. `active_ratio` — عدد اصلی این تسک ``` utilization = occupied / available active_ratio = active / occupied ``` `utilization` عدد فروش است و کلینیک دوستش دارد. `active_ratio` عدد **تشخیص** است: | `active_ratio` | معنی | |---|---| | > ۰.۸ | تعریف بخش‌ها درست است | | ۰.۵ – ۰.۸ | زمان passive/انتظار قابل توجه — بازبینی | | < ۰.۳ | **تعریف اشتباه** — منبع به بخشی نسبت داده شده که در آن کار نمی‌کند | مثال واقعی: اپراتوری که اشتباهاً به بخش «انتظار اثر بی‌حسی» هم نسبت داده شده، `active_ratio` حدود ۰.۵ می‌گیرد — و همان لحظه‌ای است که کلینیک می‌فهمد ۳۰ دقیقه ظرفیت هر نوبت را الکی می‌سوزاند. این توضیح باید **در خود UI** باشد (tooltip روی ستون)، نه فقط در مستندات. ## ۵. `available_minutes = 0` → `utilization = null` ```php 'utilization' => $available > 0 ? round($occupied / $available, 2) : null, ``` نه صفر. منبعی که تقویم ندارد، «بهره‌وری صفر» ندارد — بهره‌وری‌اش **تعریف‌نشده** است. صفر نشان دادن یعنی کلینیک فکر می‌کند منبع بی‌استفاده است در حالی که مشکل نبود تقویم است. در UI: `—` با tooltip «تقویم کاری تعریف نشده» + لینک به تنظیم تقویم (تسک ۰۳). ## ۶. مرز بازه در کوئری ```sql AND ro.start_at >= :from AND ro.start_at < :to ``` نه `end_at <= :to`. نوبتی که ۲۳:۳۰ شروع شده و ۰۰:۳۰ روز بعد تمام می‌شود، باید در روز شروعش شمرده شود. با شرط `end_at` کامل حذف می‌شود. ## ۷. `plan-accuracy` — اول منبع داده را بررسی کن کوئری این گزارش به `patient_sessions.appointment_id` و `started_at`/`ended_at` نیاز دارد. **پیش از پیاده‌سازی** بررسی کن که این ستون‌ها هستند: ```bash ddev exec php bin/console doctrine:mapping:describe 'App\Patient\Entity\PatientSession' ``` اگر نیستند، جایگزین: `appointment_events` — فاصلهٔ بین انتقال به `salon` و به `completed`. اگر آن هم قابل اتکا نیست، **این گزارش را به تسک جدا موکول کن** و در README تسک‌ها بنویس. گزارشی با داده حدسی بدتر از نبود گزارش است: کلینیک بر اساسش بخش‌ها را عوض می‌کند. ## ۸. edge case ها | حالت | رفتار درست | |---|---| | منبع بدون تقویم | `utilization: null` + لینک تنظیم تقویم | | منبع بدون هیچ اشغال | `occupied: 0, active_ratio: null` | | بخش `passive` | در `occupied` هست، در `active` نه | | `setup/cleanup` | در `occupied` هست (منبع واقعاً اشغال بود) | | اشغال `released` (لغوشده) | در گزارش **نمی‌آید** — `status='booked'` فقط | | اشغال دستی (`appointment_id IS NULL`) | در `occupied` می‌آید، `appointment_count` تحت تأثیر نیست | | منبع با `capacity=3` | `occupied` جمع همهٔ واحدهاست؛ `available` باید × capacity شود | | نمونهٔ کمتر از ۱۰ در `plan-accuracy` | `severity: 'insufficient_data'`، عدد نمایش داده نشود | | انحراف منفی بزرگ (پیش‌بینی > واقعیت) | `severity: 'high'` — همان‌قدر مهم | | بازه > ۹۰ روز | `422` | | رویداد شکست‌خورده با ۵ تلاش | ردیف می‌ماند، در `GET /domain-events` با نشان خطا | سطر `capacity=3` را فراموش نکن: اتاق سه‌تخته در ۸ ساعت، ۲۴ نفر-ساعت ظرفیت دارد نه ۸. بدون ضرب در `capacity`، بهره‌وری‌اش سه برابر واقعی نشان داده می‌شود. ## ۹. تست ``` tests/Shared/Event/OutboxTest.php ← ⭐ - record() داخل تراکنش → ردیف در همان تراکنش - rollback → هیچ ردیفی و هیچ انتشاری - worker ردیف را منتشر و published_at را پر می‌کند - شکست → attempts++ و last_error - attempts >= 5 → دیگر برداشته نمی‌شود، حذف هم نمی‌شود tests/Shared/Event/EventPayloadTest.php - payload فقط اسکالر و uuid (reflection روی همهٔ زیرکلاس‌های DomainEvent) - هر رویداد entity_type/entity_id دارد tests/Report/ResourceUtilizationTest.php ← ⭐ - passive در occupied هست، در active نه - setup/cleanup در occupied - released شمرده نمی‌شود - capacity=3 → available × 3 - منبع بدون تقویم → utilization null (نه صفر) - مرز بازه: نوبت شب‌گذر در روز شروعش tests/Report/PlanAccuracyTest.php - انحراف مثبت و منفی هر دو high - نمونهٔ < ۱۰ → insufficient_data tests/Report/ReportAuthTest.php - منشی روی domain-events → 403 - بازه > ۹۰ روز → 422 tests/Report/ReportQueryCountTest.php - گزارش ۹۰ روزه: تعداد کوئری ثابت، مستقل از تعداد منبع ``` ## ۱۰. مستندات - `docs/api/reports.md` — دو گزارش + معنی هر عدد + جدول `active_ratio` - `docs/architecture/domain-events.md` — قرارداد رویداد، فهرست کامل، الگوی outbox، تفاوت با `AppointmentEvent`، و قاعدهٔ idempotency مصرف‌کننده