- 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.
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'
}
سه قاعدهٔ غیرقابلمذاکره:
- payload فقط uuid و اسکالر — هیچ entity ای در رویداد نیست. مصرفکننده خودش واکشی میکند. entity در پیام async یعنی سریالسازی، detach شدن، و دادهی کهنه.
- انتشار بعد از commit — با
DispatchAfterCurrentBusStampیا از راه outbox. - هر رویداد محیط دارد — مصرفکننده باید بداند رویداد مال کدام محیط است، وگرنه پیامک کلینیک الف به شمارهٔ کلینیک ب میرود.
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.