- 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.
152 lines
6.7 KiB
Markdown
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`.
|