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