Files
clinicpro/docs/new_feture/taskes/task-05-appointment-plan/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

9.3 KiB
Raw Blame History

معماری — تسک ۰۵

ساختار فایل

src/Appointment/Plan/
├── Entity/
│   ├── SegmentTemplate.php
│   └── SegmentRequirement.php
├── Dto/
│   ├── AppointmentPlan.php        # نتیجهٔ نهایی — immutable
│   ├── PlannedSegment.php
│   └── PlannedRequirement.php
├── Service/
│   ├── AppointmentPlanBuilder.php # ارکستراتور
│   ├── SegmentAssembler.php       # جمع‌آوری + ادغام بخش‌ها
│   ├── SegmentDurationResolver.php# مدت هر بخش
│   └── RequirementResolver.php    # نیازمندی → منابع کاندید
├── Controller/
│   ├── SegmentTemplateController.php
│   └── AppointmentPlanController.php
└── Exception/NoEligibleResourceException.php

پنج کلاس سرویس به‌جای یک کلاس بزرگ، چون هر کدام یک دلیل تغییر دارد: ادغام بخش‌ها، محاسبهٔ مدت، و پیدا کردن منبع کاندید سه مسئلهٔ مستقل‌اند و تسک ۰۹ فقط به دوتای اول قلاب می‌زند.

SegmentTemplate

class SegmentTemplate
{
    use TenantOwnedTrait;

    public const OWNER_SERVICE = 'service';   // بخش پایهٔ سرویس
    public const OWNER_OPTION  = 'option';    // بخش اضافهٔ یک آیتم

    private string  $ownerType;
    private ?ServiceItem   $serviceItem = null;
    private ?ServiceOption $option      = null;

    private string  $name;                 // «انتظار اثر بی‌حسی»
    private string  $segmentType;          // کلید ادغام: prep | wait | treatment | aftercare | custom:*
    private int     $sequence;             // ترتیب اجرا
    private ?int    $fixedMinutes = null;  // مدت ثابت؛ null یعنی مدت پویا
    private ?int    $durationShare = null; // درصد از مدت محاسبه‌شدهٔ سرویس، وقتی fixedMinutes نیست
    private bool    $patientPresent = true;
    private bool    $mergeable = false;    // با بخش‌های هم‌نوع ادغام می‌شود
    private bool    $active = true;
}

مدت ثابت یا سهمی

دو حالت، دقیقاً یکی از آن‌ها:

  • fixedMinutes = 30 — انتظار اثر کرم همیشه ۳۰ دقیقه است، چه یک ناحیه چه پنج ناحیه
  • durationShare = 100 — «خود لیزر» همهٔ مدتِ محاسبه‌شده از DurationCalculator (تسک ۰۴) را می‌گیرد

جمع durationShare همهٔ بخش‌های یک سرویس باید دقیقاً ۱۰۰ باشد (اگر هیچ بخش سهمی نباشد، شرط بی‌اثر است). اعتبارسنجی هنگام ذخیرهٔ الگو، نه هنگام ساخت برنامه.

SegmentRequirement

class SegmentRequirement
{
    public const OCCUPANCY_EXCLUSIVE = 'exclusive';  // منبع کامل اشغال
    public const OCCUPANCY_SHARED    = 'shared';     // یک واحد از ظرفیت
    public const OCCUPANCY_PASSIVE   = 'passive';    // رزرو ولی بدون کار فعال

    private SegmentTemplate $segment;
    private ResourceType $role;             // نقش: اپراتور، دستگاه، اتاق
    private int  $count = 1;
    private ?ResourcePool $pool = null;     // «هر عضو این استخر»
    private ?ClinicResource $specific = null; // منبع مشخص (کم‌کاربرد ولی لازم)
    private array $requiredSkills = [];     // skill_id[] — همه لازم‌اند، نه یکی
    private array $constraints = [];        // {same_gender_as_patient: true, attributes: {...}}
    private string $occupancy = self::OCCUPANCY_EXCLUSIVE;
}

pool و specific هر دو تهی‌پذیرند؛ اگر هیچ‌کدام نباشد یعنی «هر منبعِ آن نقش در آن شعبه که شرط‌ها را دارد».

constraints — فهرست بسته

مثل DiscountRule، شرط‌ها از یک فهرست بسته می‌آیند، نه کد دلخواه:

کلید مقدار معنی
same_gender_as_patient bool منبع باید attributes.gender برابر جنسیت بیمار داشته باشد
attributes object اسکالر تطبیق دقیق روی clinic_resources.attributes
min_skill_level 1..5 حداقل سطح مهارت

هر کلید ناشناخته → 422 هنگام ذخیره. این محدودیت عمدی است (مستند بند ۸): تسک ۰۶ باید همهٔ این‌ها را به یک کوئری تبدیل کند.

AppointmentPlanBuilder — جریان

public function build(PlanRequest $request): AppointmentPlan
{
    // ۱. اعتبارسنجی انتخاب (تسک ۰۴) — اگر نامعتبر بود همین‌جا تمام
    $selection = $this->selectionValidator->validate(...);

    // ۲. جمع‌آوری بخش‌ها: پایهٔ سرویس + بخش‌های اضافهٔ هر آیتم انتخابی
    $raw = $this->assembler->collect($service, $selection->options);

    // ۳. ادغام هم‌نوع‌ها (mergeable=true و segmentType یکسان → یکی)
    $merged = $this->assembler->merge($raw);

    // ۴. مدت هر بخش
    $timed = $this->durationResolver->resolve($merged, $selection->totalMinutes);

    // ۵. چیدمان: offset تجمعی بر اساس sequence
    $sequenced = $this->assembler->layout($timed);

    // ۶. نیازمندی‌ها → منابع کاندید (اینجا کوئری می‌خورد)
    $withResources = $this->requirementResolver->resolve($sequenced, $request->branch, $request->patient);

    // ۷. نقطهٔ اتصال تسک ۰۹: قوانین دستهٔ «منبع» و «زمان» اینجا اعمال می‌شوند
    //    فعلاً یک no-op PolicyApplier تزریق شود تا امضا بعداً عوض نشود.
    return $this->policies->applyToPlan($withResources);
}

مرحلهٔ ۷ عمداً از روز اول در امضا هست حتی وقتی خالی است — افزودنش بعداً یعنی تغییر امضای عمومی و همهٔ تست‌ها.

ادغام بخش‌ها

ورودی: بخش‌های سرویس + بخش‌های همهٔ آیتم‌های انتخابی
گروه‌بندی بر اساس segmentType
برای هر گروه:
    اگر همهٔ اعضا mergeable=true  → یک بخش با:
        نام: نام بخشِ سرویس (یا اولین)
        مدت: بیشترین مدت ثابت، یا مجموع سهم‌ها
        نیازمندی‌ها: اتحاد (بدون تکرار؛ count بیشینه برای هر نقش)
    وگرنه → همه جدا می‌مانند، به ترتیب sequence

مثال مستند: بیمار پنج ناحیه انتخاب می‌کند، هر ناحیه یک بخش «آماده‌سازی» با mergeable=true دارد → یک آماده‌سازی، نه پنج تا.

RequirementResolver

/** @return ClinicResource[] منابع واجد شرایط برای این نیازمندی */
public function candidates(SegmentRequirement $req, Branch $branch, ?Patient $patient): array

از ClinicResourceRepository::findEligible() (تسک ۰۲) استفاده می‌کند: شعبه + نقش + HAVING COUNT(DISTINCT skill) = n + فیلتر attributes در PHP (JSON در MariaDB قابل ایندکس‌گذاری مطمئن نیست؛ تعداد منابع یک شعبه کوچک است).

اگر candidates === []NoEligibleResourceException با پیام ساخته‌شده از نقش و مهارت‌ها:

throw new NoEligibleResourceException(sprintf(
    'هیچ %s با مهارت %s در شعبهٔ %s موجود نیست',
    $req->getRole()->getName(), implode('، ', $skillNames), $branch->getName()
));

پیام انسانی اجباری است — مستند بند ۱۰ صریح می‌گوید خطا باید بگوید چه چیزی کم است.

سازگاری: سرویس بدون الگو

SegmentAssembler::collect() وقتی هیچ SegmentTemplate پیدا نکرد، یک بخش مجازی می‌سازد:

new PlannedSegment(
    name: $service->getName(),
    segmentType: 'treatment',
    durationMinutes: $selection->totalMinutes,
    requirements: [ PlannedRequirement::doctorDefault() ],   // منبع type=doctor
);

این تضمین می‌کند حالت booking_mode=service امروزی، وقتی به موتور جدید مهاجرت کند، دقیقاً همان رفتار را داشته باشد.

پنل ادمین

ServiceSegmentsPage.tsx (زیرصفحهٔ ServiceDetailPage):

  • لیست مرتب بخش‌ها با drag ندارد؛ sequence عددی
  • هر بخش قابل بازشدن: مدت (ثابت/سهمی)، حضور بیمار، ادغام‌پذیر
  • زیر هر بخش، نیازمندی‌ها: نقش (SearchableSelect)، تعداد، استخر، مهارت‌ها (چیپ)، نوع اشغال (رادیو با توضیح فارسی هر گزینه)
  • نوار پیش‌نمایش زمانی: چهار بخش روی یک خط با عرض متناسب مدت و آیکن منابع هر بخش — این تنها راهی است که کاربر غیرفنی می‌فهمد چه ساخته