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,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` اجباری است: پنل با آن میتواند مستقیم به صفحهٔ منابع همان شعبه لینک بدهد
|
||||
(«افزودن اپراتور») و کلینیک در سه کلیک مشکل را حل کند، بهجای اینکه با یک پیام
|
||||
بنبست بماند.
|
||||
Reference in New Issue
Block a user