Files
clinicpro/docs/new_feture/taskes/task-14-events-utilization/architecture.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

6.7 KiB

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

ساختار فایل

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

قرارداد رویداد

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 را برمی‌دارد و منتشر می‌کند. حداکثر یک بار تأخیر، هرگز گم‌شدن.

final class DomainEventPublisher
{
    /** داخل تراکنش کاری صدا زده می‌شود — فقط درج، بدون I/O خارجی. */
    public function record(DomainEvent $event): void
    {
        $this->em->persist(DomainEventLog::from($event));
    }
}

تسک‌های ۰۷ تا ۱۳ به‌جای bus->dispatch() باید publisher->record() صدا بزنند. اگر آن تسک‌ها تمام شده‌اند، این تسک شامل جایگزینی آن فراخوانی‌ها هم است.

ResourceUtilizationReporter

/** @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 سرویس:

// پیش‌بینی: appointments.plan_total_minutes  (تسک ۰۷)
// واقعیت:  patient_sessions یا appointment_events (زمان بین ورود و پایان)
$deviation = intdiv(($actualAvg - $plannedAvg) * 100, max(1, $plannedAvg));
انحراف شدت معنی
±۱۰٪ ok تعریف درست است
±۱۰..۳۰٪ medium بازبینی بخش‌ها
> ۳۰٪ high تعریف اشتباه — ظرفیت غلط محاسبه می‌شود

انحراف منفی بزرگ هم مشکل است: سرویسی که ۹۰ دقیقه پیش‌بینی شده و ۴۵ دقیقه طول می‌کشد، نصف ظرفیت کلینیک را الکی می‌بلعد — همان مسئله‌ای که کل این پروژه برای حلش است.

حداقل نمونه: ۱۰ مراجعهٔ completed. کمتر از آن، severity: 'insufficient_data'.

کارایی گزارش‌ها

هر دو گزارش کوئری تجمعی‌اند، نه پیمایش:

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.