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)
پیش‌نمایش زنده اختیاری نیست — بدون آن، کلینیک تفاوت «زمان تنها» و «زمان اضافه» را
نمی‌فهمد و هر دو را یک عدد می‌گذارد، که یعنی کل این تسک بی‌اثر می‌شود.