diff --git a/docs/api/appointment-plan.md b/docs/api/appointment-plan.md index 2856b886..a2e72260 100644 --- a/docs/api/appointment-plan.md +++ b/docs/api/appointment-plan.md @@ -117,8 +117,26 @@ **۳. قید جنسیت وقتی جنسیت بیمار نامشخص است نادیده گرفته نمی‌شود.** ۴۲۲ می‌دهد، چون رد کردن بی‌صدا یعنی بیمار به منبعی می‌رسد که قرار نبود. -`mergeable` هم‌نام‌ها یک بار می‌آیند: «آماده‌سازی» با دو ناحیه یک بار انجام می‌شود. -تکرار شدنِ کارِ اصلی با `duration_source: "items"` بیان می‌شود، نه با تکرار بخش. +## ادغام بخش‌ها + +برنامه از الگوهای **سرویس اصلی به‌علاوهٔ آیتم‌های انتخاب‌شده** ساخته می‌شود. تا پیش از +این فقط الگوهای سرویس اصلی خوانده می‌شد، پس بخش‌هایی که کلینیک روی خودِ ناحیه تعریف کرده +بود بی‌صدا نادیده می‌ماند و `mergeable` هرگز کاری نمی‌کرد — در یک سرویس، دو بخشِ هم‌نام +معنا ندارد. + +| قاعده | چرا | +|---|---| +| هم‌نام‌های `mergeable` یک بار می‌آیند | «آماده‌سازی» برای دو ناحیه یک بار انجام می‌شود | +| از میان هم‌نام‌ها **طولانی‌ترین** می‌ماند | آماده‌سازی دو ناحیه کوتاه‌تر از طولانی‌ترینشان نیست | +| `duration_source: "items"` هم یک بار می‌آید | `DurationCalculator` از قبل مجموع همهٔ آیتم‌ها را داده؛ تکرارش یعنی دوبار شمردن | +| تعداد منبع پس از ادغام **بیشینه** است | دو ناحیه با هم دو اتاق نمی‌خواهند، ولی اگر یکی دو اپراتور لازم داشت ادغام نباید به یک تنزلش بدهد | + +مثال: «لیزر صورت» با آماده‌سازی ۵ دقیقه و «لیزر بیکینی» با آماده‌سازی ۱۲ دقیقه، هر دو +انتخاب شوند → یک آماده‌سازیِ ۱۲ دقیقه‌ای، و کارِ اصلی به اندازهٔ مجموع دو ناحیه. + +**برنامه قطعی است:** دو `preview` با همان ورودی خروجیِ بایت‌به‌بایت یکسان می‌دهند (تست +دارد). برنامه‌ای که بین پیش‌نمایش و رزرو جابه‌جا شود یعنی کاربر چیزی را تأیید کرده که +رزرو نشد. --- @@ -132,7 +150,7 @@ ## تست‌ها ```bash -ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php # ۱۱ تست +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 7b8f863e..996bbe90 100644 --- a/docs/new_feture/taskes/task-05-appointment-plan/checklist.md +++ b/docs/new_feture/taskes/task-05-appointment-plan/checklist.md @@ -28,7 +28,7 @@ | ۱.۳ | پنج سرویس جدا | ⚠️ | یک `AppointmentPlanBuilder` با متدهای خصوصی. تقسیم به Assembler/DurationResolver/RequirementResolver وقتی معنا دارد که هرکدام مصرف‌کنندهٔ مستقل داشته باشند؛ اینجا هر سه فقط از همین یک مسیر صدا زده می‌شوند | | ۱.۴ | `build()` تابع خالص | ⚠️ | تا تسک ۰۸ خالص بود. تسک ۰۹ قوانین `timing`/`resource` را وصل کرد، پس حالا از دیتابیس می‌خواند. چیزی که تسک ۰۶ واقعاً به آن نیاز دارد — خروجی قطعی برای ورودی ثابت — هنوز برقرار است | | ۱.۵ | قلاب سیاست از روز اول در امضا | ✅ | تسک ۰۹ همان‌جا پر شد؛ همان دلیلِ گذاشتنش | -| ۱.۶ | ادغام: `count` بیشینه | ⏳ | ادغام بخش‌های `mergeable` پیاده نشد؛ پرچمش ذخیره و در API برگردانده می‌شود ولی رفتاری ندارد | +| ۱.۶ | ادغام: `count` بیشینه | ✅ | ⭐ برنامه از الگوهای سرویس **و آیتم‌های انتخاب‌شده** ساخته می‌شود؛ هم‌نام‌های `mergeable` یک بار می‌آیند (طولانی‌ترین می‌ماند) و تعداد منبع بیشینه می‌شود | | ۱.۷ | `offset_minutes` نسبی | ✅ | تسک ۰۶ برنامه را می‌لغزاند | | ۱.۸ | اشغال جدا از offset نمایشی | ⚠️ | `setup/cleanup` روی `PlannedRequirement` است (بیشینهٔ کاندیدها) نه دو offset جدا؛ اثر عملی یکی است و تسک ۰۷ همان را می‌خواند | | ۱.۹ | قید جنسیت بدون داده → ۴۲۲ | ✅ | ⭐ نادیده گرفته نمی‌شود | @@ -73,10 +73,10 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | ادغام بخش‌ها | ⏳ | با ۱.۶ یک بسته است | +| ۴.۱ | ادغام بخش‌ها | ✅ | دو تست: ادغام هم‌نام دو ناحیه · بیشینه‌بودن تعداد | | ۴.۲ | حل مدت — ثابت و از آیتم‌ها | ✅ | داخل `AppointmentPlanTest` | | ۴.۳ | سناریوی مرجع مستند | ✅ | ⭐ آفست‌های ۰/۵/۳۵/۵۵ و مجموع ۶۰ | -| ۴.۴ | قطعیت — دو build یکسان | ⏳ | تست صریح ندارد | +| ۴.۴ | قطعیت — دو build یکسان | ✅ | مقایسهٔ JSON دو `preview` پیاپی | | ۴.۵ | سرویس بدون الگو | ✅ | ⭐ | | ۴.۶ | حل نیازمندی — مهارت، بی‌کاندید، جنسیت، محیط دیگر | ✅ | | | ۴.۷ | سقف‌ها → ۴۲۲ | ⚠️ | سقف ۴۸۰ دقیقه تست شد؛ بقیه سقف ندارند (۱.۱۴) | diff --git a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php index e499aa49..8765fa8c 100644 --- a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php +++ b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php @@ -48,7 +48,7 @@ final class AppointmentPlanBuilder ?string $patientGender = null, ): AppointmentPlan { $itemMinutes = $this->durations->totalMinutes($selectedItems !== [] ? $selectedItems : [$service]); - $templates = $this->templates->findForService($service); + $templates = $this->gatherTemplates($service, $selectedItems); // سرویسی که الگوی بخش ندارد، همان رفتار امروز را می‌گیرد: یک بخش پیوسته که // پزشک را می‌گیرد. بدون این، هر سرویس موجود بی‌برنامه می‌شد. @@ -61,6 +61,7 @@ final class AppointmentPlanBuilder $segments = []; $offset = 0; + $counts = $this->mergedCounts($templates); foreach ($this->orderedTemplates($templates) as $template) { $duration = $template->getDurationSource() === SegmentTemplate::DURATION_FROM_ITEMS @@ -83,7 +84,7 @@ final class AppointmentPlanBuilder durationMinutes: $duration, patientPresent: $template->isPatientPresent(), mergeable: $template->isMergeable(), - requirements: $this->planRequirements($template, $address, $patientGender), + requirements: $this->planRequirements($template, $address, $patientGender, $counts[$template->getName()] ?? []), ); $offset += $duration; @@ -184,30 +185,77 @@ final class AppointmentPlanBuilder return $target; } + /** + * الگوهای سرویس اصلی **به‌علاوهٔ** الگوهای آیتم‌های انتخاب‌شده. + * + * بدون این، بخش‌هایی که کلینیک روی خودِ ناحیه تعریف کرده بی‌صدا نادیده می‌ماندند و + * پرچم `mergeable` هرگز کاری نمی‌کرد — چون در یک سرویس، دو بخشِ هم‌نام معنا ندارد. + * + * @param ServiceItem[] $selectedItems + * @return SegmentTemplate[] + */ + private function gatherTemplates(ServiceItem $service, array $selectedItems): array + { + $ids = [(int) $service->getId()]; + + foreach ($selectedItems as $item) { + $ids[] = (int) $item->getId(); + } + + $ids = array_values(array_unique(array_filter($ids))); + + if (count($ids) === 1) { + return $this->templates->findForService($service); + } + + $gathered = []; + + foreach ($this->templates->findForServices($ids) as $forService) { + foreach ($forService as $template) { + $gathered[(int) $template->getId()] = $template; + } + } + + return array_values($gathered); + } + /** * بخش‌های `mergeable` هم‌نام یک بار می‌آیند: «آماده‌سازی» با دو ناحیه یک بار انجام * می‌شود، ولی «خود لیزر» به‌ازای هر ناحیه طولانی‌تر می‌شود (و آن با * `DURATION_FROM_ITEMS` بیان شده، نه با تکرار بخش). * + * از میان هم‌نام‌ها **طولانی‌ترین** می‌ماند: آماده‌سازیِ دو ناحیه کوتاه‌تر از + * طولانی‌ترینشان نیست. بخشی که مدتش از آیتم‌ها می‌آید هم یک بار می‌آید حتی اگر + * `mergeable` نباشد، چون `DurationCalculator` از قبل مجموع همهٔ آیتم‌ها را داده و + * تکرارش یعنی دوبار شمردن همان زمان. + * * @param SegmentTemplate[] $templates * @return SegmentTemplate[] */ private function orderedTemplates(array $templates): array { - $seenMergeable = []; - $ordered = []; + $best = []; + $rest = []; foreach ($templates as $template) { - if ($template->isMergeable()) { - if (isset($seenMergeable[$template->getName()])) { - continue; - } - $seenMergeable[$template->getName()] = true; + $collapses = $template->isMergeable() + || $template->getDurationSource() === SegmentTemplate::DURATION_FROM_ITEMS; + + if (!$collapses) { + $rest[] = $template; + continue; } - $ordered[] = $template; + $key = $template->getName(); + $current = $best[$key] ?? null; + + if ($current === null || $template->getDurationMinutes() > $current->getDurationMinutes()) { + $best[$key] = $template; + } } + $ordered = array_merge(array_values($best), $rest); + usort( $ordered, static fn (SegmentTemplate $a, SegmentTemplate $b): int => $a->getSequence() <=> $b->getSequence(), @@ -216,6 +264,32 @@ final class AppointmentPlanBuilder return $ordered; } + /** + * تعداد منبعِ هر نقش پس از ادغام: **بیشینه**، نه جمع و نه اولی. + * + * دو ناحیه‌ای که هرکدام یک اتاق می‌خواهند، با هم دو اتاق نمی‌خواهند — همان یک اتاق + * است. ولی اگر یکی دو اپراتور لازم داشت، ادغام نباید آن را به یک تنزل بدهد؛ + * کم گرفتنش یعنی نوبتی که سرِ وقتش نیروی کافی ندارد. + * + * @param SegmentTemplate[] $templates + * @return array> `[نامِ بخش => [کدِ نقش => تعداد]]` + */ + private function mergedCounts(array $templates): array + { + $counts = []; + + foreach ($templates as $template) { + foreach ($template->getRequirements() as $requirement) { + $name = $template->getName(); + $role = $requirement->getResourceType()->getCode(); + + $counts[$name][$role] = max($counts[$name][$role] ?? 0, $requirement->getCount()); + } + } + + return $counts; + } + /** * قوانین «منبع»: نقشی که قانون لازم می‌داند، اگر الگو نداشته باشد، اضافه می‌شود. * @@ -363,13 +437,18 @@ final class AppointmentPlanBuilder SegmentTemplate $template, DoctorAddress $address, ?string $patientGender, + array $mergedCounts = [], ): array { $planned = []; foreach ($template->getRequirements() as $requirement) { $eligible = $this->eligibleFor($requirement, $address, $patientGender, $template); + $count = max( + $requirement->getCount(), + $mergedCounts[$requirement->getResourceType()->getCode()] ?? 0, + ); - if (count($eligible) < $requirement->getCount()) { + if (count($eligible) < $count) { throw new AppException( ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE, $this->explainMissing($requirement, $address, $patientGender), @@ -384,7 +463,7 @@ final class AppointmentPlanBuilder $planned[] = new PlannedRequirement( role: $requirement->getResourceType()->getCode(), roleName: $requirement->getResourceType()->getName(), - count: $requirement->getCount(), + count: $count, occupancy: $requirement->getOccupancy(), constraints: $requirement->getConstraints(), eligible: $eligible, diff --git a/tests/Appointment/AppointmentPlanTest.php b/tests/Appointment/AppointmentPlanTest.php index 5be98202..48cb2df7 100644 --- a/tests/Appointment/AppointmentPlanTest.php +++ b/tests/Appointment/AppointmentPlanTest.php @@ -324,6 +324,117 @@ class AppointmentPlanTest extends ApiTestCase self::assertSame(25, $plan['data']['total_minutes']); } + /** + * ⭐ ادغام واقعی: دو ناحیه که هرکدام «آماده‌سازی» خودشان را دارند. + * + * پیش از این، الگوهای آیتم‌های انتخاب‌شده اصلاً خوانده نمی‌شدند و پرچم `mergeable` + * هیچ کاری نمی‌کرد — در یک سرویس، دو بخشِ هم‌نام معنا ندارد. + */ + public function testSegmentsOfSelectedItemsAreMergedByName(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + + $face = $this->service($section, 'لیزر صورت', 20); + $bikini = $this->service($section, 'لیزر بیکینی', 30); + + $room = $this->resourceType($address, 'room', 'اتاق'); + $this->resource($user, $address, $room, 'اتاق ۱'); + + // آماده‌سازیِ بیکینی طولانی‌تر است؛ ادغام باید طولانی‌ترین را نگه دارد. + $this->setSegments($user, $face, [ + ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 0, 'duration_source' => 'items', 'requirements' => [['type_uuid' => $room->getUuid()]]], + ]); + + $this->setSegments($user, $bikini, [ + ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 12, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ]); + + $plan = $this->preview($user, $face, $address, [ + 'item_uuids' => [$face->getUuid(), $bikini->getUuid()], + ]); + + $segments = $plan['data']['segments']; + $names = array_column($segments, 'name'); + + self::assertSame(['آماده‌سازی', 'کار اصلی'], $names, 'آماده‌سازی یک بار می‌آید'); + self::assertSame(12, $segments[0]['duration_minutes'], 'طولانی‌ترین آماده‌سازی می‌ماند'); + + // کار اصلی از `DurationCalculator` می‌آید: ۲۰ + ۳۰. + self::assertSame(50, $segments[1]['duration_minutes']); + self::assertSame(62, $plan['data']['total_minutes']); + } + + /** + * ⭐ تعداد منبع پس از ادغام **بیشینه** است، نه جمع و نه اولی. + * + * دو ناحیه با هم دو اتاق نمی‌خواهند؛ ولی اگر یکی دو نفر لازم داشت، ادغام نباید آن + * را به یک تنزل بدهد — نوبتی که نیروی کافی ندارد بدتر از نوبتِ نگرفته است. + */ + public function testMergingTakesTheLargestRequiredCount(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + + $face = $this->service($section, 'ناحیهٔ یک', 20); + $bikini = $this->service($section, 'ناحیهٔ دو', 20); + + $operator = $this->resourceType($address, 'operator', 'اپراتور'); + $this->resource($user, $address, $operator, 'اپراتور ۱'); + $this->resource($user, $address, $operator, 'اپراتور ۲'); + + $this->setSegments($user, $face, [ + ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 10, 'mergeable' => true, 'requirements' => [['type_uuid' => $operator->getUuid(), 'count' => 1]]], + ]); + + $this->setSegments($user, $bikini, [ + ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 8, 'mergeable' => true, 'requirements' => [['type_uuid' => $operator->getUuid(), 'count' => 2]]], + ]); + + $plan = $this->preview($user, $face, $address, [ + 'item_uuids' => [$face->getUuid(), $bikini->getUuid()], + ]); + + $segments = $plan['data']['segments']; + + self::assertCount(1, $segments); + self::assertSame(2, $segments[0]['requirements'][0]['count'], 'بیشینه، نه اولی'); + } + + /** + * ⭐ قطعیت: دو build با همان ورودی باید **بایت‌به‌بایت** یکی باشند. + * + * ترتیب منابع و بخش‌ها از کوئری می‌آید و کوئریِ بدون `ORDER BY` قطعی نیست. برنامه‌ای + * که بین پیش‌نمایش و رزرو جابه‌جا شود، یعنی کاربر چیزی را تأیید کرده که رزرو نشد. + */ + public function testTwoIdenticalBuildsProduceTheSamePlan(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + + $room = $this->resourceType($address, 'room', 'اتاق'); + $operator = $this->resourceType($address, 'operator', 'اپراتور'); + + foreach (['اتاق ۱', 'اتاق ۲', 'اتاق ۳'] as $name) { + $this->resource($user, $address, $room, $name); + } + foreach (['اپراتور ۱', 'اپراتور ۲'] as $name) { + $this->resource($user, $address, $operator, $name); + } + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]], + ['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ]); + + $first = $this->preview($user, $service, $address)['data']; + $second = $this->preview($user, $service, $address)['data']; + + self::assertSame( + json_encode($first, JSON_UNESCAPED_UNICODE), + json_encode($second, JSON_UNESCAPED_UNICODE), + ); + } + public function testForeignServiceIsNotFound(): void { [$user, , $address] = $this->clinicWithBranch(); diff --git a/tests/Appointment/AvailabilityEngineTest.php b/tests/Appointment/AvailabilityEngineTest.php index 26f693fe..1b41ac4e 100644 --- a/tests/Appointment/AvailabilityEngineTest.php +++ b/tests/Appointment/AvailabilityEngineTest.php @@ -277,8 +277,10 @@ class AvailabilityEngineTest extends ApiTestCase ['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]], ]); - $lastWeek = $this->nextSaturday() - 7 * 86400; - $body = $this->search($user, $service, $address, $lastWeek, $lastWeek); + // دو هفته عقب، نه یک هفته: وقتی امروز خودش شنبه باشد، «شنبهٔ هفتهٔ پیش» همین + // امروز است و ساعت‌های بعدازظهرش هنوز گذشته نیستند. + $past = $this->nextSaturday() - 14 * 86400; + $body = $this->search($user, $service, $address, $past, $past); self::assertSame(200, $this->responseCode()); self::assertSame([], $body['data']['slots']);