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,196 @@
# معماری — تسک ۰۵
## ساختار فایل
```
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`)، تعداد، استخر، مهارت‌ها (چیپ)،
نوع اشغال (رادیو با توضیح فارسی هر گزینه)
- **نوار پیش‌نمایش زمانی**: چهار بخش روی یک خط با عرض متناسب مدت و آیکن منابع هر بخش
— این تنها راهی است که کاربر غیرفنی می‌فهمد چه ساخته
@@ -0,0 +1,95 @@
# دیتابیس — تسک ۰۵
## `segment_templates`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | از request می‌آید |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `owner_type` | VARCHAR(10) NOT NULL | `service` \| `option` |
| `service_item_id` | INT NULL | FK → `service_items.id` ON DELETE CASCADE |
| `service_option_id` | INT NULL | FK → `service_options.id` ON DELETE CASCADE |
| `name` | VARCHAR(150) NOT NULL | |
| `segment_type` | VARCHAR(40) NOT NULL | کلید ادغام |
| `sequence` | SMALLINT NOT NULL | |
| `fixed_minutes` | SMALLINT NULL | |
| `duration_share` | SMALLINT NULL | درصد ۰..۱۰۰ |
| `patient_present` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `mergeable` | TINYINT(1) NOT NULL DEFAULT 0 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_seg_tpl_tenant (entity_type, entity_id, active)
KEY idx_seg_tpl_service (service_item_id, sequence)
KEY idx_seg_tpl_option (service_option_id, sequence)
```
قیدهای اپلیکیشنی (در سازنده/سرویس، نه `CHECK`):
- دقیقاً یکی از `service_item_id` / `service_option_id` غیر-NULL و با `owner_type` سازگار
- دقیقاً یکی از `fixed_minutes` / `duration_share` غیر-NULL
- جمع `duration_share` بخش‌های یک سرویس = ۱۰۰ (اگر حداقل یکی سهمی باشد)
## `segment_requirements`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `segment_template_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `resource_type_id` | INT NOT NULL | FK → `resource_types.id` ON DELETE RESTRICT |
| `count` | SMALLINT NOT NULL DEFAULT 1 | |
| `resource_pool_id` | INT NULL | FK → `resource_pools.id` ON DELETE SET NULL |
| `specific_resource_id` | INT NULL | FK → `clinic_resources.id` ON DELETE SET NULL |
| `required_skills` | JSON NULL | آرایهٔ `skill_id` |
| `constraints` | JSON NULL | فهرست بسته — جدول architecture |
| `occupancy` | VARCHAR(10) NOT NULL DEFAULT 'exclusive' | `exclusive`\|`shared`\|`passive` |
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
```sql
KEY idx_seg_req_segment (segment_template_id, sort_order)
KEY idx_seg_req_pool (resource_pool_id)
```
فرزند aggregate با ریشهٔ `SegmentTemplate` — uuid از request فقط در
`PUT /segment-template/{uuid}/requirements` می‌آید که خودش از ریشه لنگر می‌خورد،
پس ستون tenant لازم ندارد.
> `required_skills` عمداً JSON است نه جدول واسط: همیشه کامل خوانده و کامل جایگزین می‌شود،
> و هیچ کوئری‌ای از سمت مهارت به نیازمندی نمی‌رود. جدول واسط اینجا فقط سه JOIN اضافه
> به مسیر داغ تسک ۰۶ می‌آورد.
## هیچ جدول جدیدی برای «برنامهٔ ساخته‌شده» نیست
`AppointmentPlan` یک DTO درون‌حافظه‌ای است، نه entity. ذخیره‌اش وقتی معنی پیدا می‌کند که
نوبت ثبت شود — که کار تسک ۰۷ است (`appointment_segments`).
دلیل: برنامه یک تابع خالص از (سرویس، آیتم‌ها، بیمار، شعبه، قوانین فعال) است. ذخیره‌کردنش
پیش از ثبت یعنی نگه‌داشتن حالت موقتی که باید منقضی شود — همان مسئله‌ای که رزرو موقت
تسک ۰۷ حل می‌کند و دو مکانیزم موازی لازم نیست.
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/console app:segment:seed-templates --preset=beauty --tenant=clinic:12 --force
```
`app:segment:seed-templates` سه پریست دارد (مستند بند ۱۷: «الگوی آماده برای هر نوع کلینیک»):
| پریست | بخش‌ها |
|---|---|
| `beauty` | آماده‌سازی ۵ · انتظار ۳۰ (فقط اتاق) · درمان (سهمی ۱۰۰) · مراقبت ۵ |
| `dental` | آماده‌سازی ۵ · درمان (سهمی ۱۰۰) · تمیزکاری یونیت ۱۰ (بدون حضور بیمار) |
| `physio` | درمان (سهمی ۱۰۰) |
dry-run پیش‌فرض. پریست‌ها روی سرویس‌های موجود اعمال نمی‌شوند مگر با `--service=<uuid>`.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `segment_templates` | جفت tenant |
| `segment_requirements` | `AGGREGATE_CHILDREN` → ریشه `SegmentTemplate` |
@@ -0,0 +1,131 @@
# نکات پیاده‌سازی — تسک ۰۵
## ۱. برنامه یک تابع خالص است
`AppointmentPlanBuilder::build()` نباید چیزی بنویسد، چیزی cache کند، یا به `time()` نگاه کند.
ورودی یکسان → خروجی یکسان. دلیلش تسک ۰۶ است: موتور جستجو یک برنامه می‌سازد و آن را
برای ۹۰ روز × ده‌ها نقطهٔ شروع استفاده می‌کند. اگر ساختن برنامه عوارض جانبی داشته باشد،
جستجو یا کند می‌شود یا نتیجهٔ ناپایدار می‌دهد.
تنها I/O مجاز: خواندن الگوها و منابع کاندید (مرحلهٔ ۶).
## ۲. `offset_minutes` نسبی است، نه مطلق
بخش‌ها با فاصله از **شروع نوبت** ذخیره می‌شوند، نه timestamp:
```
بخش ۱ — offset 0, duration 5
بخش ۲ — offset 5, duration 30
بخش ۳ — offset 35, duration 20
بخش ۴ — offset 55, duration 5
```
تسک ۰۶ همین برنامه را روی هر نقطهٔ شروع کاندید «می‌لغزاند». اگر offset مطلق بود، برای هر
نقطهٔ شروع باید برنامه از نو ساخته می‌شد.
## ۳. `setup/cleanup` منبع کجا اعمال می‌شود
**نه در برنامه، در اشغال.** برنامه می‌گوید «اپراتور از دقیقهٔ ۳۵ تا ۵۵ لازم است». اشغال
واقعی همان منبع، با `setup=5, cleanup=10`، از دقیقهٔ ۳۰ تا ۶۵ است.
پس `PlannedRequirement` دو مقدار می‌دهد:
```php
public readonly int $startOffset; // ۳۵ — چیزی که به بیمار نشان داده می‌شود
public readonly int $endOffset; // ۵۵
public readonly int $occupancyStartOffset; // ۳۰ — چیزی که تسک ۰۶/۰۷ استفاده می‌کند
public readonly int $occupancyEndOffset; // ۶۵
```
محاسبه‌شان اینجا انجام می‌شود چون `setup/cleanup` per منبع کاندید متفاوت است و باید
بعد از مرحلهٔ ۶ (پیدا کردن کاندیدها) حساب شود — با بیشینهٔ کاندیدها، تا برنامه محافظه‌کار
بماند و تسک ۰۶ بعد از انتخاب منبع دقیقش کند.
## ۴. ادغام: `count` بیشینه، نه جمع
وقتی دو بخش هم‌نوع ادغام می‌شوند و هر دو «۱ اپراتور» می‌خواهند، نتیجه **۱** اپراتور است،
نه ۲. همان اپراتور هر دو کار را می‌کند.
جمع فقط وقتی درست است که دو کار واقعاً هم‌زمان انجام شوند — که در بخش‌های ادغام‌شده
(که ذاتاً یک کار شده‌اند) صدق نمی‌کند.
## ۵. قید جنسیت — سکوت ممنوع
```php
if (($req->constraints['same_gender_as_patient'] ?? false) && $patient?->getGender() === null) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'برای این سرویس ثبت جنسیت بیمار الزامی است',
422
);
}
```
نادیده گرفتنِ قید وقتی داده نیست، بدترین حالت است: در کلینیک زیبایی ایران این یک الزام
جدی است (مستند بند ۶) و نقض خاموشش یعنی بیمار سر قرار با اپراتور نامناسب روبه‌رو می‌شود.
## ۶. `occupancy` سه حالت — تفاوت عملی
| حالت | در تسک ۰۶ | در تسک ۰۷ |
|---|---|---|
| `exclusive` | منبع باید کاملاً آزاد باشد | یک ردیف اشغال با `units = capacity` |
| `shared` | `COUNT(اشغال‌های فعال) < capacity` | یک ردیف با `units = 1` |
| `passive` | مثل `exclusive` | ردیف اشغال با پرچم `passive` — گزارش بهره‌وری آن را «کارِ فعال» حساب نمی‌کند |
اینجا فقط ستون و اعتبارسنجی است؛ معنی‌شان در ۰۶ و ۰۷ پیاده می‌شود. ولی تفاوت را همین
حالا در `docs/api/appointment-plan.md` بنویس.
## ۷. سقف‌ها — حفاظت از تسک ۰۶
| سقف | مقدار | چرا |
|---|---|---|
| تعداد بخش یک برنامه | ۲۰ | هر بخش یعنی یک بررسی تداخل per نقطهٔ شروع |
| مجموع مدت برنامه | ۴۸۰ دقیقه | برنامهٔ طولانی‌تر عملاً هیچ روزی جا نمی‌شود |
| تعداد نیازمندی هر بخش | ۱۰ | |
| تعداد آیتم انتخابی | ۲۰ | (تسک ۰۴ هم `max_select` دارد، این سقف کلی است) |
نقض → `422` با پیام روشن، نه تلاش برای محاسبه. مستند بند ۱۷ ریسک «کند شدن جستجو با
زیاد شدن منابع» را اولین ریسک می‌داند؛ سقف‌ها ارزان‌ترین دفاع‌اند.
## ۸. edge case ها
| حالت | رفتار درست |
|---|---|
| سرویس بدون الگو | یک بخش مجازی با منبع `type=doctor` — رفتار امروزی |
| بخش با `requirements = []` | معتبر؛ زمان می‌گیرد، منبع نمی‌گیرد |
| همهٔ بخش‌ها `fixed_minutes` و آیتم‌ها مدت دارند | مدت آیتم‌ها **نادیده** نمی‌رود: اگر هیچ بخش سهمی نباشد و آیتم مدت داشته باشد → `422` هنگام ذخیرهٔ الگو |
| `duration_share` جمعش ۹۹ | `422` هنگام ذخیره |
| دو بخش با یک `sequence` | ترتیب بر اساس `id` پایدار شود، نه خطا |
| بخش آیتمی که آیتمش انتخاب نشده | در برنامه نمی‌آید |
| `specific_resource` غیرفعال | `candidates=[]` → خطای انسانی |
| استخر خالی | همان |
| بیمار مهمان (بدون پرونده) و قید جنسیت | جنسیت از فرم رزرو (`patient_gender` روی `Appointment` هست) گرفته شود |
## ۹. تست
```
tests/Appointment/Plan/SegmentAssemblerTest.php
- جمع‌آوری بخش سرویس + بخش آیتم
- ادغام دو بخش هم‌نوع mergeable → یکی، count بیشینه نه جمع
- بخش غیر-mergeable هم‌نوع → جدا می‌ماند
tests/Appointment/Plan/SegmentDurationResolverTest.php
- fixed ثابت می‌ماند وقتی آیتم‌ها زیاد شوند
- سهمی با مدت محاسبه‌شدهٔ تسک ۰۴ مقیاس می‌گیرد
- جمع مدت بخش‌ها = total_minutes
tests/Appointment/Plan/AppointmentPlanBuilderTest.php
- سناریوی کامل مستند: چهار بخش، offset های ۰/۵/۳۵/۵۵، total=60
- سرویس بدون الگو → یک بخش با منبع doctor (سازگاری)
- قطعیت: دو بار build با ورودی یکسان → خروجی یکسان
tests/Appointment/Plan/RequirementResolverTest.php
- مهارت موجود → کاندید
- هیچ کاندید → NoEligibleResourceException با پیام شامل نقش و مهارت
- قید جنسیت با بیمار بدون جنسیت → 422
- منبع محیط دیگر هرگز کاندید نمی‌شود
tests/Appointment/Plan/PlanLimitsTest.php
- ۲۱ بخش → 422 · ۴۸۱ دقیقه → 422
```
## ۱۰. مستندات
`docs/api/appointment-plan.md` بساز — شامل جدول سه حالت اشغال، مثال کامل خروجی
`preview`، و توضیح تفاوت `offset` نمایشی با `occupancy_offset`.
@@ -0,0 +1,102 @@
# تسک ۰۵ — بخش‌های نوبت و سازندهٔ برنامه
**فاز:** ۱ (هسته) · **وابستگی:** ۰۲، ۰۴ · **زمان:** ۱۶-۲۰ ساعت
---
## هدف
مهم‌ترین بخش مستند (بند ۷). یک نوبت یک تکه زمان پیوسته نیست:
| بخش | مدت | اتاق | اپراتور | دستگاه |
|---|---|---|---|---|
| مالیدن کرم بی‌حسی | ۵ | اشغال | اشغال | آزاد |
| انتظار اثر کرم | ۳۰ | اشغال | **آزاد** | آزاد |
| خود لیزر | ۲۰ | اشغال | اشغال | اشغال |
| مراقبت بعد | ۵ | اشغال | اشغال | آزاد |
با مدل امروز اپراتور ۶۰ دقیقه قفل می‌شود در حالی که ۳۰ دقیقه کار می‌کند. نصف ظرفیت
هدر می‌رود.
## وضعیت فعلی
```php
// src/Appointment/Entity/Appointment.php
private int $slotStart; // یک بازهٔ پیوسته
private int $slotEnd;
```
هیچ مفهومی از بخش، و هیچ نیازمندی منبعی وجود ندارد. تنها منبعی که تداخلش بررسی می‌شود
پزشک است (`AppointmentRepository::isSlotTaken`).
## دامنه
**هست:**
- `SegmentTemplate` — الگوی بخش‌های یک سرویس (و بخش‌های اضافهٔ هر `ServiceOption`)
- `SegmentRequirement` — نیازمندی منبع هر بخش: نقش، تعداد، شرط مهارت، قید، نوع اشغال
- `AppointmentPlanBuilder` — از (سرویس، آیتم‌ها، بیمار، شعبه) یک **برنامهٔ نوبت** می‌سازد
- ادغام بخش‌های هم‌نوع، محاسبهٔ مدت هر بخش، چیدمان ترتیبی
- `GET /api/v1/appointment-plan/preview` برای دیدن برنامه پیش از جستجوی وقت
**نیست:** پیدا کردن منابع آزاد و زمان (تسک ۰۶)، ثبت اشغال (تسک ۰۷)،
اعمال قوانین روی برنامه (تسک ۰۹ — نقطهٔ اتصالش اینجا آماده می‌شود).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET/PUT | `/api/v1/service-item/{uuid}/segments` | الگوی بخش‌های سرویس |
| PUT | `/api/v1/segment-template/{uuid}/requirements` | نیازمندی‌های منبع یک بخش |
| POST | `/api/v1/appointment-plan/preview` | ساخت و برگرداندن برنامهٔ نوبت (بدون ثبت) |
## خروجی `POST /appointment-plan/preview`
```json
{
"success": true,
"data": {
"total_minutes": 60,
"segments": [
{ "sequence": 1, "name": "بی‌حسی موضعی", "offset_minutes": 0, "duration_minutes": 5,
"patient_present": true,
"requirements": [
{ "role": "room", "count": 1, "occupancy": "exclusive", "candidates": 3 },
{ "role": "operator", "count": 1, "occupancy": "exclusive", "candidates": 2 }
] },
{ "sequence": 2, "name": "انتظار", "offset_minutes": 5, "duration_minutes": 30,
"patient_present": true, "mergeable": true,
"requirements": [ { "role": "room", "count": 1, "occupancy": "exclusive", "candidates": 3 } ] }
]
}
}
```
`candidates` تعداد منابع واجد شرایط است — اگر صفر باشد، برنامه ساخته نمی‌شود و خطای
انسانی برمی‌گردد: «هیچ اپراتور خانمی با مهارت لیزر در این شعبه نیست» (مستند بند ۱۰).
## معیار پذیرش
- ✅ موفق: سرویس «لیزر» با چهار بخش بالا تعریف می‌شود؛ `preview` برنامه‌ای با
`total_minutes = 60` و چهار بخش با `offset_minutes` صحیح (۰، ۵، ۳۵، ۵۵) برمی‌گرداند.
- ✅ موفق: انتخاب دو ناحیه (صورت + بیکینی) → بخش «آماده‌سازی» **یک بار** می‌آید
(`mergeable=true` هم‌نوع‌ها ادغام می‌شوند) ولی بخش «لیزر» مدتش با `DurationCalculator`
تسک ۰۴ محاسبه شده است.
- ✅ موفق: سرویسی که هیچ `SegmentTemplate` ندارد → برنامه‌ای با **یک بخش** برابر کل مدت
و نیازمندی پیش‌فرض (منبع `type=doctor`). این همان رفتار امروز است.
- ❌ خطا: نیازمندی با مهارتی که هیچ منبعی در آن شعبه ندارد →
`422` با `ERR_NO_ELIGIBLE_RESOURCE` و پیام فارسی شامل نقش و مهارت.
- ❌ خطا: بخش با `duration_minutes <= 0` و بدون منبع مدت پویا → `422`.
- ⚠️ مرزی: بخش با `requirements = []` (مثلاً «انتظار در خانه») → معتبر؛ زمان می‌گیرد،
هیچ منبعی نمی‌گیرد.
- ⚠️ مرزی: قید `same_gender_as_patient` وقتی جنسیت بیمار نامشخص است → نیازمندی نادیده
گرفته **نمی‌شود**؛ `422` با پیام «برای این سرویس ثبت جنسیت بیمار الزامی است».
- ⚠️ مرزی: `setup/cleanup` منبع در `preview` **نمایش داده نمی‌شود** ولی در
`occupancy_offset` هر نیازمندی می‌آید تا تسک ۰۶ همان را استفاده کند.
- ⚠️ مرزی: مجموع مدت بخش‌ها بیشتر از ۸ ساعت → `422` (حفاظت از جستجوی وقت).
## خروجی
- `src/Appointment/Plan/` — entity ها، `AppointmentPlanBuilder`، DTO ها
- `assets/admin/pages/ServiceSegmentsPage.tsx` + پیش‌نمایش برنامه
- `docs/api/appointment-plan.md`
- الگوهای آماده: `app:segment:seed-templates --preset=beauty|dental|physio` (مستند بند ۱۷)
@@ -0,0 +1,123 @@
# جریان کاربری — تسک ۰۵
## الف) کلینیک الگوی بخش‌های یک سرویس را تعریف می‌کند
```
پنل › خدمات › لیزر کندلا › تب «بخش‌های نوبت»
├─ «افزودن بخش»
│ نام: مالیدن کرم بی‌حسی
│ نوع (کلید ادغام): آماده‌سازی
│ مدت: ثابت — ۵ دقیقه
│ بیمار حاضر است: بله
│ با بخش‌های هم‌نوع ادغام شود: بله
│ └─ نیازمندی‌ها:
│ [اتاق] × ۱ — انحصاری
│ [اپراتور] × ۱ — انحصاری — مهارت: لیزر آلکساندرایت — قید: هم‌جنس با بیمار
├─ بخش ۲: انتظار اثر بی‌حسی — ثابت ۳۰ — ادغام‌پذیر
│ نیازمندی: فقط [اتاق] × ۱ انحصاری ← اپراتور اینجا آزاد است
├─ بخش ۳: خود لیزر — سهمی ۱۰۰٪
│ نیازمندی: [اتاق] × ۱ · [اپراتور] × ۱ · [دستگاه] × ۱ از استخر «لیزرهای آلکساندرایت»
└─ بخش ۴: مراقبت بعد — ثابت ۵
نیازمندی: [اتاق] × ۱ · [اپراتور] × ۱
نوار پیش‌نمایش زمانی (زیر فرم، زنده):
┌─────┬───────────────────┬─────────────┬─────┐
۵' │ ۳۰' │ ۲۰' │ ۵' │
│🏠👤 │ 🏠 │ 🏠 👤 🔧 │🏠👤 │
└─────┴───────────────────┴─────────────┴─────┘
کل: ۶۰ دقیقه · اپراتور واقعاً درگیر: ۳۰ دقیقه
«ذخیره» → PUT /api/v1/service-item/{uuid}/segments
```
خط «اپراتور واقعاً درگیر: ۳۰ دقیقه» مهم‌ترین بازخورد این صفحه است: کلینیک آنجا می‌فهمد
چرا این کار ارزشش را دارد.
---
## ب) بیمار سرویس و آیتم انتخاب می‌کند (سایت عمومی)
```
انتخاب پزشک/کلینیک
GET /api/v1/appointment-booking-services/{doctorUuid}
→ booking_mode = "resource" ← حالت جدید
→ services[] با گروه‌های آیتم
بیمار انتخاب می‌کند: ناحیه = صورت + بیکینی · سطح انرژی = ۱۶
POST /api/v1/service-selection/validate (تسک ۰۴، debounce)
→ valid: true · total_duration_minutes: 23 · total_price_rials: …
│ اگر valid=false:
│ خطاها زیر همان گروه نمایش داده می‌شوند
│ «انتخاب حداقل یک مورد از نواحی الزامی است»
│ «صورت و فول‌بادی با هم قابل انتخاب نیستند»
│ و دکمهٔ «ادامه» غیرفعال می‌ماند
POST /api/v1/appointment-plan/preview
→ total_minutes: 68
segments: [آماده‌سازی ۵ · انتظار ۳۰ · لیزر ۲۳ · مراقبت ۵ · تمیزکاری ۵]
│ اگر NoEligibleResourceException:
│ «هیچ اپراتور خانمی با مهارت لیزر آلکساندرایت در شعبهٔ مرکزی موجود نیست»
│ + پیشنهاد شعبهٔ دیگر (اگر داشته باشد)
مرحلهٔ انتخاب زمان → تسک ۰۶
```
**نکته UX:** برنامهٔ نوبت به بیمار **نمایش داده نمی‌شود**. بیمار فقط «۶۸ دقیقه» و
«توضیحات آماده‌سازی» را می‌بیند. بخش‌ها جزئیات عملیاتی کلینیک‌اند؛ نشان دادنشان به بیمار
فقط سؤال می‌سازد.
استثنا: بخش‌هایی با `patient_present = false` نباید در مدت اعلامی به بیمار بیایند
(«تمیزکاری یونیت» ۵ دقیقهٔ بعد از رفتن بیمار است). پس دو عدد وجود دارد:
- `total_minutes` = ۶۸ (اشغال کلینیک) — برای موتور
- `patient_facing_minutes` = ۶۳ — برای نمایش
هر دو در پاسخ `preview` برگردند.
---
## ج) منشی از پنل نوبت می‌سازد
همان جریان ب، با دو تفاوت:
1. `forManagement = true` — بازهٔ رزرو و خاموش بودن نوبت‌دهی آنلاین اعمال نمی‌شود
(همان رفتاری که `SlotCalculatorService::isWithinBookingWindow()` امروز دارد)
2. منشی می‌تواند **منبع را دستی انتخاب کند**: پاسخ `preview` برای هر نیازمندی
`candidates` را با نام برمی‌گرداند و پنل یک `SearchableSelect` اختیاری نشان می‌دهد.
خالی گذاشتن یعنی «تو انتخاب کن» (استراتژی تسک ۰۶).
---
## د) حالت خطا — هیچ منبعی موجود نیست
```
POST /appointment-plan/preview
422 {
"success": false,
"errors": [{
"code": "ERR_NO_ELIGIBLE_RESOURCE",
"message": "هیچ اپراتور خانمی با مهارت لیزر آلکساندرایت در شعبهٔ مرکزی موجود نیست",
"meta": { "segment": "خود لیزر", "role": "اپراتور", "branch_uuid": "…" }
}]
}
```
`meta` اجباری است: پنل با آن می‌تواند مستقیم به صفحهٔ منابع همان شعبه لینک بدهد
(«افزودن اپراتور») و کلینیک در سه کلیک مشکل را حل کند، به‌جای اینکه با یک پیام
بن‌بست بماند.