Files
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

8.5 KiB

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

واژگان — مهم‌ترین نکتهٔ این تسک

مستند و کد فعلی دو واژهٔ متفاوت برای چیزهای متفاوت دارند و قاطی کردنشان کل تسک را خراب می‌کند:

مستند معادل در این کدبیس یعنی
دسته‌بندی (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 — قلب تسک

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

/** @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

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

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 شعبه

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)

پیش‌نمایش زنده اختیاری نیست — بدون آن، کلینیک تفاوت «زمان تنها» و «زمان اضافه» را نمی‌فهمد و هر دو را یک عدد می‌گذارد، که یعنی کل این تسک بی‌اثر می‌شود.