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.
This commit is contained in:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -0,0 +1,170 @@
# معماری — تسک ۰۴
## واژگان — مهم‌ترین نکتهٔ این تسک
مستند و کد فعلی دو واژهٔ متفاوت برای چیزهای متفاوت دارند و قاطی کردنشان کل تسک را خراب می‌کند:
| مستند | معادل در این کدبیس | یعنی |
|---|---|---|
| دسته‌بندی (`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)
پیش‌نمایش زنده اختیاری نیست — بدون آن، کلینیک تفاوت «زمان تنها» و «زمان اضافه» را
نمی‌فهمد و هر دو را یک عدد می‌گذارد، که یعنی کل این تسک بی‌اثر می‌شود.
@@ -0,0 +1,149 @@
# دیتابیس — تسک ۰۴
## تغییر جدول موجود: `service_items`
```sql
ALTER TABLE service_items
ADD COLUMN service_category_id INT NULL AFTER section_id,
ADD COLUMN session_count SMALLINT NOT NULL DEFAULT 1, -- ۱ = تک‌جلسه، >۱ = دوره‌ای
ADD COLUMN preparation_note TEXT NULL, -- «آماده‌سازی بیمار» — مستند بند ۵
ADD CONSTRAINT fk_service_items_category
FOREIGN KEY (service_category_id) REFERENCES service_categories(id) ON DELETE SET NULL,
ADD KEY idx_service_items_category (service_category_id);
```
`duration_minutes` موجود **حذف نمی‌شود** و همچنان «مدت پایهٔ سرویس» است. آیتم‌ها روی آن
اضافه می‌کنند. در حالت `booking_mode=service` فعلی هیچ تغییری در رفتار نیست چون هیچ
سرویسی گروه ندارد و `totalMinutes` برابر همان `duration_minutes` می‌ماند.
## `service_categories` — درختی
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `parent_id` | INT NULL | FK → خودش، ON DELETE RESTRICT |
| `name` | VARCHAR(150) NOT NULL | |
| `path` | VARCHAR(255) NOT NULL | materialized path: `/1/7/23/` |
| `depth` | TINYINT NOT NULL DEFAULT 0 | |
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_svc_cat_tenant (entity_type, entity_id, active)
KEY idx_svc_cat_parent (parent_id, sort_order)
KEY idx_svc_cat_path (path)
```
**materialized path** به‌جای adjacency خالص: تسک ۰۹ شرط «سرویس در دستهٔ جراحی یا
زیردسته‌هایش» را می‌خواهد و با `path LIKE '/1/7/%'` یک کوئری است، نه یک پیمایش بازگشتی.
سقف عمق: ۴. در `ServiceCategoryService` اجبار شود.
## `item_groups`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | از سرویس مشتق می‌شود |
| `service_item_id` | INT NOT NULL | FK → `service_items.id` ON DELETE CASCADE |
| `name` | VARCHAR(150) NOT NULL | «نواحی موردنظر» |
| `min_select` | SMALLINT NOT NULL DEFAULT 0 | ۰ = اختیاری |
| `max_select` | SMALLINT NULL | NULL = نامحدود |
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_item_groups_tenant (entity_type, entity_id, active)
KEY idx_item_groups_service (service_item_id, sort_order)
```
## `service_options` — «آیتم» مستند
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | از request می‌آید |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `item_group_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `name` | VARCHAR(150) NOT NULL | |
| `solo_minutes` | SMALLINT NOT NULL | «زمان تنها» |
| `additional_minutes` | SMALLINT NULL | «زمان اضافه»؛ NULL → `solo_minutes` |
| `price_rials` | INT NOT NULL DEFAULT 0 | |
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_service_options_tenant (entity_type, entity_id, active)
KEY idx_service_options_group (item_group_id, sort_order)
```
قید اپلیکیشنی: `additional_minutes <= solo_minutes` (زمان اضافه هرگز بیشتر از زمان تنها
نیست — آماده‌سازی که دو بار نمی‌شود). نقض → `422` با پیام روشن.
## `service_option_relations`
```sql
CREATE TABLE service_option_relations (
id INT PRIMARY KEY AUTO_INCREMENT,
source_option_id INT NOT NULL,
target_option_id INT NOT NULL,
type VARCHAR(20) NOT NULL, -- incompatible | requires
UNIQUE KEY uniq_sor (source_option_id, target_option_id, type),
KEY idx_sor_target (target_option_id, type),
CONSTRAINT fk_sor_source FOREIGN KEY (source_option_id) REFERENCES service_options(id) ON DELETE CASCADE,
CONSTRAINT fk_sor_target FOREIGN KEY (target_option_id) REFERENCES service_options(id) ON DELETE CASCADE
);
```
فرزند aggregate با ریشهٔ `ServiceOption`. قید اپلیکیشنی: `source != target`.
## `service_branch_overrides`
```sql
CREATE TABLE service_branch_overrides (
id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
service_item_id INT NOT NULL,
branch_id INT NOT NULL,
price_rials INT NULL, -- NULL = ارث از سرویس
duration_minutes SMALLINT NULL, -- NULL = ارث از سرویس
bookable TINYINT(1) NULL, -- NULL = ارث؛ 0 = این شعبه ارائه نمی‌دهد
created_at INT NOT NULL,
updated_at INT NOT NULL,
UNIQUE KEY uniq_sbo (service_item_id, branch_id),
KEY idx_sbo_tenant (entity_type, entity_id),
KEY idx_sbo_branch (branch_id),
CONSTRAINT fk_sbo_service FOREIGN KEY (service_item_id) REFERENCES service_items(id) ON DELETE CASCADE,
CONSTRAINT fk_sbo_branch FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE
);
```
سه ستون تهی‌پذیرند تا override جزئی ممکن باشد: فقط قیمت، بدون دست زدن به مدت.
## Migration و backfill
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
backfill لازم نیست: سرویس‌های موجود گروه ندارند، `DurationCalculator` مقدار
`service_items.duration_minutes` را برمی‌گرداند و رفتار حالت `service` بدون تغییر می‌ماند.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `service_categories`, `item_groups`, `service_options`, `service_branch_overrides` | جفت tenant |
| `service_option_relations` | `AGGREGATE_CHILDREN` → ریشه `ServiceOption` |
`TenantLookupInventoryTest` شمارنده دارد؛ `findByUuid` های جدید (`ServiceOptionRepository`,
`ItemGroupRepository`) باید با `TenantOwnershipChecker` جفت شوند و بعد عدد به‌روز شود.
@@ -0,0 +1,133 @@
# نکات پیاده‌سازی — تسک ۰۴
## ۱. `ServiceItem` را تغییر نام نده
در این جدول‌ها و کدها به آن ارجاع هست:
```
appointment_service_items · service_item_staff · service_item_consumables
service_item_audit_logs · tariffs.service_item_id · session_services
appointments.service_item_id
```
و در `nobat724_front/services/response.js` و `clinic-pro-tauri/src/service/response.js`
کلید `service_item_uuid` در بدنهٔ رزرو می‌رود. تغییر نام یعنی شکستن سه ریپو بدون یک
خطای build. کلاس جدید `ServiceOption` بساز و در `docs/api/clinic-services.md` جدول
واژگان (فایل architecture) را عیناً بنویس.
## ۲. `/service-selection/validate` هم عمومی است هم پنلی
سایت عمومی بدون توکن آن را صدا می‌زند (بیمار هنوز وارد نشده). پس:
- در `security.yaml` مسیرش را whitelist کن
- بدون کاربر احراز شده، `TenantFilter` خاموش است → **گارد دستی اجباری است**:
محیط از `doctor_uuid` + `clinic_uuid` درخواست حل می‌شود و همهٔ uuid ها با
`TenantOwnershipChecker::belongsToPair()` سنجیده می‌شوند
- نرخ‌محدودسازی: این endpoint یک enumerate کنندهٔ کاتالوگ است. `symfony/rate-limiter`
روی IP، مثل بقیهٔ endpoint های عمومی
این دقیقاً همان اشتباهی است که یک بار در `GET /api/v1/appointment-service-slots` رخ داد و
در فاز ۸ tenancy رفع شد. تکرارش نکن.
## ۳. قطعیت محاسبهٔ مدت
دو انتخاب یکسان با ترتیب متفاوت باید **همیشه** یک عدد بدهند. تست:
```php
$a = $calc->totalMinutes($service, [$face, $bikini]);
$b = $calc->totalMinutes($service, [$bikini, $face]);
self::assertSame($a, $b);
```
اگر این تست نباشد، اولین بهینه‌سازی که ترتیب آرایه را عوض کند، قیمت‌ها را تغییر می‌دهد و
هیچ‌کس نمی‌فهمد چرا.
## ۴. تصمیم: مرتب‌سازی نزولی بر اساس «زمان تنها»
مستند نگفته کدام آیتم «اولی» است. سه گزینه بررسی شد:
| گزینه | مشکل |
|---|---|
| ترتیب انتخاب کاربر | غیرقطعی — همان انتخاب دو مدت می‌دهد |
| `sort_order` تعریف‌شده | کلینیک باید برای هر ترکیب فکر کند؛ عملاً پر نمی‌شود |
| **بیشترین «زمان تنها»** ✅ | قطعی، بدون ورودی اضافه، و از نظر کسب‌وکار درست: کار بزرگ‌تر آماده‌سازی را می‌بلعد |
انتخاب سوم. دلیلش را در کد به‌صورت کامنت بنویس، وگرنه اولین بازبینی‌کننده آن را
«مرتب‌سازی بی‌دلیل» می‌بیند و حذفش می‌کند.
## ۵. `additional_minutes = null` یعنی محافظه‌کار
```php
$rest->getAdditionalMinutes() ?? $rest->getSoloMinutes()
```
نه صفر. اگر null را صفر بگیری، سرویس‌های موجود که این ستون را ندارند یک‌شبه مدتشان
نصف می‌شود و ظرفیت الکی باز می‌شود — یعنی نوبت روی نوبت.
## ۶. ناسازگاری متقارن، پیش‌نیاز جهت‌دار
```php
// ناسازگاری — یک ردیف کافی است، هر دو جهت پرس‌وجو می‌شوند
$conflicts = $relationRepo->createQueryBuilder('r')
->where('r.type = :incompatible')
->andWhere('r.source IN (:sel) AND r.target IN (:sel)')
->setParameter('sel', $selectedIds)
->getQuery()->getResult();
```
پیش‌نیاز جهت‌دار است و حلقه ممنوع. `assertNoCycle()` با DFS هنگام **ثبت** اجرا شود، نه
هنگام اعتبارسنجی انتخاب — بررسی حلقه در مسیر داغ رزرو، هزینهٔ بی‌دلیل است.
## ۷. عمق درخت و حذف دسته
- سقف عمق ۴ (`depth <= 3` با ریشهٔ صفر)
- حذف دسته‌ای که فرزند یا سرویس دارد → `422`
- جابه‌جایی دسته → `path` همهٔ نوادگان با یک `UPDATE … SET path = REPLACE(path, :old, :new)`
به‌روز شود، در یک تراکنش
## ۸. edge case ها
| حالت | رفتار درست |
|---|---|
| سرویس بدون هیچ گروه | معتبر — رفتار امروزی، مدت = `duration_minutes` |
| گروه بدون هیچ آیتم فعال و `min_select=1` | انتخاب همیشه نامعتبر می‌شود → هشدار در پنل هنگام ذخیره |
| `min_select > max_select` | `422` |
| `max_select` بزرگ‌تر از تعداد آیتم‌های فعال | مجاز؛ عملاً یعنی نامحدود |
| `additional_minutes > solo_minutes` | `422` |
| آیتم غیرفعال در انتخاب | `422` با کد `inactive_option` |
| override شعبه با `bookable=0` | سرویس در آن شعبه در لیست رزرو نیاید |
| دو آیتم ناسازگار در دو گروه مختلف | همچنان ناسازگار — رابطه بین آیتم‌هاست، نه گروه‌ها |
| انتخاب آیتم از سرویس دیگر | `422` `option_not_in_service` (بعد از بررسی tenant) |
## ۹. تست
```
tests/ClinicService/DurationCalculatorTest.php
- یک آیتم → solo
- دو آیتم یک گروه → solo(بزرگ‌تر) + additional(کوچک‌تر)
- دو گروه → هر گروه solo خودش
- additional=null → از solo استفاده شود
- قطعیت: جابه‌جایی ترتیب ورودی، همان عدد
tests/ClinicService/ServiceSelectionValidatorTest.php
- min_select نقض → کد min_select
- max_select نقض → کد max_select
- ناسازگار → کد incompatible با نام هر دو
- پیش‌نیاز غایب → کد missing_prerequisite
- چند خطا هم‌زمان → همه با هم برگردند
- uuid محیط دیگر → 404 و هیچ اطلاعاتی در بدنه
tests/ClinicService/ServicePriceResolverTest.php
- override شعبه بر تعرفه اولویت دارد
- override جزئی (فقط قیمت) مدت را دست نمی‌زند
tests/ClinicService/ServiceCategoryTreeTest.php
- عمق ۵ → 422 · حذف دستهٔ دارای فرزند → 422 · جابه‌جایی path نوادگان
tests/ClinicService/BackwardCompatibilityTest.php
- سرویس بدون گروه: appointment-service-slots دقیقاً همان خروجی قبلی
```
آخرین تست مهم‌ترین است: **این تسک نباید رفتار نوبت‌دهی سرویسی فعلی را تغییر دهد.**
## ۱۰. مستندات
`docs/api/clinic-services.md` به‌روزرسانی با جدول واژگان + endpoint های جدید.
یادآوری: قرارداد `POST /service-selection/validate` را `nobat724_front` مصرف می‌کند و
شکستنش در build خطا نمی‌دهد.
@@ -0,0 +1,89 @@
# تسک ۰۴ — کاتالوگ خدمات نسخهٔ ۲: گروه آیتم، دو نوع زمان، ناسازگاری
**فاز:** ۱ (هسته) · **وابستگی:** ۰۱ · **زمان:** ۱۴-۱۶ ساعت
---
## هدف
مستند بند ۵ می‌گوید انتخاب آیتم خودش قانون دارد و این قوانین **نباید** به موتور قوانین
سپرده شوند: «حتماً یک سطح انرژی، فقط یکی»، «بین ۱ تا ۸ دندان»، «بیکینی با فول‌بادی
جمع نمی‌شود». و مهم‌تر: هر آیتم دو زمان دارد — «زمان تنها» و «زمان اضافه».
## وضعیت فعلی
```php
// src/ClinicService/Entity/ServiceItem.php
private ?int $durationMinutes = null; // یک عدد، تخت
private int $priceRials = 0;
private bool $bookable = false;
```
و در `AppointmentController::serviceSlots()`:
```php
$totalMinutes += $duration; // ← جمع ساده؛ همان فرمولی که مستند ردش می‌کند
```
نتیجه: بیمار که «صورت + بیکینی» می‌خواهد، ۱۵+۱۵=۳۰ دقیقه ظرفیت می‌گیرد در حالی که
واقعیت ۱۵+۸=۲۳ دقیقه است. هفت دقیقه ضرب در روزی ۲۰ نوبت = یک ساعت ظرفیت هدررفته در روز.
همچنین `ServiceSection` تک‌سطحی است و دسته‌بندی درختی مستند را ندارد.
## دامنه
**هست:**
- `ServiceCategory` درختی (جدا از `ServiceSection` موجود که «بخش کلینیک» است)
- `ItemGroup` با `min_select` / `max_select`
- روی `ServiceItem`: `solo_duration_minutes` و `additional_duration_minutes`
- `ServiceItemRelation` برای `incompatible_with` و `requires`
- `ServiceBranchOverride` برای قیمت و مدت اختصاصی شعبه
- `session_count` روی سرویس (تک‌جلسه یا دوره‌ای — پروتکل کاملش تسک ۱۲)
- `ServiceSelectionValidator` — اعتبارسنجی انتخاب کاربر پیش از هر محاسبه
- `DurationCalculator` — محاسبهٔ درست مدت با دو نوع زمان
**نیست:** بخش‌های نوبت و نیازمندی منبع (تسک ۰۵)، اعمال روی جستجوی وقت (تسک ۰۶).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/v1/service-categories/tree` | درخت دسته‌بندی |
| POST/PATCH/DELETE | `/api/v1/service-category[/{uuid}]` | |
| GET/POST | `/api/v1/service-item/{uuid}/groups` | گروه‌های آیتم یک سرویس |
| PATCH/DELETE | `/api/v1/item-group/{uuid}` | |
| PUT | `/api/v1/item-group/{uuid}/items` | جایگزینی کامل آیتم‌های گروه |
| PUT | `/api/v1/service-item/{uuid}/relations` | ناسازگاری و پیش‌نیاز |
| PUT | `/api/v1/service-item/{uuid}/branch-overrides` | قیمت/مدت per شعبه |
| POST | `/api/v1/service-selection/validate` | اعتبارسنجی انتخاب + مدت و قیمت محاسبه‌شده |
`POST /service-selection/validate` مهم‌ترین endpoint این تسک است: سایت عمومی و پنل هر دو
پیش از رفتن به مرحلهٔ انتخاب زمان، آن را صدا می‌زنند.
## معیار پذیرش
- ✅ موفق: سرویس «لیزر» با گروه «نواحی» (`min=1, max=8`) و آیتم‌های صورت (تنها ۱۵، اضافه ۸)
و بیکینی (تنها ۱۲، اضافه ۸). انتخاب هر دو →
`POST /service-selection/validate` برمی‌گرداند `total_duration_minutes = 23`
(اولین آیتم زمان تنها، بقیه زمان اضافه) و `valid = true`.
- ✅ موفق: انتخاب فقط بیکینی → `total_duration_minutes = 12`.
- ✅ موفق: شعبهٔ مرکزی برای همین سرویس `price_rials` بالاتر دارد →
با `branch_uuid` مرکزی، قیمت override اعمال می‌شود.
- ❌ خطا: انتخاب صفر آیتم از گروهی با `min_select=1``valid=false` با
`errors[{group_uuid, code: 'min_select', message: 'انتخاب حداقل یک مورد از «نواحی» الزامی است'}]`.
- ❌ خطا: انتخاب ۹ آیتم از گروهی با `max_select=8``valid=false` با کد `max_select`.
- ❌ خطا: انتخاب دو آیتم ناسازگار → `valid=false` با کد `incompatible` و نام هر دو آیتم.
- ❌ خطا: انتخاب آیتمی که پیش‌نیازش انتخاب نشده → `valid=false` با کد `missing_prerequisite`.
- ❌ خطا: uuid آیتم از محیط دیگر → `404` (نه ۴۲۲ — نباید وجودش لو برود).
- ⚠️ مرزی: `max_select = null` یعنی نامحدود.
- ⚠️ مرزی: گروه با `min_select = 0` یعنی اختیاری.
- ⚠️ مرزی: آیتم بدون `additional_duration_minutes` → از `solo_duration_minutes` استفاده شود
(سازگاری با داده‌های موجود که فقط یک `duration_minutes` دارند).
- ⚠️ مرزی: حلقهٔ پیش‌نیاز (الف پیش‌نیاز ب، ب پیش‌نیاز الف) → `422` هنگام ثبت رابطه.
## خروجی
- توسعهٔ `src/ClinicService/` (بدون شکستن endpoint های موجود)
- `assets/admin/pages/ServiceDetailPage.tsx` توسعه: تب «گروه‌ها و آیتم‌ها»
- `docs/api/clinic-services.md` به‌روزرسانی
- migration + backfill: `duration_minutes` موجود → `solo_duration_minutes`