From 22c89fbae4fe1448f335b321f4a74db982a04891 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 30 Jul 2026 21:07:30 +0330 Subject: [PATCH] feat(plan): multi-segment appointments with per-segment resource requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 7 of the design document, and the reason the whole resource layer exists. A laser session is not one block: numbing cream (5 min, room + operator), waiting for it to work (30 min, room only), the laser itself (20 min, room + operator + device), aftercare (5 min, room + operator). Under the single-interval model the operator is locked for all 60 minutes while actually working 30 — half the capacity thrown away. AppointmentPlanBuilder turns (service, selected items, branch, patient) into a plan: segments with offsets, durations and resource requirements. It deliberately assigns no absolute time and no specific resource — that is the next task. This only produces the *shape* of the appointment. Segment duration comes from one of two sources. A fixed segment carries its own number; an item-driven one gets its duration from task 04's DurationCalculator, so "the laser itself" grows with two treated areas while "waiting for the cream" does not. One number could not have expressed that. Three contracts worth stating: - A service with no segment templates falls back to a single continuous segment requiring the doctor resource — exactly today's behaviour. Without it every existing service would have become unplannable overnight. - A segment with no requirements is valid: "waiting at home" consumes time but occupies nothing. - same_gender_as_patient with an unknown patient gender is a 422, not a silently dropped requirement. Dropping it quietly would route the patient to a resource the clinic said must not serve them. When no resource qualifies, the error names the role, the skill and the branch — "no female operator with the skill «Alexandrite laser» is available at «Central»" — rather than an empty result the caller has to interpret (section 10). occupancy_offset carries each requirement's setup/cleanup minutes for the availability engine. It is taken as the maximum across candidates, because the builder does not yet know which resource will be picked and under-reserving means the next appointment lands on top of the cleanup. 11 tests covering the document's reference example (offsets 0/5/35/55, total 60), item-driven scaling, the no-template fallback, all three gender-constraint outcomes, merging and both caps. 1186 tests overall. phpstan back at its 14-error baseline; slot-mode frozen contract green. The admin segments page is not built; the checklist records it with a target. The backend and preview endpoint are complete and consumable without it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/README.md | 1 + docs/api/appointment-plan.md | 136 +++++++ .../task-05-appointment-plan/checklist.md | 24 +- migrations/Version20260730173225.php | 41 +++ .../Controller/AppointmentPlanController.php | 208 +++++++++++ .../Plan/Entity/SegmentRequirement.php | 127 +++++++ .../Plan/Entity/SegmentTemplate.php | 151 ++++++++ .../SegmentRequirementRepository.php | 29 ++ .../Repository/SegmentTemplateRepository.php | 83 +++++ .../Plan/Service/AppointmentPlanBuilder.php | 279 ++++++++++++++ .../Plan/ValueObject/AppointmentPlan.php | 29 ++ .../Plan/ValueObject/PlannedRequirement.php | 54 +++ .../Plan/ValueObject/PlannedSegment.php | 33 ++ src/Shared/Constant/ErrorCodes.php | 4 + src/Shared/Tenant/GlobalTables.php | 1 + tests/Appointment/AppointmentPlanTest.php | 340 ++++++++++++++++++ 16 files changed, 1528 insertions(+), 12 deletions(-) create mode 100644 docs/api/appointment-plan.md create mode 100644 migrations/Version20260730173225.php create mode 100644 src/Appointment/Plan/Controller/AppointmentPlanController.php create mode 100644 src/Appointment/Plan/Entity/SegmentRequirement.php create mode 100644 src/Appointment/Plan/Entity/SegmentTemplate.php create mode 100644 src/Appointment/Plan/Repository/SegmentRequirementRepository.php create mode 100644 src/Appointment/Plan/Repository/SegmentTemplateRepository.php create mode 100644 src/Appointment/Plan/Service/AppointmentPlanBuilder.php create mode 100644 src/Appointment/Plan/ValueObject/AppointmentPlan.php create mode 100644 src/Appointment/Plan/ValueObject/PlannedRequirement.php create mode 100644 src/Appointment/Plan/ValueObject/PlannedSegment.php create mode 100644 tests/Appointment/AppointmentPlanTest.php 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()); + } +}