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

152 lines
6.7 KiB
Markdown

# معماری — تسک ۱۴
## ساختار فایل
```
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
```
## قرارداد رویداد
```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` را برمی‌دارد و منتشر می‌کند. حداکثر یک بار
تأخیر، هرگز گم‌شدن.
```php
final class DomainEventPublisher
{
/** داخل تراکنش کاری صدا زده می‌شود — فقط درج، بدون I/O خارجی. */
public function record(DomainEvent $event): void
{
$this->em->persist(DomainEventLog::from($event));
}
}
```
تسک‌های ۰۷ تا ۱۳ به‌جای `bus->dispatch()` باید `publisher->record()` صدا بزنند.
اگر آن تسک‌ها تمام شده‌اند، این تسک شامل جایگزینی آن فراخوانی‌ها هم است.
## `ResourceUtilizationReporter`
```php
/** @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 سرویس:
```php
// پیش‌بینی: appointments.plan_total_minutes (تسک ۰۷)
// واقعیت: patient_sessions یا appointment_events (زمان بین ورود و پایان)
$deviation = intdiv(($actualAvg - $plannedAvg) * 100, max(1, $plannedAvg));
```
| انحراف | شدت | معنی |
|---|---|---|
| ±۱۰٪ | `ok` | تعریف درست است |
| ±۱۰..۳۰٪ | `medium` | بازبینی بخش‌ها |
| > ۳۰٪ | `high` | تعریف اشتباه — ظرفیت غلط محاسبه می‌شود |
انحراف **منفی** بزرگ هم مشکل است: سرویسی که ۹۰ دقیقه پیش‌بینی شده و ۴۵ دقیقه طول
می‌کشد، نصف ظرفیت کلینیک را الکی می‌بلعد — همان مسئله‌ای که کل این پروژه برای حلش است.
حداقل نمونه: ۱۰ مراجعهٔ `completed`. کمتر از آن، `severity: 'insufficient_data'`.
## کارایی گزارش‌ها
هر دو گزارش کوئری تجمعی‌اند، نه پیمایش:
```sql
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`.