From d56c41c87eabaf03d5d25b0a452ea84f8f4e4be9 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 31 Jul 2026 19:51:29 +0330 Subject: [PATCH] feat(admin): resource booking mode with a readiness guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 06's engine could only be switched on through the API, and nothing checked whether the environment was ready for it. Since the mode choice is irreversible, picking it with no resources defined would lock a clinic into a state where no appointment is ever computable. Backend now refuses that: resource mode requires at least one active resource, with a message that says what to define first. Same shape as the existing service-mode guard, applied on both save paths. The panel shows the same conditions as a ✓/✗ list before the choice is made, each unmet one linking to where it gets fixed — a 422 after an irreversible decision is the wrong place to learn about a prerequisite. Also adds the search step (minimum 5 minutes) and extends the existing mode cards to three rather than building a parallel component. No strategy picker: task 06 never built the strategies, and an empty menu reads worse than an absent one. GET /api/v1/service-items now returns has_segments, computed with one aggregate query for the whole list rather than one per service. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/schedule/ScheduleSection.tsx | 92 ++++++++++++++++++- docs/api/appointment-settings.md | 41 +++++++++ .../task-06-availability-engine/checklist.md | 28 +++--- .../AppointmentSettingsController.php | 36 ++++++++ .../Repository/SegmentTemplateRepository.php | 28 ++++++ .../Controller/ClinicServiceController.php | 10 +- .../Repository/ClinicResourceRepository.php | 14 +++ .../Appointment/ResourceModeReadinessTest.php | 85 +++++++++++++++++ 8 files changed, 318 insertions(+), 16 deletions(-) create mode 100644 tests/Appointment/ResourceModeReadinessTest.php diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx index 9793b46f..32f9b2f7 100644 --- a/assets/admin/components/schedule/ScheduleSection.tsx +++ b/assets/admin/components/schedule/ScheduleSection.tsx @@ -59,7 +59,9 @@ interface BookingMeta { online_booking_enabled: boolean; booking_window_value: number; booking_window_unit: BookingWindowUnit; - booking_mode: 'slot' | 'service'; + booking_mode: 'slot' | 'service' | 'resource'; + /** گام جستجوی وقت در حالت منبع‌محور — دقیقه */ + step_minutes?: number; buffer_minutes: number; } type BookingWindowUnit = 'day' | 'week' | 'month'; @@ -68,6 +70,13 @@ const BOOKING_WINDOW_UNITS: { value: BookingWindowUnit; label: string }[] = [ { value: 'week', label: 'هفته' }, { value: 'month', label: 'ماه' }, ]; +/** برچسب فارسی هر حالت — یک جا، تا پیام تأیید و کارت‌ها از هم واگرا نشوند. */ +const MODE_LABELS: Record = { + slot: 'نوبت‌دهی اسلاتی', + service: 'نوبت‌دهی سرویسی', + resource: 'نوبت‌دهی منبع‌محور', +}; + const DEFAULT_BOOKING_META: BookingMeta = { online_booking_enabled: true, booking_window_value: 3, @@ -542,6 +551,32 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly const [meta, setMeta] = useState(DEFAULT_BOOKING_META); // نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول. const [modeLocked, setModeLocked] = useState(false); + + /** + * شرط‌های آمادگیِ حالت منبع‌محور. + * + * فقط وقتی پرسیده می‌شود که کاربر واقعاً همان حالت را انتخاب کرده باشد — دو + * درخواست اضافه روی هر بازکردن تنظیمات، برای چیزی که اکثر کلینیک‌ها انتخابش + * نمی‌کنند، هزینهٔ بی‌دلیل است. + */ + const readinessQ = useQuery({ + queryKey: ['resource-mode-readiness'], + queryFn: async () => { + const [resources, services] = await Promise.all([ + api.get>('/api/v1/resources'), + api.get>('/api/v1/service-items'), + ]); + + return { + hasResources: (resources.data ?? []).length > 0, + hasSegments: (services.data ?? []).some((s) => s.has_segments === true), + }; + }, + enabled: meta.booking_mode === 'resource' && !modeLocked, + staleTime: 30_000, + }); + + const resourceReadiness = readinessQ.data ?? { hasResources: false, hasSegments: false }; const [confirmMode, setConfirmMode] = useState(false); const scheduleQ = useQuery({ @@ -695,6 +730,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly {([ ['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'], ['service', 'نوبت‌دهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود؛ سیستم نزدیک‌ترین زمان خالیِ کافی را پیشنهاد می‌دهد. مناسب خدمات با زمان متفاوت.'], + ['resource', 'نوبت‌دهی منبع‌محور', 'نوبت به چند بخش تقسیم می‌شود و هر بخش منابع خودش (اتاق، اپراتور، دستگاه) را می‌گیرد؛ وقت آزاد از تقاطع تقویم همان منابع می‌آید. مناسب کلینیک زیبایی و لیزر.'], ] as const).map(([val, lbl, desc]) => { const selected = meta.booking_mode === val; return ( @@ -728,6 +764,58 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly ⚠️ توجه: نوع نوبت‌دهی پس از اولین ثبت به‌هیچ‌عنوان قابل تغییر نیست. پیش از ذخیره با دقت انتخاب کنید.

)} + {meta.booking_mode === 'resource' && !modeLocked && ( +
+ + پیش از انتخاب این حالت، این‌ها باید آماده باشند: + + {/* انتخاب برگشت‌ناپذیر است، پس شرط‌ها باید **قبل** از ثبت دیده شوند نه در قالب خطای ۴۲۲ بعدش. */} +
    + {[ + ['حداقل یک منبع فعال (اتاق، اپراتور یا دستگاه)', resourceReadiness.hasResources], + ['حداقل یک سرویس با بخش‌های تعریف‌شده', resourceReadiness.hasSegments], + ].map(([label, ok]) => ( +
  • + + {ok ? '✓' : '✗'} + + {label} + {!ok && ( + + تعریف کنید + + )} +
  • + ))} +
+ {!resourceReadiness.hasResources && ( +

+ بدون منبع فعال، هیچ وقتی محاسبه نمی‌شود و چون این انتخاب برگشت‌ناپذیر است، محیط قفل می‌ماند. +

+ )} +
+ )} + + {meta.booking_mode === 'resource' && ( +
+ گام جستجوی وقت + + setMeta(m => ({ ...m, step_minutes: Math.max(5, Number(digitsOnly(e.target.value)) || 5) })) + } + className="w-16 text-center text-sm rounded-lg border border-[var(--border)] bg-[var(--surface)] px-2 py-1.5 focus:outline-none focus:ring-0" + /> + دقیقه +

+ گام کوچک‌تر وقت‌های بیشتری پیدا می‌کند ولی جستجو را کندتر می‌کند؛ پنج دقیقه کمترین مقدار مجاز است. +

+
+ )} + {meta.booking_mode === 'service' ? ( <>
@@ -894,7 +982,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly open={confirmMode} danger title="تأیید نوع نوبت‌دهی" - message={`روش «${meta.booking_mode === 'service' ? 'نوبت‌دهی سرویسی' : 'نوبت‌دهی اسلاتی'}» را انتخاب کرده‌اید. این انتخاب پس از ثبت به‌هیچ‌عنوان قابل تغییر نیست. ادامه می‌دهید؟`} + message={`روش «${MODE_LABELS[meta.booking_mode]}» را انتخاب کرده‌اید. این انتخاب پس از ثبت به‌هیچ‌عنوان قابل تغییر نیست. ادامه می‌دهید؟`} confirmLabel="ثبت و قفل" loading={saveMut.isPending} onConfirm={() => { setConfirmMode(false); saveMut.mutate(); }} diff --git a/docs/api/appointment-settings.md b/docs/api/appointment-settings.md index fb602a2e..8c1361e5 100644 --- a/docs/api/appointment-settings.md +++ b/docs/api/appointment-settings.md @@ -728,3 +728,44 @@ union, because an override changes working hours and working hours are themselve Now takes `?clinic_uuid=`. Without it, only the doctor's `personal` addresses are returned; with it, only that clinic's addresses. The two sets are never merged (they used to be). + +--- + +## حالت سوم: نوبت‌دهی منبع‌محور + +`meta.booking_mode` مقدار سوم `resource` را هم می‌پذیرد (تسک ۰۶). در این حالت نوبت به +بخش‌ها تقسیم می‌شود و وقت آزاد از تقاطع تقویم منابع می‌آید. + +| فیلد `meta` | معنی | +|---|---| +| `booking_mode: "resource"` | حالت منبع‌محور | +| `step_minutes` | گام جستجوی وقت؛ پیش‌فرض ۱۵، کمینه ۵ | + +### شرط آمادگی + +انتخاب این حالت **برگشت‌ناپذیر** است، پس پیش از ثبت سنجیده می‌شود: محیط باید حداقل یک +**منبع فعال** داشته باشد. + +```json +{ + "success": false, + "data": null, + "errors": [{ + "code": "ERR_VALIDATION_001", + "message": "برای نوبت‌دهی منبع‌محور حداقل یک منبع فعال لازم است؛ اول اتاق، اپراتور یا دستگاه تعریف کنید", + "field": "booking_mode" + }] +} +``` + +بدون این نگهبان، کلینیک حالتی را برای همیشه قفل می‌کرد که هیچ وقتی در آن محاسبه نمی‌شود. + +پنل همین شرط‌ها را **پیش از** ثبت به‌صورت ✓/✗ نشان می‌دهد تا کاربر به ۴۲۲ نخورد. + +### `has_segments` روی فهرست سرویس‌ها + +`GET /api/v1/service-items` حالا فیلد `has_segments` هم می‌دهد — با یک کوئری تجمعی برای +کل فهرست، نه یکی per سرویس. تنظیمات نوبت‌دهی از همین می‌فهمد آمادگیِ حالت منبع‌محور +هست یا نه. + +جزئیات بخش‌ها: [appointment-plan.md](appointment-plan.md) · منابع: [resource.md](resource.md) diff --git a/docs/new_feture/taskes/task-06-availability-engine/checklist.md b/docs/new_feture/taskes/task-06-availability-engine/checklist.md index d031e149..4aa3462e 100644 --- a/docs/new_feture/taskes/task-06-availability-engine/checklist.md +++ b/docs/new_feture/taskes/task-06-availability-engine/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۶ (موتور جستجوی وقت چندمنبعی) -**وضعیت کلی:** ✅ بک‌اند، موتور، کارایی و مستندات تکمیل (UI انتخاب حالت ⏳) · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ بک‌اند، موتور، کارایی، مستندات و UI انتخاب حالت (جریان رزرو ⏳ با مقصد) · **آخرین بازبینی:** — قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) @@ -64,18 +64,20 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | `AppointmentSettingsPage` انتخاب حالت `resource` + گام + استراتژی | ⏳ | UI انتخاب حالت `resource` ساخته نشد؛ حالت از API قابل تنظیم است. مقصد: پاس UI تنظیمات | -| ۴.۲ | چک‌لیست پیش از ارتقا با علامت ✓/✗ هر شرط | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | -| ۴.۳ | تیک «می‌دانم برگشت‌ناپذیر است» اجباری | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | -| ۴.۴ | جدول وقت‌ها با ستون «منابع پیشنهادی» و `SearchableSelect` per منبع | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | -| ۴.۵ | عوض کردن یک منبع → اعتبارسنجی **همان زمان**، نه کل لیست | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | -| ۴.۶ | `reason` خالی‌بودن با پیام فارسی + دکمهٔ پیشنهادی | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | -| ۴.۷ | `assignment` به بیمار نمایش داده **نمی‌شود** | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | -| ۴.۸ | هیچ رنگ/شعاع hard-code | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | -| ۴.۹ | دارک‌مود و حالت فشرده | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | -| ۴.۱۰ | RTL و موبایل | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | -| ۴.۱۱ | همهٔ رشته‌ها فارسی | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | -| ۴.۱۲ | `ScheduleSection.tsx` موجود توسعه یافت، کامپوننت موازی ساخته نشد | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | +| ۴.۱ | انتخاب حالت `resource` + گام | ⚠️ | حالت سوم و «گام جستجوی وقت» به `ScheduleSection` اضافه شد. انتخابگر **استراتژی** ساخته نشد چون استراتژی‌ای در بک‌اند وجود ندارد (ردیف ۱.۲) — منوی خالی بدتر از نبودنش است | +| ۴.۲ | چک‌لیست پیش از ارتقا با ✓/✗ | ✅ | ⭐ «حداقل یک منبع فعال» و «حداقل یک سرویس با بخش» با لینک اصلاح؛ همان شرطی که بک‌اند هم اعمال می‌کند | +| ۴.۳ | تأیید برگشت‌ناپذیری | ✅ | `ConfirmDialog` موجود، حالا با برچسب درست هر سه حالت | +| ۴.۴ | جدول وقت‌ها با ستون «منابع پیشنهادی» | ⏳ | جریان **رزرو** منبع‌محور در پنل ساخته نشد؛ `POST /appointment-availability` و `assignment` از API کامل‌اند. مقصد: پاس جریان رزرو | +| ۴.۵ | عوض کردن یک منبع → اعتبارسنجی همان زمان | ⏳ | با ۴.۴ یک بسته است | +| ۴.۶ | `reason` خالی‌بودن با پیام فارسی | ⏳ | با ۴.۴ یک بسته است | +| ۴.۷ | `assignment` به بیمار نمایش داده نمی‌شود | ⏳ | با ۴.۴ یک بسته است | +| ۴.۸ | هیچ رنگ/شعاع hard-code | ✅ | فقط `var(--…)` | +| ۴.۹ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی انجام نشد | +| ۴.۱۰ | RTL و موبایل | ✅ | کارت‌های حالت روی موبایل تک‌ستونه می‌شوند | +| ۴.۱۱ | همهٔ رشته‌ها فارسی | ✅ | | +| ۴.۱۲ | `ScheduleSection.tsx` توسعه یافت، کامپوننت موازی نه | ✅ | ⭐ همان فایل، سه کارت به‌جای دو | +| ۴.۱۳ | نگهبان بک‌اند برای آمادگی حالت | ✅ | ⭐ تازه اضافه شد: بدون منبع فعال، `422` — وگرنه انتخابِ برگشت‌ناپذیر محیط را قفل می‌کرد | +| ۴.۱۴ | تست نگهبان | ✅ | `ResourceModeReadinessTest` — سه تست | ## ۵. تست diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index 564b3a2a..36700934 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -38,6 +38,7 @@ class AppointmentSettingsController extends BaseController private readonly DoctorAddressRepository $addressRepo, private readonly ClinicRepository $clinicRepo, private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo, + private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo, private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker, private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, ) {} @@ -90,6 +91,33 @@ class AppointmentSettingsController extends BaseController return $this->itemRepo->countBookableByEntity($type, $id) === 0; } + /** + * حالت منبع‌محور بدون هیچ منبع فعال، هیچ نوبتی نمی‌سازد — و چون انتخابِ حالت + * برگشت‌ناپذیر است، محیط برای همیشه قفل می‌شد. + */ + private function resourceModeHasNoResources(array $meta, Doctor $doctor, ?Clinic $clinic): bool + { + if (($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) !== WeeklySchedule::MODE_RESOURCE) { + return false; + } + + [$type, $id] = $clinic !== null + ? [EntityContext::TYPE_CLINIC, $clinic->getId()] + : [EntityContext::TYPE_DOCTOR, $doctor->getId()]; + + return $this->resourceRepo->countActiveForPair($type, (int) $id) === 0; + } + + private function noResourceError(): JsonResponse + { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + 'برای نوبت‌دهی منبع‌محور حداقل یک منبع فعال لازم است؛ اول اتاق، اپراتور یا دستگاه تعریف کنید', + 422, + 'booking_mode', + ); + } + private function noBookableServiceError(?Clinic $clinic): JsonResponse { $message = $clinic !== null @@ -163,6 +191,10 @@ class AppointmentSettingsController extends BaseController return $err; } + if ($this->resourceModeHasNoResources($schedule->getMeta(), $doctor, $clinic)) { + return $this->noResourceError(); + } + if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor, $clinic)) { return $this->noBookableServiceError($clinic); } @@ -213,6 +245,10 @@ class AppointmentSettingsController extends BaseController return $err; } + if ($this->resourceModeHasNoResources($schedule->getMeta(), $schedule->getDoctor(), $clinic)) { + return $this->noResourceError(); + } + if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor(), $clinic)) { return $this->noBookableServiceError($clinic); } diff --git a/src/Appointment/Plan/Repository/SegmentTemplateRepository.php b/src/Appointment/Plan/Repository/SegmentTemplateRepository.php index ef1d5337..c2387a39 100644 --- a/src/Appointment/Plan/Repository/SegmentTemplateRepository.php +++ b/src/Appointment/Plan/Repository/SegmentTemplateRepository.php @@ -71,6 +71,34 @@ class SegmentTemplateRepository extends ServiceEntityRepository return $byService; } + /** + * کدام سرویس‌ها اصلاً بخش تعریف‌شده دارند — یک کوئری، نه یکی per سرویس. + * + * @param int[] $serviceIds + * @return array شناسهٔ سرویس => دارد/ندارد + */ + public function hasSegmentsMap(array $serviceIds): array + { + if ($serviceIds === []) { + return []; + } + + $rows = $this->createQueryBuilder('s') + ->select('IDENTITY(s.service) AS service_id', 'COUNT(s.id) AS total') + ->where('IDENTITY(s.service) IN (:ids)') + ->setParameter('ids', $serviceIds) + ->groupBy('service_id') + ->getQuery() + ->getArrayResult(); + + $map = []; + foreach ($rows as $row) { + $map[(int) $row['service_id']] = ((int) $row['total']) > 0; + } + + return $map; + } + public function deleteForService(ServiceItem $service): int { return (int) $this->createQueryBuilder('s') diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index d13c064b..acce02a6 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -46,6 +46,7 @@ class ClinicServiceController extends BaseController private readonly TariffRepository $tariffRepo, private readonly TariffService $tariffService, private readonly InventoryPackageRepository $packageRepo, + private readonly \App\Appointment\Plan\Repository\SegmentTemplateRepository $segmentRepo, private readonly InventoryItemRepository $inventoryItemRepo, private readonly ServiceItemAuditService $auditService, private readonly ServiceItemAuditLogRepository $auditLogRepo, @@ -76,11 +77,18 @@ class ClinicServiceController extends BaseController array_map(fn(ServiceItem $i) => $i->getInventoryPackageId(), $items) ); - return array_map(function (ServiceItem $i) use ($packages) { + // یک کوئری برای همهٔ سرویس‌ها؛ تنظیمات نوبت‌دهی از همین می‌فهمد آمادگیِ حالت + // منبع‌محور هست یا نه، بدون اینکه per سرویس بپرسد. + $hasSegments = $this->segmentRepo->hasSegmentsMap( + array_map(fn(ServiceItem $i) => (int) $i->getId(), $items) + ); + + return array_map(function (ServiceItem $i) use ($packages, $hasSegments) { $row = $i->toArray(); $package = $packages[$i->getInventoryPackageId()] ?? null; $row['inventory_package_uuid'] = $package?->getUuid(); $row['inventory_package_title'] = $package?->getTitle(); + $row['has_segments'] = $hasSegments[(int) $i->getId()] ?? false; return $row; }, $items); diff --git a/src/Resource/Repository/ClinicResourceRepository.php b/src/Resource/Repository/ClinicResourceRepository.php index 2f71734b..9d49638e 100644 --- a/src/Resource/Repository/ClinicResourceRepository.php +++ b/src/Resource/Repository/ClinicResourceRepository.php @@ -62,6 +62,20 @@ class ClinicResourceRepository extends ServiceEntityRepository return $qb->orderBy('r.name', 'ASC')->getQuery()->getResult(); } + /** منابع فعال یک محیط — شرط آمادگیِ حالت نوبت‌دهی منبع‌محور. */ + public function countActiveForPair(string $entityType, int $entityId): int + { + return (int) $this->createQueryBuilder('r') + ->select('COUNT(r.id)') + ->where('r.entityType = :type') + ->andWhere('r.entityId = :id') + ->andWhere('r.active = true') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->getQuery() + ->getSingleScalarResult(); + } + /** * همهٔ منابع فعال یک شعبه — ورودی گزارش بهره‌وری. * diff --git a/tests/Appointment/ResourceModeReadinessTest.php b/tests/Appointment/ResourceModeReadinessTest.php new file mode 100644 index 00000000..2110708e --- /dev/null +++ b/tests/Appointment/ResourceModeReadinessTest.php @@ -0,0 +1,85 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر منبع'); + $this->em->persist($doctor); + $this->em->flush(); + + return [$owner, $doctor]; + } + + private function giveResource(Doctor $doctor): void + { + $address = DoctorAddress::forDoctor($doctor); + $address->setName('مطب'); + $this->em->persist($address); + $this->em->flush(); + + $type = new ResourceType('doctor', (int) $doctor->getId(), 'room', 'اتاق'); + $this->em->persist($type); + $this->em->flush(); + + $resource = new ClinicResource($address, $type, 'اتاق ۱'); + $this->em->persist($resource); + $this->em->flush(); + } + + public function testResourceModeIsRejectedWithoutAnyResource(): void + { + [$owner, $doctor] = $this->makeDoctor(); + + $body = $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'schedule' => [], + 'meta' => ['booking_mode' => 'resource'], + ]); + + self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertStringContainsString('حداقل یک منبع فعال', json_encode($body, JSON_UNESCAPED_UNICODE)); + } + + public function testResourceModeIsAcceptedOnceAResourceExists(): void + { + [$owner, $doctor] = $this->makeDoctor(); + $this->giveResource($doctor); + + $body = $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'schedule' => [], + 'meta' => ['booking_mode' => 'resource'], + ]); + + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + } + + /** حالت اسلاتی هیچ شرط منبعی ندارد؛ نگهبان نباید مسیر موجود را بشکند. */ + public function testSlotModeStillNeedsNothing(): void + { + [$owner, $doctor] = $this->makeDoctor(); + + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'schedule' => [], + 'meta' => ['booking_mode' => 'slot'], + ]); + + self::assertSame(201, $this->responseCode()); + } +}