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`)، تعداد، استخر، مهارت‌ها (چیپ)،
نوع اشغال (رادیو با توضیح فارسی هر گزینه)
- **نوار پیش‌نمایش زمانی**: چهار بخش روی یک خط با عرض متناسب مدت و آیکن منابع هر بخش
— این تنها راهی است که کاربر غیرفنی می‌فهمد چه ساخته