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:
@@ -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`
|
||||
Reference in New Issue
Block a user