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.
This commit is contained in:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -0,0 +1,151 @@
# معماری — تسک ۱۴
## ساختار فایل
```
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`.
@@ -0,0 +1,126 @@
# دیتابیس — تسک ۱۴
## `domain_events` — outbox
| ستون | نوع | توضیح |
|---|---|---|
| `id` | BIGINT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | شناسهٔ idempotency برای مصرف‌کننده |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | محیط رویداد |
| `name` | VARCHAR(60) NOT NULL | `AppointmentBooked` |
| `payload` | JSON NOT NULL | فقط uuid و اسکالر |
| `occurred_at` | INT NOT NULL | زمان وقوع (نه انتشار) |
| `published_at` | INT NULL | NULL = منتشر نشده |
| `attempts` | SMALLINT NOT NULL DEFAULT 0 | |
| `last_error` | VARCHAR(255) NULL | |
```sql
KEY idx_de_pending (published_at, occurred_at) -- worker: WHERE published_at IS NULL
KEY idx_de_tenant (entity_type, entity_id, occurred_at)
KEY idx_de_name (name, occurred_at)
```
`idx_de_pending` کوئری worker است:
```sql
SELECT * FROM domain_events
WHERE published_at IS NULL AND attempts < 5
ORDER BY occurred_at ASC
LIMIT 100
```
`attempts < 5` سقف تلاش. ردیف مرده با `last_error` باقی می‌ماند تا ادمین ببیند —
حذف خاموش یعنی رویداد گم‌شدهٔ بی‌رد.
## تغییر جدول موجود
هیچ. `resource_occupancy` (تسک ۰۷) ستون‌های لازم برای گزارش بهره‌وری را دارد:
`start_at`, `end_at`, `occupancy_kind`, `status`, `resource_id`.
`appointments.plan_total_minutes` (تسک ۰۷) پیش‌بینی را دارد.
`AppointmentEvent` موجود دست‌نخورده می‌ماند — تاریخچهٔ وضعیت نوبت است، رویداد دامنه نیست.
تفاوتشان را در `docs/architecture/domain-events.md` بنویس:
| | `AppointmentEvent` | `DomainEventLog` |
|---|---|---|
| دامنه | فقط نوبت | همهٔ دامنه‌ها |
| مصرف‌کننده | UI تاریخچه | سیستم‌های دیگر (پیامک، حسابداری) |
| انتشار | ندارد | messenger |
## کوئری گزارش بهره‌وری
```sql
SELECT ro.resource_id,
SUM(ro.end_at - ro.start_at) AS occupied_seconds,
SUM(CASE WHEN ro.occupancy_kind <> 'passive'
THEN ro.end_at - ro.start_at ELSE 0 END) AS active_seconds,
COUNT(DISTINCT ro.appointment_id) AS appointment_count
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.start_at < :to
GROUP BY ro.resource_id
```
`ro.start_at < :to` (نه `end_at <= :to`) — نوبتی که در بازه شروع شده ولی بیرون تمام شده،
باید شمرده شود. جزئی است ولی روی گزارش هفتگی چند درصد اختلاف می‌سازد.
ایندکس `idx_occ_tenant_range (entity_type, entity_id, start_at)` تسک ۰۷ این را پوشش می‌دهد.
## کوئری دقت برنامه
```sql
SELECT a.service_item_id,
AVG(a.plan_total_minutes) AS planned_avg,
AVG((ps.ended_at - ps.started_at) / 60) AS actual_avg,
COUNT(*) AS sample
FROM appointments a
JOIN patient_sessions ps ON ps.appointment_id = a.id
WHERE a.entity_type = :type AND a.entity_id = :id
AND a.status = 'completed'
AND a.slot_start >= :from AND a.slot_start < :to
AND a.plan_total_minutes IS NOT NULL
AND ps.ended_at IS NOT NULL
GROUP BY a.service_item_id
HAVING sample >= 10
```
⚠️ **بررسی لازم پیش از پیاده‌سازی:** `patient_sessions` باید `appointment_id` و
`started_at`/`ended_at` داشته باشد. اگر ندارد، دو گزینه:
1. از `appointment_events` استفاده کن: فاصلهٔ بین انتقال به `salon` و انتقال به `completed`
2. اگر آن هم نیست، این گزارش به یک تسک جدا موکول شود و فقط گزارش بهره‌وری در این تسک بماند
**تصمیم را بگیر و بنویس** — نه یک گزارش با داده حدسی.
## نگهداشت
```bash
ddev exec php bin/console app:events:prune --older-than=180d --force
```
ردیف‌های `published_at IS NOT NULL` قدیمی‌تر از ۶ ماه. ردیف‌های شکست‌خورده
(`published_at IS NULL AND attempts >= 5`) **هرگز** حذف نمی‌شوند.
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
worker انتشار در `config/packages/messenger.yaml` و `scheduler`:
```yaml
# هر ۱۰ ثانیه
App\Shared\Event\Message\FlushOutboxMessage: { frequency: 10 }
```
⚠️ طبق حافظهٔ عملیاتی پروژه، worker های Coolify باید loop-wrap شوند تا کانتینر خارج نشود.
دستور جدید را با همان الگو اضافه کن.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `domain_events` | جفت tenant |
@@ -0,0 +1,158 @@
# نکات پیاده‌سازی — تسک ۱۴
## ۱. 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 مصرف‌کننده
@@ -0,0 +1,83 @@
# تسک ۱۴ — رویدادهای دامنه و گزارش بهره‌وری منابع
**فاز:** ۴ (بهینه‌سازی) · **وابستگی:** ۰۷ · **زمان:** ۸-۱۰ ساعت
---
## هدف
دو چیز از مستند:
1. **بند ۱۶** — فهرست رویدادهایی که سیستم منتشر می‌کند تا سیستم‌های دیگر (پیامک،
حسابداری، گزارش) به آن‌ها گوش بدهند.
2. **بند ۱۷، ریسک سوم** — «کلینیک بخش‌های نوبت را اشتباه تعریف کند → ظرفیت غلط حساب
می‌شود». راه‌حل مستند: **گزارش بهره‌وری منابع برای پیدا کردن اشکال.**
گزارش بهره‌وری تنها ابزاری است که به کلینیک می‌گوید تعریف بخش‌هایش درست است یا نه.
بدون آن، تسک ۰۵ یک ابزار قدرتمند بدون بازخورد است.
## وضعیت فعلی
- `AppointmentEvent` وجود دارد و تاریخچهٔ تغییر وضعیت نوبت را ثبت می‌کند
- `symfony/messenger` + `symfony/redis-messenger` + `symfony/scheduler` در استک هستند
- پیامک از راه `Sms` domain و `messenger:consume async` کار می‌کند
- تسک‌های ۰۷ تا ۱۳ هر کدام یک `dispatch` گذاشته‌اند بدون یک قرارداد واحد
## دامنه
**هست:**
- قرارداد واحد رویداد دامنه: نام، payload (فقط uuid)، زمان انتشار (بعد از commit)
- ثبت همهٔ رویدادهای بند ۱۶ مستند
- `domain_events` — جدول outbox برای تضمین انتشار
- گزارش بهره‌وری منابع: ساعت آزاد / اشغال / انتظار / کار فعال per منبع per بازه
- گزارش «مدت پیش‌بینی‌شده در برابر مدت واقعی» برای تشخیص تعریف غلط بخش‌ها
**نیست:** پیش‌بینی عدم حضور، پیشنهاد هوشمند وقت (فاز ۴ مستند، خارج از دامنه).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/v1/reports/resource-utilization` | بهره‌وری منابع در بازه |
| GET | `/api/v1/reports/plan-accuracy` | مقایسهٔ مدت پیش‌بینی و واقعی per سرویس |
| GET | `/api/v1/domain-events` | (ادمین) رویدادهای منتشرشده — عیب‌یابی |
## فهرست رویدادها (مستند بند ۱۶)
```
HoldCreated AppointmentBooked
AppointmentCancelled AppointmentRescheduled
PatientNoShow AppointmentCompleted
ResourceBlocked ResourceReleased
CourseStarted CourseSessionCompleted
CourseCompleted PackagePurchased
CreditConsumed CreditRefunded
```
## معیار پذیرش
- ✅ موفق: ثبت نوبت → یک ردیف در `domain_events` با نام `AppointmentBooked` و
payload شامل `appointment_uuid`؛ و `GET /domain-events` آن را نشان می‌دهد.
- ✅ موفق: رویداد **بعد از** commit منتشر می‌شود. تست: تراکنشی که rollback می‌شود
هیچ رویدادی منتشر نمی‌کند.
- ✅ موفق: گزارش بهره‌وری برای اپراتور مریم در یک هفته →
`{ available_minutes: 2400, occupied_minutes: 1800, active_minutes: 1200, utilization: 0.75, active_ratio: 0.50 }`.
- ✅ موفق (**تشخیص تعریف غلط بخش‌ها**): سرویسی که `total_minutes` پیش‌بینی‌اش ۶۰ است ولی
میانگین مدت واقعی مراجعاتش ۹۰ دقیقه → `GET /reports/plan-accuracy` آن را با
`deviation_percent: +50` و `severity: 'high'` برمی‌گرداند.
- ✅ موفق: منبعی با `active_ratio` زیر ۰.۳ در گزارش با نشان «ظرفیت هدررفته» می‌آید —
یعنی بخش‌های `passive` یا انتظار زیادی به آن نسبت داده شده.
- ❌ خطا: گزارش با بازهٔ بزرگ‌تر از ۹۰ روز → `422`.
- ❌ خطا: منشی روی `GET /domain-events``403` (فقط `ROLE_ADMIN`).
- ⚠️ مرزی: منبع بدون هیچ تقویم → `available_minutes: 0` و `utilization: null` (نه صفر —
تقسیم بر صفر معنایی متفاوت دارد).
- ⚠️ مرزی: بخش‌های `passive` در `occupied_minutes` می‌آیند ولی در `active_minutes` نه.
- ⚠️ مرزی: `setup/cleanup` در `occupied_minutes` می‌آید (منبع واقعاً اشغال بوده).
- ⚠️ مرزی: رویداد تکراری (پیام دوباره از messenger) → مصرف‌کننده idempotent، نه رویداد.
## خروجی
- `src/Shared/Event/` — قرارداد رویداد + outbox
- `src/Report/` — دو گزارش
- `assets/admin/pages/ResourceUtilizationPage.tsx` + `PlanAccuracyPage.tsx`
- `docs/api/reports.md` + `docs/architecture/domain-events.md`