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:
@@ -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)
|
||||
|
||||
پیشنمایش زنده اختیاری نیست — بدون آن، کلینیک تفاوت «زمان تنها» و «زمان اضافه» را
|
||||
نمیفهمد و هر دو را یک عدد میگذارد، که یعنی کل این تسک بیاثر میشود.
|
||||
Reference in New Issue
Block a user