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()); + } +}