diff --git a/docs/api/README.md b/docs/api/README.md index 31ca6c05..5e602b76 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -85,6 +85,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb | [branch.md](branch.md) | Branches (= addresses), working hours, rooms | 8 | | [resource.md](resource.md) | Resources, types, skills, pools | 16 | | [resource-calendar.md](resource-calendar.md) | Resource calendars, exceptions, national holidays | 9 | +| [appointment-plan.md](appointment-plan.md) | Appointment segments and plan preview | 3 | | [appointment.md](appointment.md) | Appointments & slot booking | 6 | | [appointment-settings.md](appointment-settings.md) | Weekly schedule, date overrides, holidays | 14 | | [payment.md](payment.md) | Payments (Mellat / Sep) | 5 | diff --git a/docs/api/appointment-plan.md b/docs/api/appointment-plan.md new file mode 100644 index 00000000..80eafe12 --- /dev/null +++ b/docs/api/appointment-plan.md @@ -0,0 +1,136 @@ +# Appointment Plan API — بخش‌های نوبت و سازندهٔ برنامه + +> **Base:** `/api/v1` · **Auth:** JWT +> وابسته به [resource.md](resource.md) (منبع و مهارت) و [clinic-services.md](clinic-services.md) (مدت آیتم‌ها). + +--- + +## چرا نوبت یک تکه نیست + +بند ۷ مستند. یک جلسهٔ لیزر: + +| بخش | مدت | اتاق | اپراتور | دستگاه | +|---|---|---|---|---| +| مالیدن کرم بی‌حسی | ۵ | اشغال | اشغال | آزاد | +| انتظار اثر کرم | ۳۰ | اشغال | **آزاد** | آزاد | +| خود لیزر | ۲۰ | اشغال | اشغال | اشغال | +| مراقبت بعد | ۵ | اشغال | اشغال | آزاد | + +با مدل تک‌بازه‌ای، اپراتور ۶۰ دقیقه قفل می‌شود در حالی که ۳۰ دقیقه کار می‌کند — +**نصف ظرفیت هدر می‌رود**. + +⚠️ این بخش هیچ زمان مطلقی و هیچ منبع مشخصی تعیین نمی‌کند. فقط **شکل** نوبت را می‌سازد؛ +پیدا کردن وقت و منبع آزاد کارِ تسک بعدی است. + +--- + +## `GET/PUT /api/v1/service-item/{uuid}/segments` + +`PUT` جایگزینی کامل است. هر بخش: + +| فیلد | نوع | توضیح | +|---|---|---| +| `sequence` | int | ترتیب اجرا | +| `name` | string | ✅ الزامی | +| `duration_source` | `fixed` \| `items` | پیش‌فرض `fixed` | +| `duration_minutes` | int | برای `fixed` باید مثبت باشد | +| `patient_present` | bool | پیش‌فرض `true`؛ «انتظار در خانه» خلافش است | +| `mergeable` | bool | با چند آیتم **یک بار** می‌آید | +| `requirements` | array | نیازمندی منبع | + +**`duration_source: "items"`** یعنی مدت این بخش از آیتم‌های انتخاب‌شده می‌آید و با +فرمول تسک ۰۴ حساب می‌شود. «خود لیزر» با دو ناحیه طولانی‌تر می‌شود، ولی «انتظار اثر +کرم» نه — به همین دلیل دو منبع مدت لازم است و یک عدد ثابت کافی نیست. + +هر نیازمندی: + +| فیلد | توضیح | +|---|---| +| `type_uuid` | ✅ نوع منبع (اتاق، اپراتور، دستگاه) | +| `skill_uuid` | مهارت لازم | +| `count` | پیش‌فرض ۱ | +| `occupancy` | `exclusive` (قفل کامل) یا `shared` (ظرفیت می‌شمارد) | +| `constraints` | فعلاً فقط `same_gender_as_patient` | + +**۴۲۲:** نام خالی · بخش `fixed` با مدت صفر · مجموع بیش از ۴۸۰ دقیقه · `occupancy` یا +`constraint` ناشناخته. + +--- + +## `POST /api/v1/appointment-plan/preview` + +```json +{ + "service_uuid": "…لیزر", + "branch_uuid": "…شعبه", + "item_uuids": ["…صورت", "…بیکینی"], + "patient_gender": "female" +} +``` + +**۲۰۰:** + +```json +{ + "success": true, + "data": { + "total_minutes": 60, + "segments": [ + { "sequence": 1, "name": "بی‌حسی موضعی", "offset_minutes": 0, "duration_minutes": 5, + "patient_present": true, "mergeable": false, + "requirements": [ + { "role": "room", "role_name": "اتاق", "skill_name": null, "count": 1, + "occupancy": "exclusive", "constraints": [], "candidates": 3, + "occupancy_offset": { "setup_minutes": 0, "cleanup_minutes": 0 } } + ] } + ] + } +} +``` + +`candidates` تعداد منابع واجد شرایط است. `occupancy_offset` در نمای کاربر نمایش داده +**نمی‌شود**؛ برای موتور جستجوی وقت است و محافظه‌کارانه از بیشترین مقدارِ کاندیدها +گرفته می‌شود — کم گرفتنش یعنی نوبت بعدی روی زمان تمیزکاری بیفتد. + +### خطاها + +| کد | کِی | +|---|---| +| `ERR_NO_ELIGIBLE_RESOURCE` (۴۲۲) | هیچ منبعی شرایط یک بخش را ندارد | +| `ERR_VALIDATION_002` (۴۲۲) | قید جنسیت هست ولی جنسیت بیمار نامشخص است | +| `ERR_VALIDATION_001` (۴۲۲) | مدت بخش تعیین نشده · مجموع بیش از سقف | +| ۴۰۴ | سرویس یا شعبهٔ محیط دیگر | + +پیام `ERR_NO_ELIGIBLE_RESOURCE` انسانی است و نقش، مهارت و شعبه را می‌گوید: +«هیچ اپراتور خانمی با مهارت «لیزر آلکساندرایت» در شعبهٔ «مرکزی» موجود نیست» (بند ۱۰). + +--- + +## سه قرارداد + +**۱. سرویس بدون الگوی بخش، همان رفتار امروز را می‌گیرد.** یک بخش پیوسته به اندازهٔ کل +مدت که منبعِ `type=doctor` را می‌گیرد. بدون این، هر سرویس موجود بی‌برنامه می‌شد. + +**۲. بخش بدون هیچ نیازمندی معتبر است.** «انتظار در خانه» زمان می‌گیرد ولی هیچ منبعی +اشغال نمی‌کند. + +**۳. قید جنسیت وقتی جنسیت بیمار نامشخص است نادیده گرفته نمی‌شود.** ۴۲۲ می‌دهد، چون رد +کردن بی‌صدا یعنی بیمار به منبعی می‌رسد که قرار نبود. + +`mergeable` هم‌نام‌ها یک بار می‌آیند: «آماده‌سازی» با دو ناحیه یک بار انجام می‌شود. +تکرار شدنِ کارِ اصلی با `duration_source: "items"` بیان می‌شود، نه با تکرار بخش. + +--- + +## طبقه‌بندی محیط + +| جدول | وضعیت | +|---|---| +| `segment_templates` | جفت محیط (از بخشِ سرویس مشتق می‌شود) | +| `segment_requirements` | `AGGREGATE_CHILDREN` — ریشه `SegmentTemplate` | + +## تست‌ها + +```bash +ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php # ۱۱ تست +``` diff --git a/docs/new_feture/taskes/task-05-appointment-plan/checklist.md b/docs/new_feture/taskes/task-05-appointment-plan/checklist.md index 02b12b18..71a4f34f 100644 --- a/docs/new_feture/taskes/task-05-appointment-plan/checklist.md +++ b/docs/new_feture/taskes/task-05-appointment-plan/checklist.md @@ -53,18 +53,18 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `ServiceSegmentsPage` به‌عنوان زیرصفحهٔ `ServiceDetailPage` | ⏳ | | -| ۳.۲ | لیست بخش‌ها با `sequence` عددی (بدون drag) | ⏳ | | -| ۳.۳ | نیازمندی‌ها: نقش با `SearchableSelect`، مهارت با چیپ، اشغال با رادیو | ⏳ | | -| ۳.۴ | هر گزینهٔ نوع اشغال توضیح فارسی دارد | ⏳ | انحصاری/اشتراکی/منفعل | -| ۳.۵ | **نوار پیش‌نمایش زمانی** با عرض متناسب مدت و آیکن منابع | ⏳ | ⭐ تنها راه فهم کاربر غیرفنی | -| ۳.۶ | خط «اپراتور واقعاً درگیر: N دقیقه» زیر نوار | ⏳ | ⭐ ارزش کار را نشان می‌دهد | -| ۳.۷ | `backTo` روی صفحه | ⏳ | | -| ۳.۸ | هیچ رنگ/شعاع hard-code — نوار پیش‌نمایش هم | ⏳ | | -| ۳.۹ | دارک‌مود و حالت فشرده | ⏳ | | -| ۳.۱۰ | RTL و موبایل — نوار پیش‌نمایش روی موبایل اسکرول افقی داخلی دارد | ⏳ | | -| ۳.۱۱ | همهٔ رشته‌ها فارسی | ⏳ | | -| ۳.۱۲ | `meta` خطای «هیچ منبعی» به لینک «افزودن منبع» تبدیل شد | ⏳ | بن‌بست نباشد | +| ۳.۱ | `ServiceSegmentsPage` به‌عنوان زیرصفحهٔ `ServiceDetailPage` | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۲ | لیست بخش‌ها با `sequence` عددی (بدون drag) | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۳ | نیازمندی‌ها: نقش با `SearchableSelect`، مهارت با چیپ، اشغال با رادیو | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۴ | هر گزینهٔ نوع اشغال توضیح فارسی دارد | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۵ | **نوار پیش‌نمایش زمانی** با عرض متناسب مدت و آیکن منابع | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۶ | خط «اپراتور واقعاً درگیر: N دقیقه» زیر نوار | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۷ | `backTo` روی صفحه | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۸ | هیچ رنگ/شعاع hard-code — نوار پیش‌نمایش هم | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۹ | دارک‌مود و حالت فشرده | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۱۰ | RTL و موبایل — نوار پیش‌نمایش روی موبایل اسکرول افقی داخلی دارد | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۱۱ | همهٔ رشته‌ها فارسی | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | +| ۳.۱۲ | `meta` خطای «هیچ منبعی» به لینک «افزودن منبع» تبدیل شد | ⏳ | UI این تسک ساخته نشد — بک‌اند و `POST /appointment-plan/preview` کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI کاتالوگ و بخش‌ها | ## ۴. تست diff --git a/migrations/Version20260730173225.php b/migrations/Version20260730173225.php new file mode 100644 index 00000000..4683d12b --- /dev/null +++ b/migrations/Version20260730173225.php @@ -0,0 +1,41 @@ +addSql('CREATE TABLE segment_requirements (id INT AUTO_INCREMENT NOT NULL, count SMALLINT DEFAULT 1 NOT NULL, occupancy VARCHAR(12) DEFAULT \'exclusive\' NOT NULL, constraints JSON DEFAULT NULL, segment_id INT NOT NULL, resource_type_id INT NOT NULL, skill_id INT DEFAULT NULL, INDEX IDX_6701180998EC6B7B (resource_type_id), INDEX IDX_670118095585C142 (skill_id), INDEX idx_requirement_segment (segment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE segment_templates (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, sequence SMALLINT NOT NULL, name VARCHAR(150) NOT NULL, duration_source VARCHAR(10) DEFAULT \'fixed\' NOT NULL, duration_minutes SMALLINT DEFAULT 0 NOT NULL, patient_present TINYINT DEFAULT 1 NOT NULL, mergeable TINYINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, service_id INT NOT NULL, UNIQUE INDEX UNIQ_2BBC29FD17F50A6 (uuid), INDEX IDX_2BBC29FED5CA9E6 (service_id), INDEX idx_segment_tenant (entity_type, entity_id), INDEX idx_segment_service_seq (service_id, sequence), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE segment_requirements ADD CONSTRAINT FK_67011809DB296AAD FOREIGN KEY (segment_id) REFERENCES segment_templates (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE segment_requirements ADD CONSTRAINT FK_6701180998EC6B7B FOREIGN KEY (resource_type_id) REFERENCES resource_types (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE segment_requirements ADD CONSTRAINT FK_670118095585C142 FOREIGN KEY (skill_id) REFERENCES skills (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE segment_templates ADD CONSTRAINT FK_2BBC29FED5CA9E6 FOREIGN KEY (service_id) REFERENCES service_items (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE segment_requirements DROP FOREIGN KEY FK_67011809DB296AAD'); + $this->addSql('ALTER TABLE segment_requirements DROP FOREIGN KEY FK_6701180998EC6B7B'); + $this->addSql('ALTER TABLE segment_requirements DROP FOREIGN KEY FK_670118095585C142'); + $this->addSql('ALTER TABLE segment_templates DROP FOREIGN KEY FK_2BBC29FED5CA9E6'); + $this->addSql('DROP TABLE segment_requirements'); + $this->addSql('DROP TABLE segment_templates'); + } +} diff --git a/src/Appointment/Plan/Controller/AppointmentPlanController.php b/src/Appointment/Plan/Controller/AppointmentPlanController.php new file mode 100644 index 00000000..0e08517d --- /dev/null +++ b/src/Appointment/Plan/Controller/AppointmentPlanController.php @@ -0,0 +1,208 @@ +requireItem($user, $uuid); + + return $this->success(array_map( + static fn (SegmentTemplate $s): array => $s->toArray(), + $this->templates->findForService($service), + )); + } + + /** + * جایگزینی کامل بخش‌های یک سرویس، به‌همراه نیازمندی‌هایشان. + * + * همه‌چیز پیش از هر حذفی حل و اعتبارسنجی می‌شود — همان قرارداد بقیهٔ PUT های + * پروژه: بخش نامعتبر در انتهای فهرست نباید بخش‌های درستِ قبلی را پاک کند. + */ + #[Route('/api/v1/service-item/{uuid}/segments', name: 'service_segments_replace', methods: ['PUT'])] + public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_array($data['segments'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد segments الزامی است', 422, 'segments'); + } + + $service = $this->requireItem($user, $uuid); + $planned = []; + $total = 0; + + foreach ($data['segments'] as $index => $row) { + if (!is_array($row) || !is_string($row['name'] ?? null) || trim($row['name']) === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام هر بخش الزامی است', 422, 'name'); + } + + $source = is_string($row['duration_source'] ?? null) ? $row['duration_source'] : SegmentTemplate::DURATION_FIXED; + $minutes = is_numeric($row['duration_minutes'] ?? null) ? (int) $row['duration_minutes'] : 0; + + if (!in_array($source, SegmentTemplate::DURATION_SOURCES, true)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'منبع مدت بخش نامعتبر است', 422, 'duration_source'); + } + + if ($source === SegmentTemplate::DURATION_FIXED && $minutes <= 0) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + sprintf('بخش «%s» مدت مثبت لازم دارد', trim($row['name'])), + 422, + 'duration_minutes', + ); + } + + $total += $minutes; + + $requirements = []; + foreach (($row['requirements'] ?? []) as $req) { + if (!is_array($req) || !is_string($req['type_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'type_uuid هر نیازمندی الزامی است', 422, 'requirements'); + } + + $requirements[] = [ + 'type' => $this->resourceContext->type($user, $req['type_uuid']), + 'skill' => is_string($req['skill_uuid'] ?? null) ? $this->resourceContext->skill($user, $req['skill_uuid']) : null, + 'count' => is_numeric($req['count'] ?? null) ? max(1, (int) $req['count']) : 1, + 'occupancy' => is_string($req['occupancy'] ?? null) ? $req['occupancy'] : SegmentRequirement::OCCUPANCY_EXCLUSIVE, + 'constraints' => is_array($req['constraints'] ?? null) ? $req['constraints'] : [], + ]; + } + + $planned[] = [ + 'sequence' => is_numeric($row['sequence'] ?? null) ? (int) $row['sequence'] : $index + 1, + 'name' => trim($row['name']), + 'source' => $source, + 'minutes' => $minutes, + 'patient' => (bool) ($row['patient_present'] ?? true), + 'mergeable' => (bool) ($row['mergeable'] ?? false), + 'requirements' => $requirements, + ]; + } + + if ($total > SegmentTemplate::MAX_TOTAL_MINUTES) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + sprintf('مجموع مدت بخش‌ها از سقف %d دقیقه بیشتر است', SegmentTemplate::MAX_TOTAL_MINUTES), + 422, + 'segments', + ); + } + + $this->templates->deleteForService($service); + + foreach ($planned as $row) { + $template = new SegmentTemplate($service, $row['sequence'], $row['name']); + + try { + $template->setDuration($row['source'], $row['minutes']); + } catch (\InvalidArgumentException $e) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مدت بخش نامعتبر است', 422, 'duration_minutes'); + } + + $template->setPatientPresent($row['patient'])->setMergeable($row['mergeable']); + $this->em->persist($template); + + foreach ($row['requirements'] as $req) { + $requirement = new SegmentRequirement($template, $req['type'], $req['count']); + $requirement->setSkill($req['skill']); + + try { + $requirement->setOccupancy($req['occupancy'])->setConstraints($req['constraints']); + } catch (\InvalidArgumentException $e) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422, 'requirements'); + } + + $this->em->persist($requirement); + $template->getRequirements()->add($requirement); + } + } + + $this->em->flush(); + + return $this->success(array_map( + static fn (SegmentTemplate $s): array => $s->toArray(), + $this->templates->findForService($service), + )); + } + + /** برنامهٔ نوبت، بدون هیچ ثبتی — پیش از رفتن به جستجوی وقت. */ + #[Route('/api/v1/appointment-plan/preview', name: 'appointment_plan_preview', methods: ['POST'])] + public function preview(#[CurrentUser] User $user, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_string($data['service_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد service_uuid الزامی است', 422, 'service_uuid'); + } + + if (!is_string($data['branch_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد branch_uuid الزامی است', 422, 'branch_uuid'); + } + + $service = $this->requireItem($user, $data['service_uuid']); + $address = $this->branches->resolve($user, $data['branch_uuid']); + + $selected = []; + foreach (($data['item_uuids'] ?? []) as $itemUuid) { + if (!is_string($itemUuid)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'item_uuids باید فهرستی از uuid باشد', 422, 'item_uuids'); + } + + $selected[] = $this->requireItem($user, $itemUuid); + } + + $gender = is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null; + + return $this->success($this->builder->build($service, $selected, $address, $gender)->toArray()); + } + + private function requireItem(User $user, string $uuid): ServiceItem + { + $item = $this->items->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($item === null + || $item->getSection()->getEntityType() !== $entityType + || $item->getSection()->getEntityId() !== $entityId + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); + } + + return $item; + } +} diff --git a/src/Appointment/Plan/Entity/SegmentRequirement.php b/src/Appointment/Plan/Entity/SegmentRequirement.php new file mode 100644 index 00000000..0f7af633 --- /dev/null +++ b/src/Appointment/Plan/Entity/SegmentRequirement.php @@ -0,0 +1,127 @@ + 1])] + private int $count = 1; + + #[ORM\Column(type: 'string', length: 12, options: ['default' => self::OCCUPANCY_EXCLUSIVE])] + private string $occupancy = self::OCCUPANCY_EXCLUSIVE; + + /** @var string[] */ + #[ORM\Column(type: 'json', nullable: true)] + private ?array $constraints = null; + + public function __construct(SegmentTemplate $segment, ResourceType $resourceType, int $count = 1) + { + if ($count < 1) { + throw new \InvalidArgumentException('A requirement needs at least one resource.'); + } + + $this->segment = $segment; + $this->resourceType = $resourceType; + $this->count = $count; + } + + public function getId(): ?int { return $this->id; } + public function getSegment(): SegmentTemplate { return $this->segment; } + public function getResourceType(): ResourceType { return $this->resourceType; } + public function getSkill(): ?Skill { return $this->skill; } + public function getCount(): int { return $this->count; } + public function getOccupancy(): string { return $this->occupancy; } + + /** @return string[] */ + public function getConstraints(): array { return $this->constraints ?? []; } + + public function setSkill(?Skill $v): self { $this->skill = $v; return $this; } + + public function setOccupancy(string $v): self + { + if (!in_array($v, self::OCCUPANCIES, true)) { + throw new \InvalidArgumentException(sprintf('Unknown occupancy "%s".', $v)); + } + + $this->occupancy = $v; + + return $this; + } + + /** @param string[] $constraints */ + public function setConstraints(array $constraints): self + { + foreach ($constraints as $constraint) { + if (!in_array($constraint, self::CONSTRAINTS, true)) { + throw new \InvalidArgumentException(sprintf('Unknown constraint "%s".', $constraint)); + } + } + + $this->constraints = $constraints === [] ? null : array_values(array_unique($constraints)); + + return $this; + } + + public function requiresSameGender(): bool + { + return in_array(self::CONSTRAINT_SAME_GENDER, $this->getConstraints(), true); + } + + public function toArray(): array + { + return [ + 'role' => $this->resourceType->getCode(), + 'role_name' => $this->resourceType->getName(), + 'type_uuid' => $this->resourceType->getUuid(), + 'skill_uuid' => $this->skill?->getUuid(), + 'skill_name' => $this->skill?->getName(), + 'count' => $this->count, + 'occupancy' => $this->occupancy, + 'constraints' => $this->getConstraints(), + ]; + } +} diff --git a/src/Appointment/Plan/Entity/SegmentTemplate.php b/src/Appointment/Plan/Entity/SegmentTemplate.php new file mode 100644 index 00000000..aab65817 --- /dev/null +++ b/src/Appointment/Plan/Entity/SegmentTemplate.php @@ -0,0 +1,151 @@ + self::DURATION_FIXED])] + private string $durationSource = self::DURATION_FIXED; + + #[ORM\Column(name: 'duration_minutes', type: 'smallint', options: ['default' => 0])] + private int $durationMinutes = 0; + + /** بیمار در این بخش حاضر است؟ «انتظار در خانه» نمونهٔ خلافش است. */ + #[ORM\Column(name: 'patient_present', type: 'boolean', options: ['default' => true])] + private bool $patientPresent = true; + + /** + * با انتخاب چند آیتم، این بخش **یک بار** می‌آید نه چند بار. + * «آماده‌سازی» یک بار انجام می‌شود؛ «خود لیزر» به‌ازای هر ناحیه. + */ + #[ORM\Column(type: 'boolean', options: ['default' => false])] + private bool $mergeable = false; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: SegmentRequirement::class, mappedBy: 'segment', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $requirements; + + public function __construct(ServiceItem $service, int $sequence, string $name) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->service = $service; + $this->sequence = $sequence; + $this->name = $name; + $this->createdAt = time(); + $this->requirements = new ArrayCollection(); + + $this->assignTenantPair($service->getSection()->getEntityType(), $service->getSection()->getEntityId()); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getService(): ServiceItem { return $this->service; } + public function getSequence(): int { return $this->sequence; } + public function getName(): string { return $this->name; } + public function getDurationSource(): string { return $this->durationSource; } + public function getDurationMinutes(): int { return $this->durationMinutes; } + public function isPatientPresent(): bool { return $this->patientPresent; } + public function isMergeable(): bool { return $this->mergeable; } + + /** @return Collection */ + public function getRequirements(): Collection { return $this->requirements; } + + public function setPatientPresent(bool $v): self { $this->patientPresent = $v; return $this; } + public function setMergeable(bool $v): self { $this->mergeable = $v; return $this; } + + /** @throws \InvalidArgumentException روی منبع مدت ناشناخته یا مدت ناممکن */ + public function setDuration(string $source, int $minutes): self + { + if (!in_array($source, self::DURATION_SOURCES, true)) { + throw new \InvalidArgumentException(sprintf('Unknown duration source "%s".', $source)); + } + + // بخشی که مدتش از آیتم‌ها می‌آید، عدد ثابت لازم ندارد؛ بخش ثابت حتماً دارد. + if ($source === self::DURATION_FIXED && $minutes <= 0) { + throw new \InvalidArgumentException('A fixed segment needs a positive duration.'); + } + + if ($minutes > self::MAX_TOTAL_MINUTES) { + throw new \InvalidArgumentException('Segment duration exceeds the daily cap.'); + } + + $this->durationSource = $source; + $this->durationMinutes = max(0, $minutes); + + return $this; + } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'service_uuid' => $this->service->getUuid(), + 'sequence' => $this->sequence, + 'name' => $this->name, + 'duration_source' => $this->durationSource, + 'duration_minutes' => $this->durationMinutes, + 'patient_present' => $this->patientPresent, + 'mergeable' => $this->mergeable, + 'requirements' => array_map( + static fn (SegmentRequirement $r): array => $r->toArray(), + $this->requirements->toArray(), + ), + ]; + } +} diff --git a/src/Appointment/Plan/Repository/SegmentRequirementRepository.php b/src/Appointment/Plan/Repository/SegmentRequirementRepository.php new file mode 100644 index 00000000..82d0a283 --- /dev/null +++ b/src/Appointment/Plan/Repository/SegmentRequirementRepository.php @@ -0,0 +1,29 @@ + + */ +class SegmentRequirementRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SegmentRequirement::class); + } + + public function deleteForSegment(SegmentTemplate $segment): int + { + return (int) $this->createQueryBuilder('r') + ->delete() + ->where('r.segment = :segment') + ->setParameter('segment', $segment) + ->getQuery() + ->execute(); + } +} diff --git a/src/Appointment/Plan/Repository/SegmentTemplateRepository.php b/src/Appointment/Plan/Repository/SegmentTemplateRepository.php new file mode 100644 index 00000000..ef1d5337 --- /dev/null +++ b/src/Appointment/Plan/Repository/SegmentTemplateRepository.php @@ -0,0 +1,83 @@ + + */ +class SegmentTemplateRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SegmentTemplate::class); + } + + public function findByUuid(string $uuid): ?SegmentTemplate + { + return $this->findOneBy(['uuid' => $uuid]); + } + + /** + * الگوی بخش‌های یک سرویس، به‌همراه نیازمندی‌ها و نوع منبع و مهارتشان — یک کوئری، + * نه یکی per بخش. سازندهٔ برنامه همهٔ این‌ها را لازم دارد. + * + * @return SegmentTemplate[] + */ + public function findForService(ServiceItem $service): array + { + return $this->createQueryBuilder('s') + ->addSelect('r', 'rt', 'sk') + ->leftJoin('s.requirements', 'r') + ->leftJoin('r.resourceType', 'rt') + ->leftJoin('r.skill', 'sk') + ->where('s.service = :service') + ->setParameter('service', $service) + ->orderBy('s.sequence', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * @param int[] $serviceIds + * @return array شناسهٔ سرویس => بخش‌ها + */ + public function findForServices(array $serviceIds): array + { + if ($serviceIds === []) { + return []; + } + + $rows = $this->createQueryBuilder('s') + ->addSelect('r', 'rt', 'sk') + ->leftJoin('s.requirements', 'r') + ->leftJoin('r.resourceType', 'rt') + ->leftJoin('r.skill', 'sk') + ->where('IDENTITY(s.service) IN (:ids)') + ->setParameter('ids', $serviceIds) + ->orderBy('s.sequence', 'ASC') + ->getQuery() + ->getResult(); + + $byService = []; + foreach ($rows as $segment) { + $byService[(int) $segment->getService()->getId()][] = $segment; + } + + return $byService; + } + + public function deleteForService(ServiceItem $service): int + { + return (int) $this->createQueryBuilder('s') + ->delete() + ->where('s.service = :service') + ->setParameter('service', $service) + ->getQuery() + ->execute(); + } +} diff --git a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php new file mode 100644 index 00000000..b31bd822 --- /dev/null +++ b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php @@ -0,0 +1,279 @@ +durations->totalMinutes($selectedItems !== [] ? $selectedItems : [$service]); + $templates = $this->templates->findForService($service); + + // سرویسی که الگوی بخش ندارد، همان رفتار امروز را می‌گیرد: یک بخش پیوسته که + // پزشک را می‌گیرد. بدون این، هر سرویس موجود بی‌برنامه می‌شد. + if ($templates === []) { + return $this->singleSegmentPlan($service, $address, $itemMinutes, $patientGender); + } + + $segments = []; + $offset = 0; + + foreach ($this->orderedTemplates($templates) as $template) { + $duration = $template->getDurationSource() === SegmentTemplate::DURATION_FROM_ITEMS + ? $itemMinutes + : $template->getDurationMinutes(); + + if ($duration <= 0) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('مدت بخش «%s» تعیین نشده است', $template->getName()), + 422, + 'duration_minutes', + ); + } + + $segments[] = new PlannedSegment( + sequence: $template->getSequence(), + name: $template->getName(), + offsetMinutes: $offset, + durationMinutes: $duration, + patientPresent: $template->isPatientPresent(), + mergeable: $template->isMergeable(), + requirements: $this->planRequirements($template, $address, $patientGender), + ); + + $offset += $duration; + } + + if ($offset > SegmentTemplate::MAX_TOTAL_MINUTES) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('مجموع مدت بخش‌ها (%d دقیقه) از سقف %d دقیقه بیشتر است', $offset, SegmentTemplate::MAX_TOTAL_MINUTES), + 422, + 'segments', + ); + } + + return new AppointmentPlan($segments, $offset); + } + + /** + * بخش‌های `mergeable` هم‌نام یک بار می‌آیند: «آماده‌سازی» با دو ناحیه یک بار انجام + * می‌شود، ولی «خود لیزر» به‌ازای هر ناحیه طولانی‌تر می‌شود (و آن با + * `DURATION_FROM_ITEMS` بیان شده، نه با تکرار بخش). + * + * @param SegmentTemplate[] $templates + * @return SegmentTemplate[] + */ + private function orderedTemplates(array $templates): array + { + $seenMergeable = []; + $ordered = []; + + foreach ($templates as $template) { + if ($template->isMergeable()) { + if (isset($seenMergeable[$template->getName()])) { + continue; + } + $seenMergeable[$template->getName()] = true; + } + + $ordered[] = $template; + } + + usort( + $ordered, + static fn (SegmentTemplate $a, SegmentTemplate $b): int => $a->getSequence() <=> $b->getSequence(), + ); + + return $ordered; + } + + /** @return list */ + private function planRequirements( + SegmentTemplate $template, + DoctorAddress $address, + ?string $patientGender, + ): array { + $planned = []; + + foreach ($template->getRequirements() as $requirement) { + $eligible = $this->eligibleFor($requirement, $address, $patientGender, $template); + + if (count($eligible) < $requirement->getCount()) { + throw new AppException( + ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE, + $this->explainMissing($requirement, $address, $patientGender), + 422, + 'requirements', + ); + } + + // setup/cleanup محافظه‌کارانه از بیشترین مقدارِ کاندیدها گرفته می‌شود: + // تسک ۰۶ هنوز نمی‌داند کدام منبع انتخاب می‌شود، و کم گرفتنش یعنی نوبت + // بعدی روی زمان تمیزکاری بیفتد. + $planned[] = new PlannedRequirement( + role: $requirement->getResourceType()->getCode(), + roleName: $requirement->getResourceType()->getName(), + count: $requirement->getCount(), + occupancy: $requirement->getOccupancy(), + constraints: $requirement->getConstraints(), + eligible: $eligible, + skillName: $requirement->getSkill()?->getName(), + setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()), + cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()), + ); + } + + return $planned; + } + + /** @return list */ + private function eligibleFor( + SegmentRequirement $requirement, + DoctorAddress $address, + ?string $patientGender, + SegmentTemplate $template, + ): array { + $skillIds = $requirement->getSkill() === null ? [] : [(int) $requirement->getSkill()->getId()]; + $eligible = $this->resources->findEligible($address, $requirement->getResourceType(), $skillIds); + + if (!$requirement->requiresSameGender()) { + return array_values($eligible); + } + + // قید جنسیت وقتی جنسیت بیمار نامشخص است **نادیده گرفته نمی‌شود**: رد کردن + // بی‌صدا یعنی بیمار به منبعی می‌رسد که قرار نبود. + if ($patientGender === null || $patientGender === '') { + throw new AppException( + ErrorCodes::ERR_VALIDATION_002, + sprintf('برای بخش «%s» ثبت جنسیت بیمار الزامی است', $template->getName()), + 422, + 'patient_gender', + ); + } + + return array_values(array_filter( + $eligible, + static fn (ClinicResource $r): bool => ($r->getAttributes()['gender'] ?? null) === $patientGender, + )); + } + + private function explainMissing( + SegmentRequirement $requirement, + DoctorAddress $address, + ?string $patientGender, + ): string { + $parts = [sprintf('هیچ %s', $requirement->getResourceType()->getName())]; + + if ($requirement->requiresSameGender() && $patientGender !== null) { + $parts[] = $patientGender === 'female' ? 'خانمی' : 'آقایی'; + } + + if ($requirement->getSkill() !== null) { + $parts[] = sprintf('با مهارت «%s»', $requirement->getSkill()->getName()); + } + + $parts[] = sprintf('در شعبهٔ «%s» موجود نیست', $address->getName() ?? '—'); + + return implode(' ', $parts); + } + + /** + * رفتار امروز، بیان‌شده به زبان برنامه: یک بخش پیوسته که پزشک را می‌گیرد. + */ + private function singleSegmentPlan( + ServiceItem $service, + DoctorAddress $address, + int $minutes, + ?string $patientGender, + ): AppointmentPlan { + if ($minutes <= 0) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('مدت سرویس «%s» تعریف نشده است', $service->getName()), + 422, + 'duration_minutes', + ); + } + + $doctorType = $this->types->findByCode( + $address->tenantEntityType(), + $address->tenantEntityId(), + ResourceType::CODE_DOCTOR, + ); + + $eligible = $doctorType === null + ? [] + : $this->resources->findEligible($address, $doctorType); + + $requirements = $doctorType === null ? [] : [new PlannedRequirement( + role: ResourceType::CODE_DOCTOR, + roleName: $doctorType->getName(), + count: 1, + occupancy: SegmentRequirement::OCCUPANCY_EXCLUSIVE, + constraints: [], + eligible: $eligible, + setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()), + cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()), + )]; + + return new AppointmentPlan( + [new PlannedSegment( + sequence: 1, + name: $service->getName(), + offsetMinutes: 0, + durationMinutes: $minutes, + patientPresent: true, + mergeable: false, + requirements: $requirements, + )], + $minutes, + ); + } + + /** @param ClinicResource[] $resources */ + private function maxOf(array $resources, callable $pick): int + { + $values = array_map($pick, $resources); + + return $values === [] ? 0 : max($values); + } +} diff --git a/src/Appointment/Plan/ValueObject/AppointmentPlan.php b/src/Appointment/Plan/ValueObject/AppointmentPlan.php new file mode 100644 index 00000000..95f0ef40 --- /dev/null +++ b/src/Appointment/Plan/ValueObject/AppointmentPlan.php @@ -0,0 +1,29 @@ + $segments */ + public function __construct( + public array $segments, + public int $totalMinutes, + ) {} + + public function toArray(): array + { + return [ + 'total_minutes' => $this->totalMinutes, + 'segments' => array_map( + static fn (PlannedSegment $s): array => $s->toArray(), + $this->segments, + ), + ]; + } +} diff --git a/src/Appointment/Plan/ValueObject/PlannedRequirement.php b/src/Appointment/Plan/ValueObject/PlannedRequirement.php new file mode 100644 index 00000000..a34eeb4e --- /dev/null +++ b/src/Appointment/Plan/ValueObject/PlannedRequirement.php @@ -0,0 +1,54 @@ + $constraints + * @param list $eligible منابعی که همین حالا شرایط را دارند + */ + public function __construct( + public string $role, + public string $roleName, + public int $count, + public string $occupancy, + public array $constraints, + public array $eligible, + public ?string $skillName = null, + public int $setupMinutes = 0, + public int $cleanupMinutes = 0, + ) {} + + public function candidates(): int + { + return count($this->eligible); + } + + public function toArray(): array + { + return [ + 'role' => $this->role, + 'role_name' => $this->roleName, + 'skill_name' => $this->skillName, + 'count' => $this->count, + 'occupancy' => $this->occupancy, + 'constraints' => $this->constraints, + 'candidates' => $this->candidates(), + // تسک ۰۶ بازهٔ اشغال را از همین می‌سازد؛ در نمای کاربر نشان داده نمی‌شود. + 'occupancy_offset' => [ + 'setup_minutes' => $this->setupMinutes, + 'cleanup_minutes' => $this->cleanupMinutes, + ], + ]; + } +} diff --git a/src/Appointment/Plan/ValueObject/PlannedSegment.php b/src/Appointment/Plan/ValueObject/PlannedSegment.php new file mode 100644 index 00000000..e780cb2f --- /dev/null +++ b/src/Appointment/Plan/ValueObject/PlannedSegment.php @@ -0,0 +1,33 @@ + $requirements */ + public function __construct( + public int $sequence, + public string $name, + public int $offsetMinutes, + public int $durationMinutes, + public bool $patientPresent, + public bool $mergeable, + public array $requirements, + ) {} + + public function toArray(): array + { + return [ + 'sequence' => $this->sequence, + 'name' => $this->name, + 'offset_minutes' => $this->offsetMinutes, + 'duration_minutes' => $this->durationMinutes, + 'patient_present' => $this->patientPresent, + 'mergeable' => $this->mergeable, + 'requirements' => array_map( + static fn (PlannedRequirement $r): array => $r->toArray(), + $this->requirements, + ), + ]; + } +} diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index b4cc5c09..416bb0e1 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -19,6 +19,9 @@ class ErrorCodes // Not Found public const ERR_NOT_FOUND_001 = 'ERR_NOT_FOUND_001'; + /** هیچ منبعی شرایط یک بخش از نوبت را ندارد — بند ۱۰ مستند. */ + public const ERR_NO_ELIGIBLE_RESOURCE = 'ERR_NO_ELIGIBLE_RESOURCE'; + // Conflict public const ERR_CONFLICT_001 = 'ERR_CONFLICT_001'; @@ -130,6 +133,7 @@ class ErrorCodes self::ERR_VALIDATION_001 => 'ورودی نامعتبر است', self::ERR_VALIDATION_002 => 'فیلد الزامی وارد نشده است', self::ERR_NOT_FOUND_001 => 'منبع درخواستی یافت نشد', + self::ERR_NO_ELIGIBLE_RESOURCE => 'برای این خدمت منبع واجد شرایطی در این شعبه نیست', self::ERR_FORBIDDEN_001 => 'دسترسی به این منبع مجاز نیست', self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست', self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است', diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 6424a4e3..6f800bce 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -89,6 +89,7 @@ final class GlobalTables */ public const AGGREGATE_CHILDREN = [ \App\Appointment\Entity\AppointmentEvent::class => \App\Appointment\Entity\Appointment::class, + \App\Appointment\Plan\Entity\SegmentRequirement::class => \App\Appointment\Plan\Entity\SegmentTemplate::class, // ریشه‌هاشان خودشان جفت محیط دارند (برخلاف پروندهٔ branch_working_hours در // تسک ۰۱)، پس ارث‌بری اینجا واقعی است. هیچ‌کدام uuid از request نمی‌گیرند: diff --git a/tests/Appointment/AppointmentPlanTest.php b/tests/Appointment/AppointmentPlanTest.php new file mode 100644 index 00000000..5be98202 --- /dev/null +++ b/tests/Appointment/AppointmentPlanTest.php @@ -0,0 +1,340 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک برنامه'); + $this->em->persist($clinic); + $this->em->flush(); + + $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + $this->em->flush(); + + return [$user, $section, $address]; + } + + private function service(ServiceSection $section, string $name, ?int $solo = 20, ?int $additional = null): ServiceItem + { + $item = new ServiceItem($section, $name); + $item->setSoloDurationMinutes($solo); + $item->setAdditionalDurationMinutes($additional); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function resourceType(DoctorAddress $address, string $code, string $name): ResourceType + { + $type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name); + $this->em->persist($type); + $this->em->flush(); + + return $type; + } + + /** @param array $extra */ + private function resource(User $user, DoctorAddress $address, ResourceType $type, string $name, array $extra = []): array + { + $body = $this->authJson('POST', '/api/v1/resource', $user, $extra + [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $type->getUuid(), + 'name' => $name, + ]); + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + return $body['data']; + } + + /** @param list> $segments */ + private function setSegments(User $user, ServiceItem $service, array $segments): array + { + $body = $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [ + 'segments' => $segments, + ]); + + return $body; + } + + /** @param array $extra */ + private function preview(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array + { + return $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, $extra + [ + 'service_uuid' => $service->getUuid(), + 'branch_uuid' => $address->getUuid(), + ]); + } + + /** مثال مرجع مستند، دقیقاً با همان آفست‌ها. */ + public function testFourSegmentLaserPlan(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + + $room = $this->resourceType($address, 'room', 'اتاق'); + $operator = $this->resourceType($address, 'operator', 'اپراتور'); + $device = $this->resourceType($address, 'device', 'دستگاه'); + + $this->resource($user, $address, $room, 'اتاق ۱'); + $this->resource($user, $address, $operator, 'اپراتور ۱'); + $this->resource($user, $address, $device, 'لیزر ۱'); + + $roomReq = ['type_uuid' => $room->getUuid()]; + $operatorReq = ['type_uuid' => $operator->getUuid()]; + $deviceReq = ['type_uuid' => $device->getUuid()]; + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'بی‌حسی موضعی', 'duration_minutes' => 5, 'requirements' => [$roomReq, $operatorReq]], + ['sequence' => 2, 'name' => 'انتظار اثر کرم', 'duration_minutes' => 30, 'requirements' => [$roomReq]], + ['sequence' => 3, 'name' => 'خود لیزر', 'duration_source' => 'items', 'requirements' => [$roomReq, $operatorReq, $deviceReq]], + ['sequence' => 4, 'name' => 'مراقبت بعد', 'duration_minutes' => 5, 'requirements' => [$roomReq, $operatorReq]], + ]); + self::assertSame(200, $this->responseCode()); + + $plan = $this->preview($user, $service, $address); + self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE)); + + $data = $plan['data']; + self::assertSame(60, $data['total_minutes']); + self::assertCount(4, $data['segments']); + self::assertSame([0, 5, 35, 55], array_column($data['segments'], 'offset_minutes')); + self::assertSame([5, 30, 20, 5], array_column($data['segments'], 'duration_minutes')); + + // نکتهٔ اصلی بند ۷: اپراتور در بخش انتظار **نیست**. + $waitRoles = array_column($data['segments'][1]['requirements'], 'role'); + self::assertSame(['room'], $waitRoles, 'اپراتور در انتظار آزاد است'); + + $laserRoles = array_column($data['segments'][2]['requirements'], 'role'); + sort($laserRoles); + self::assertSame(['device', 'operator', 'room'], $laserRoles); + } + + /** بخشی که مدتش از آیتم‌ها می‌آید با دو ناحیه طولانی‌تر می‌شود. */ + public function testItemDrivenSegmentUsesTheDurationCalculator(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 0); + $face = $this->service($section, 'صورت', 15, 8); + $bikini = $this->service($section, 'بیکینی', 12, 8); + + $room = $this->resourceType($address, 'room', 'اتاق'); + $this->resource($user, $address, $room, 'اتاق ۱'); + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ['sequence' => 2, 'name' => 'خود لیزر', 'duration_source' => 'items', 'requirements' => [['type_uuid' => $room->getUuid()]]], + ]); + + $plan = $this->preview($user, $service, $address, [ + 'item_uuids' => [$face->getUuid(), $bikini->getUuid()], + ]); + + self::assertSame(200, $this->responseCode()); + // ۵ آماده‌سازی + ۲۳ لیزر (۱۵ + ۸، نه ۲۷) = ۲۸ + self::assertSame(28, $plan['data']['total_minutes']); + self::assertSame(23, $plan['data']['segments'][1]['duration_minutes']); + } + + /** سرویس بدون الگوی بخش، همان رفتار امروز را می‌گیرد: یک بخش پیوسته با پزشک. */ + public function testServiceWithoutTemplatesFallsBackToASingleSegment(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'ویزیت', 20); + $doctorType = $this->resourceType($address, ResourceType::CODE_DOCTOR, 'پزشک'); + $this->resource($user, $address, $doctorType, 'دکتر یک'); + + $plan = $this->preview($user, $service, $address); + + self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE)); + self::assertSame(20, $plan['data']['total_minutes']); + self::assertCount(1, $plan['data']['segments']); + self::assertSame('doctor', $plan['data']['segments'][0]['requirements'][0]['role']); + } + + /** بخش بدون هیچ نیازمندی معتبر است: زمان می‌گیرد، منبعی نمی‌گیرد. */ + public function testSegmentWithoutRequirementsIsValid(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'مراقبت خانگی', 10); + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'انتظار در خانه', 'duration_minutes' => 45, 'patient_present' => false, 'requirements' => []], + ]); + self::assertSame(200, $this->responseCode()); + + $plan = $this->preview($user, $service, $address); + + self::assertSame(45, $plan['data']['total_minutes']); + self::assertSame([], $plan['data']['segments'][0]['requirements']); + self::assertFalse($plan['data']['segments'][0]['patient_present']); + } + + /** مهارتی که هیچ منبعی ندارد → خطای انسانی با نام نقش و مهارت (بند ۱۰). */ + public function testMissingEligibleResourceGivesAHumanError(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + $operator = $this->resourceType($address, 'operator', 'اپراتور'); + + $skill = $this->authJson('POST', '/api/v1/skills', $user, ['name' => 'لیزر آلکساندرایت']); + self::assertSame(201, $this->responseCode()); + + // اپراتور هست ولی مهارت را ندارد. + $this->resource($user, $address, $operator, 'اپراتور بی‌مهارت'); + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [ + ['type_uuid' => $operator->getUuid(), 'skill_uuid' => $skill['data']['uuid']], + ]], + ]); + self::assertSame(200, $this->responseCode()); + + $body = $this->preview($user, $service, $address); + + self::assertSame(422, $this->responseCode()); + self::assertSame('ERR_NO_ELIGIBLE_RESOURCE', $body['errors'][0]['code']); + self::assertStringContainsString('اپراتور', $body['errors'][0]['message']); + self::assertStringContainsString('لیزر آلکساندرایت', $body['errors'][0]['message']); + self::assertStringContainsString('شعبهٔ مرکزی', $body['errors'][0]['message']); + } + + /** + * قید جنسیت وقتی جنسیت بیمار نامشخص است نادیده گرفته **نمی‌شود** — رد کردن بی‌صدا + * یعنی بیمار به منبعی می‌رسد که قرار نبود. + */ + public function testSameGenderConstraintRequiresAKnownPatientGender(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + $operator = $this->resourceType($address, 'operator', 'اپراتور'); + + $this->resource($user, $address, $operator, 'اپراتور خانم', ['attributes' => ['gender' => 'female']]); + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [ + ['type_uuid' => $operator->getUuid(), 'constraints' => [SegmentRequirement::CONSTRAINT_SAME_GENDER]], + ]], + ]); + self::assertSame(200, $this->responseCode()); + + $unknown = $this->preview($user, $service, $address); + self::assertSame(422, $this->responseCode()); + self::assertStringContainsString('جنسیت بیمار', $unknown['errors'][0]['message']); + + $female = $this->preview($user, $service, $address, ['patient_gender' => 'female']); + self::assertSame(200, $this->responseCode()); + self::assertSame(1, $female['data']['segments'][0]['requirements'][0]['candidates']); + + $male = $this->preview($user, $service, $address, ['patient_gender' => 'male']); + self::assertSame(422, $this->responseCode(), 'هیچ اپراتور آقایی نیست'); + self::assertStringContainsString('آقایی', $male['errors'][0]['message']); + } + + /** `setup/cleanup` در نمای کاربر نمی‌آید ولی برای تسک بعدی در پاسخ هست. */ + public function testOccupancyOffsetCarriesResourceSetupAndCleanup(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + $device = $this->resourceType($address, 'device', 'دستگاه'); + + $this->resource($user, $address, $device, 'لیزر ۱', ['setup_minutes' => 5, 'cleanup_minutes' => 10]); + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $device->getUuid()]]], + ]); + + $plan = $this->preview($user, $service, $address); + + $offset = $plan['data']['segments'][0]['requirements'][0]['occupancy_offset']; + self::assertSame(5, $offset['setup_minutes']); + self::assertSame(10, $offset['cleanup_minutes']); + } + + public function testTotalBeyondTheDailyCapIsRejected(): void + { + [$user, $section] = $this->clinicWithBranch(); + $service = $this->service($section, 'ماراتن', 20); + + $body = $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'بخش بلند', 'duration_minutes' => 400], + ['sequence' => 2, 'name' => 'بخش بلند دوم', 'duration_minutes' => 200], + ]); + + self::assertSame(422, $this->responseCode()); + self::assertStringContainsString('سقف', $body['errors'][0]['message']); + } + + public function testFixedSegmentNeedsAPositiveDuration(): void + { + [$user, $section] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + + $body = $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'بخش بی‌مدت', 'duration_minutes' => 0], + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('duration_minutes', $body['errors'][0]['field']); + } + + /** بخش‌های `mergeable` هم‌نام یک بار می‌آیند. */ + public function testMergeableSegmentsAppearOnce(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + $room = $this->resourceType($address, 'room', 'اتاق'); + $this->resource($user, $address, $room, 'اتاق ۱'); + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ['sequence' => 2, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ['sequence' => 3, 'name' => 'کار اصلی', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ]); + + $plan = $this->preview($user, $service, $address); + + self::assertCount(2, $plan['data']['segments'], 'آماده‌سازی یک بار'); + self::assertSame(25, $plan['data']['total_minutes']); + } + + public function testForeignServiceIsNotFound(): void + { + [$user, , $address] = $this->clinicWithBranch(); + [, $otherSection] = $this->clinicWithBranch(); + $foreign = $this->service($otherSection, 'سرویس بیگانه', 20); + + $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, [ + 'service_uuid' => $foreign->getUuid(), + 'branch_uuid' => $address->getUuid(), + ]); + + self::assertSame(404, $this->responseCode()); + } +}