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