# معماری — تسک ۰۵ ## ساختار فایل ``` 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` ```php 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` ```php 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` — جریان ```php 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` ```php /** @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` با پیام ساخته‌شده از نقش و مهارت‌ها: ```php throw new NoEligibleResourceException(sprintf( 'هیچ %s با مهارت %s در شعبهٔ %s موجود نیست', $req->getRole()->getName(), implode('، ', $skillNames), $branch->getName() )); ``` پیام انسانی اجباری است — مستند بند ۱۰ صریح می‌گوید خطا باید بگوید چه چیزی کم است. ## سازگاری: سرویس بدون الگو `SegmentAssembler::collect()` وقتی هیچ `SegmentTemplate` پیدا نکرد، یک بخش مجازی می‌سازد: ```php new PlannedSegment( name: $service->getName(), segmentType: 'treatment', durationMinutes: $selection->totalMinutes, requirements: [ PlannedRequirement::doctorDefault() ], // منبع type=doctor ); ``` این تضمین می‌کند حالت `booking_mode=service` امروزی، وقتی به موتور جدید مهاجرت کند، دقیقاً همان رفتار را داشته باشد. ## پنل ادمین `ServiceSegmentsPage.tsx` (زیرصفحهٔ `ServiceDetailPage`): - لیست مرتب بخش‌ها با drag ندارد؛ `sequence` عددی - هر بخش قابل بازشدن: مدت (ثابت/سهمی)، حضور بیمار، ادغام‌پذیر - زیر هر بخش، نیازمندی‌ها: نقش (`SearchableSelect`)، تعداد، استخر، مهارت‌ها (چیپ)، نوع اشغال (رادیو با توضیح فارسی هر گزینه) - **نوار پیش‌نمایش زمانی**: چهار بخش روی یک خط با عرض متناسب مدت و آیکن منابع هر بخش — این تنها راهی است که کاربر غیرفنی می‌فهمد چه ساخته