Files
clinicpro/docs/new_feture/taskes/task-14-events-utilization/implementation_notes.md
T
hamed 021d0eb6b2 feat: implement cancellation policy, no-show tracking, and waitlist management
- Add implementation notes for cancellation and waitlist features.
- Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting.
- Establish architecture for domain events and outbox pattern to ensure reliable event publishing.
- Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports.
- Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
2026-07-30 11:43:58 +03:30

7.6 KiB
Raw Blame History

نکات پیاده‌سازی — تسک ۱۴

۱. outbox، نه dispatch مستقیم

تسک‌های ۰۷ تا ۱۳ هر کدام یک bus->dispatch() دارند. این تسک همه را به publisher->record() تغییر می‌دهد:

// قبل
$this->bus->dispatch((new Envelope($event))->with(new DispatchAfterCurrentBusStamp()));

// بعد
$this->publisher->record($event);      // فقط persist — داخل همان تراکنش کاری

DispatchAfterCurrentBusStamp مشکل «rollback بعد از پیامک» را حل می‌کند ولی مشکل «commit موفق، Redis پایین» را نه. outbox هر دو را حل می‌کند.

اگر تسک‌های قبلی هنوز اجرا نشده‌اند، از روز اول record() بنویس.

۲. payload فقط uuid

// ❌ entity در پیام async
new AppointmentBooked($appointment);

// ✅
new AppointmentBooked(['appointment_uuid' => $appointment->getUuid()]);

entity در پیام یعنی: سریال‌سازی سنگین، detach شدن از EntityManager، و داده‌ای که تا لحظهٔ مصرف کهنه شده. مصرف‌کننده با uuid خودش واکشی می‌کند و تازه‌ترین حالت را می‌بیند.

۳. idempotency در مصرف‌کننده، نه در انتشار

messenger ممکن است یک پیام را دوبار تحویل دهد (at-least-once). پس مصرف‌کننده باید idempotent باشد:

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 = 0utilization = null

'utilization' => $available > 0 ? round($occupied / $available, 2) : null,

نه صفر. منبعی که تقویم ندارد، «بهره‌وری صفر» ندارد — بهره‌وری‌اش تعریف‌نشده است. صفر نشان دادن یعنی کلینیک فکر می‌کند منبع بی‌استفاده است در حالی که مشکل نبود تقویم است.

در UI: با tooltip «تقویم کاری تعریف نشده» + لینک به تنظیم تقویم (تسک ۰۳).

۶. مرز بازه در کوئری

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 نیاز دارد. پیش از پیاده‌سازی بررسی کن که این ستون‌ها هستند:

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 مصرف‌کننده