# معماری — تسک ۰۴ ## واژگان — مهم‌ترین نکتهٔ این تسک مستند و کد فعلی دو واژهٔ متفاوت برای چیزهای متفاوت دارند و قاطی کردنشان کل تسک را خراب می‌کند: | مستند | معادل در این کدبیس | یعنی | |---|---|---| | دسته‌بندی (`service_category`) | **`ServiceCategory` جدید، درختی** | «زیبایی › لیزر» — فقط برای مرتب کردن | | سرویس (`service`) | **`ServiceItem` موجود** | چیزی که بیمار رزرو می‌کند: «لیزر کندلا» | | گروه آیتم (`item_group`) | **`ItemGroup` جدید** | «نواحی موردنظر»، «سطح انرژی» | | آیتم (`service_item`) | **`ServiceOption` جدید** | «صورت»، «بیکینی»، «دندان ۵» | | — | `ServiceSection` موجود | **بخش کلینیک** (رادیولوژی، تزریقات) — سازمانی، نه کاتالوگی | ⚠️ نام `ServiceItem` در کد فعلی معادل «سرویس» مستند است، نه «آیتم». پس آیتم‌های مستند کلاس جدید `ServiceOption` می‌گیرند. تغییر نام `ServiceItem` **ممنوع** است — در `appointment_service_items`, `service_item_staff`, `session_services`, `tariffs` و سه ریپوی کلاینت استفاده می‌شود. این جدول را عیناً در `docs/api/clinic-services.md` بنویس. ## ساختار فایل ``` src/ClinicService/ ├── Entity/ │ ├── ServiceCategory.php # جدید — درختی │ ├── ItemGroup.php # جدید │ ├── ServiceOption.php # جدید — «آیتم» مستند │ ├── ServiceOptionRelation.php # جدید — ناسازگاری/پیش‌نیاز │ ├── ServiceBranchOverride.php # جدید │ ├── ServiceItem.php # موجود — ستون‌های تازه │ └── ServiceSection.php # موجود — دست‌نخورده ├── Service/ │ ├── ServiceSelectionValidator.php # اعتبارسنجی انتخاب │ ├── DurationCalculator.php # محاسبهٔ مدت با دو نوع زمان │ ├── ServicePriceResolver.php # قیمت با override شعبه │ └── ItemGroupService.php └── Controller/ ├── ServiceCategoryController.php ├── ItemGroupController.php └── ServiceSelectionController.php ``` ## `DurationCalculator` — قلب تسک ```php final class DurationCalculator { /** * مدت کل یک انتخاب. قاعدهٔ مستند بند ۷: * «اولین آیتم هر گروه: زمان تنها — بقیه: زمان اضافه» * * @param ServiceOption[] $options انتخاب‌های کاربر */ public function totalMinutes(ServiceItem $service, array $options, ?Branch $branch = null): int { $base = $this->baseDuration($service, $branch); // مدت پایهٔ سرویس (ممکن است ۰ باشد) $byGroup = []; foreach ($options as $option) { $byGroup[$option->getGroup()->getId()][] = $option; } $total = $base; foreach ($byGroup as $groupOptions) { // ترتیب پایدار: بلندترین «زمان تنها» اول، تا انتخاب کاربر روی نتیجه اثر نگذارد usort($groupOptions, fn($a, $b) => $b->getSoloMinutes() <=> $a->getSoloMinutes()); $total += $groupOptions[0]->getSoloMinutes(); foreach (array_slice($groupOptions, 1) as $rest) { $total += $rest->getAdditionalMinutes() ?? $rest->getSoloMinutes(); } } return $total; } } ``` **چرا مرتب‌سازی نزولی؟** بدون آن، «صورت بعد بیکینی» و «بیکینی بعد صورت» دو مدت متفاوت می‌دهند و همان انتخاب در دو نشست دو قیمت/دو ظرفیت می‌گیرد. مستند این را نگفته ولی لازمهٔ قطعی بودن است. تصمیم: بیشترین زمان تنها، «آیتم اصلی» است. **چرا per گروه، نه per کل انتخاب؟** آماده‌سازی per نوع کار است. «سطح انرژی» و «ناحیه» دو کار متفاوت‌اند و هر کدام آماده‌سازی خودش را دارد. ## `ServiceSelectionValidator` ```php /** @return SelectionResult{valid: bool, errors: SelectionError[], total_minutes: int, total_price_rials: int} */ public function validate(EntityContext $ctx, ServiceItem $service, array $optionUuids, ?Branch $branch): SelectionResult ``` ترتیب بررسی — عمداً همین ترتیب: ``` ۱. مالکیت محیط همهٔ uuid ها → یک بیگانه = 404، نه پیام دقیق‌تر ۲. آیتم‌ها واقعاً به این سرویس تعلق دارند → 422 ۳. قید min/max هر گروه ۴. ناسازگاری‌ها ۵. پیش‌نیازها ۶. محاسبهٔ مدت و قیمت (فقط اگر ۱..۵ سبز باشند) ``` مرحلهٔ ۱ اول است چون پیام‌های مراحل بعد وجود و نام آیتم را لو می‌دهند — دقیقاً همان نشتی‌ای که در `GET /api/v1/appointment-service-slots` پیدا و رفع شد (`docs/architecture/tenancy.md`، جدول «uuid از درخواست»). خطاها **همه با هم** برگردانده می‌شوند، نه اولی. فرم انتخاب باید همهٔ ایرادها را یک‌جا نشان دهد. ## `ServiceOption` ```php class ServiceOption { use TenantOwnedTrait; // uuid از request می‌آید private ItemGroup $group; private string $name; private int $soloMinutes; // «زمان تنها» private ?int $additionalMinutes = null; // «زمان اضافه»؛ null → soloMinutes private int $priceRials = 0; private int $sortOrder = 0; private bool $active = true; } ``` `additionalMinutes` تهی‌پذیر عمدی است: مقدار null یعنی «تعریف نشده، محافظه‌کارانه رفتار کن» و همان `soloMinutes` را می‌گیرد. این باعث می‌شود مهاجرت داده‌های موجود بدون تغییر رفتار انجام شود؛ کلینیک بعداً عدد واقعی را وارد می‌کند و ظرفیتش آزاد می‌شود. ## `ServiceOptionRelation` ```php private ServiceOption $source; private ServiceOption $target; private string $type; // TYPE_INCOMPATIBLE | TYPE_REQUIRES ``` - `incompatible` **متقارن** است: ثبت (الف، ب) خودکار (ب، الف) را هم معنا می‌دهد. در repository با `WHERE (source IN :sel AND target IN :sel)` هر دو جهت پوشش داده می‌شود؛ ردیف دوم ذخیره نمی‌شود. - `requires` **جهت‌دار** است و باید بدون حلقه بماند. تشخیص حلقه با DFS هنگام ثبت (`ServiceOptionRelationService::assertNoCycle()`). ## قیمت با override شعبه ```php final class ServicePriceResolver { /** ترتیب: override شعبه ← تعرفهٔ سال جاری ← قیمت پایهٔ سرویس */ public function basePrice(ServiceItem $service, ?Branch $branch, int $at): int; } ``` `Tariff` موجود (سالانه) دست‌نخورده می‌ماند و در این زنجیره قرار می‌گیرد. لیست قیمت بازه‌دار کامل کار تسک ۰۸ است؛ اینجا فقط لایهٔ شعبه اضافه می‌شود. ## پنل ادمین `ServiceDetailPage.tsx` موجود یک تب می‌گیرد: «گروه‌ها و آیتم‌ها». - لیست گروه‌ها با `min/max` قابل ویرایش inline - زیر هر گروه، جدول آیتم‌ها با ستون‌های: نام، زمان تنها، زمان اضافه، قیمت، فعال - ناسازگاری/پیش‌نیاز با `SearchableSelect` چندانتخابی روی آیتم‌های همان سرویس - پیش‌نمایش زنده: «انتخاب صورت + بیکینی → ۲۳ دقیقه» با صدا زدن `POST /service-selection/validate` (debounce ۴۰۰ms) پیش‌نمایش زنده اختیاری نیست — بدون آن، کلینیک تفاوت «زمان تنها» و «زمان اضافه» را نمی‌فهمد و هر دو را یک عدد می‌گذارد، که یعنی کل این تسک بی‌اثر می‌شود.