diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index a98b2c7c..31efe5a0 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -13,6 +13,7 @@ import { PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon, CheckCircleIcon, XMarkIcon, ExclamationTriangleIcon, HeartIcon, CheckIcon, IdentificationIcon, DocumentTextIcon, GlobeAltIcon, + LockClosedIcon, } from '@heroicons/react/24/outline'; import { StarIcon as StarSolid } from '@heroicons/react/24/solid'; import { toast } from 'sonner'; @@ -111,7 +112,7 @@ const DEFAULT_BOOKING_META: BookingMeta = { booking_mode: 'slot', buffer_minutes: 0, }; -interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; } +interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; booking_mode_locked?: boolean; } // ── Constants ────────────────────────────────────────────────────────────── @@ -1106,11 +1107,13 @@ function SlotEditor({ slots, onChange }: { ); } -function SessionEditor({ session, onChange, onRemove, addresses }: { +function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = false }: { session: SessionConfig; onChange: (s: SessionConfig) => void; onRemove: () => void; addresses: AddressData[]; + /** حالت نوبت‌دهی سرویسی: فیلدهای اسلاتی (بازه هر نوبت، استراحت، شمارش) نمایش داده نمی‌شوند. */ + serviceMode?: boolean; }) { const upd = (k: K, v: SessionConfig[K]) => onChange({ ...session, [k]: v }); @@ -1134,16 +1137,18 @@ function SessionEditor({ session, onChange, onRemove, addresses }: { - {/* Duration + Location */} -
-
-
بازه هر نوبت
- ({ value: d, label: `${d} دقیقه` }))} - value={session.duration_per_patient} - onChange={(v) => upd('duration_per_patient', Number(v))} - /> -
+ {/* Duration (slot mode only) + Location */} +
+ {!serviceMode && ( +
+
بازه هر نوبت
+ ({ value: d, label: `${d} دقیقه` }))} + value={session.duration_per_patient} + onChange={(v) => upd('duration_per_patient', Number(v))} + /> +
+ )}
مکان نوبت
- {/* Rest toggle */} + {/* Rest toggle (slot mode only) */} + {!serviceMode && (
+ )} {/* Rest params */} - {session.has_rest && ( + {!serviceMode && session.has_rest && (
هر (دقیقه کار)
@@ -1210,7 +1217,8 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
)} - {/* Slot count footer */} + {/* Slot count footer (slot mode only) */} + {!serviceMode && (
— )}
+ )}
); } @@ -1238,6 +1247,9 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { const [scheduleUuid, setScheduleUuid] = useState(null); const [expandedDay, setExpandedDay] = useState(null); const [meta, setMeta] = useState(DEFAULT_BOOKING_META); + // نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول. + const [modeLocked, setModeLocked] = useState(false); + const [confirmMode, setConfirmMode] = useState(false); const scheduleQ = useQuery({ queryKey: ['doctor-schedule', doctorUuid], @@ -1259,6 +1271,7 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { setScheduleUuid(d.uuid); } if (d?.meta) setMeta({ ...DEFAULT_BOOKING_META, ...d.meta }); + setModeLocked(!!d?.booking_mode_locked); } else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) { setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null); } @@ -1291,6 +1304,7 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { onSuccess: (res) => { const d: WeeklyScheduleData = res?.data?.data ?? res?.data; if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid); + setModeLocked(true); // پس از ثبت، نوع نوبت‌دهی قفل می‌شود toast.success('برنامه هفتگی ذخیره شد'); qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid] }); }, @@ -1394,22 +1408,43 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { روش نوبت‌دهی
-
- {([['slot', 'اسلاتی (مدت ثابت)'], ['service', 'بر اساس سرویس']] as const).map(([val, lbl]) => ( - - ))} +
+ {([ + ['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'], + ['service', 'نوبت‌دهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود؛ سیستم نزدیک‌ترین زمان خالیِ کافی را پیشنهاد می‌دهد. مناسب خدمات با زمان متفاوت.'], + ] as const).map(([val, lbl, desc]) => { + const selected = meta.booking_mode === val; + return ( + + ); + })}
+ {modeLocked ? ( +

+ + نوع نوبت‌دهی ثبت شده و دیگر قابل تغییر نیست. +

+ ) : ( +

+ ⚠️ توجه: نوع نوبت‌دهی پس از اولین ثبت به‌هیچ‌عنوان قابل تغییر نیست. پیش از ذخیره با دقت انتخاب کنید. +

+ )} {meta.booking_mode === 'service' ? ( <>
@@ -1544,6 +1579,7 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
) : sessions.map((session, idx) => ( updateSession(day.key, idx, s)} onRemove={() => removeSession(day.key, idx)} /> ))} @@ -1561,13 +1597,24 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {

)} {!readOnly && ( - )}
+ + { setConfirmMode(false); saveMut.mutate(); }} + onCancel={() => setConfirmMode(false)} + />
); } diff --git a/docs/api/appointment-settings.md b/docs/api/appointment-settings.md index c3e02eb8..badaf1e6 100644 --- a/docs/api/appointment-settings.md +++ b/docs/api/appointment-settings.md @@ -106,6 +106,8 @@ Create or update the weekly schedule for a doctor (upsert). > Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 1, booking_window_unit: "month", booking_mode: "slot", buffer_minutes: 0 }`. `meta` is stored inside the schedule `setting` JSON (no DB migration) and is **preserved** when only `schedule` is sent. `SlotCalculatorService` rejects any date in the past, beyond `today + value unit`, or when online booking is disabled — for the weekly schedule, date overrides, and `appointment-slots` alike. > > **اجبار حالت سرویسی:** اگر `booking_mode = service` ذخیره شود ولی پزشک هیچ سرویسِ «نمایش در نوبت‌دهی» (`bookable = true`) نداشته باشد، `POST`/`PATCH` برنامهٔ هفتگی با `422` (`ERR_VALIDATION_001`, field `booking_mode`) رد می‌شود. +> +> **غیرقابل‌تغییر پس از ثبت:** `booking_mode` فقط تا **اولین ثبت** قابل‌انتخاب است. پس از آنکه یک‌بار به‌صورت صریح ذخیره شد (در `setting.meta.booking_mode` نوشته شد)، هر `POST`/`PATCH` که آن را تغییر دهد با `422` («نوع نوبت‌دهی پس از ثبت قابل تغییر نیست»، field `booking_mode`) رد می‌شود. پاسخِ `toArray` فیلد boolean `booking_mode_locked` را برمی‌گرداند (`true` = قفل‌شده) تا پنل توگل را غیرفعال کند. رکوردهای قدیمی که هنوز mode صریح ندارند، `booking_mode_locked=false` و یک‌بار قابل‌انتخاب‌اند. **Session Config Object:** @@ -143,8 +145,11 @@ Create or update the weekly schedule for a doctor (upsert). "meta": { "online_booking_enabled": true, "booking_window_value": 1, - "booking_window_unit": "month" + "booking_window_unit": "month", + "booking_mode": "slot", + "buffer_minutes": 0 }, + "booking_mode_locked": true, "created_at": 1717000000, "updated_at": 1717000000 } diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index 694291d4..0d4c7b87 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -41,6 +41,18 @@ class AppointmentSettingsController extends BaseController * در حالت نوبت‌دهی سرویسی، پزشک باید حداقل یک سرویسِ «نمایش در نوبت‌دهی» * (bookable) داشته باشد؛ وگرنه هیچ نوبتی قابل‌محاسبه نیست. */ + /** + * نوع نوبت‌دهی پس از اولین ثبت غیرقابل‌تغییر است. اگر قبلاً mode ذخیره شده بود + * ($prevMode !== null) و meta جدید آن را تغییر دهد، خطای 422 برمی‌گرداند. + */ + private function assertModeImmutable(?string $prevMode, array $newMeta): ?JsonResponse + { + if ($prevMode !== null && ($newMeta['booking_mode'] ?? null) !== $prevMode) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع نوبت‌دهی پس از ثبت قابل تغییر نیست', 422, 'booking_mode'); + } + return null; + } + private function serviceModeHasNoBookable(array $meta, \App\Doctor\Entity\Doctor $doctor): bool { return ($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) === WeeklySchedule::MODE_SERVICE @@ -70,6 +82,7 @@ class AppointmentSettingsController extends BaseController // Only one schedule per doctor — upsert $schedule = $this->scheduleRepo->findByDoctor($doctor); + $prevMode = $schedule?->getStoredBookingMode(); if ($schedule !== null) { $schedule->setSetting($data['schedule'] ?? []); } else { @@ -80,6 +93,10 @@ class AppointmentSettingsController extends BaseController $schedule->setMeta($data['meta']); } + if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta())) !== null) { + return $err; + } + if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است', 422, 'booking_mode'); } @@ -107,6 +124,7 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } + $prevMode = $schedule->getStoredBookingMode(); $data = json_decode($request->getContent(), true) ?? []; if (isset($data['schedule'])) { if (($err = $this->validateSessionsHaveLocation($data['schedule'])) !== null) { @@ -118,6 +136,10 @@ class AppointmentSettingsController extends BaseController $schedule->setMeta($data['meta']); } + if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta())) !== null) { + return $err; + } + if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor())) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است', 422, 'booking_mode'); } diff --git a/src/Appointment/Entity/WeeklySchedule.php b/src/Appointment/Entity/WeeklySchedule.php index 21821eaf..b5266c1a 100644 --- a/src/Appointment/Entity/WeeklySchedule.php +++ b/src/Appointment/Entity/WeeklySchedule.php @@ -79,6 +79,16 @@ class WeeklySchedule return array_merge(self::DEFAULT_META, $this->setting[self::META_KEY] ?? []); } + /** + * booking_mode ذخیره‌شده به‌صورت خام (بدون merge پیش‌فرض). null یعنی هنوز + * صریحاً ثبت نشده — تا وقتی null است، انتخاب روش قابل‌تغییر است؛ پس از اولین + * ثبت، قفل می‌شود. + */ + public function getStoredBookingMode(): ?string + { + return $this->setting[self::META_KEY]['booking_mode'] ?? null; + } + public function setMeta(array $meta): self { $current = $this->getMeta(); @@ -111,6 +121,8 @@ class WeeklySchedule 'doctor_uuid' => $this->doctor->getUuid(), 'schedule' => $this->getDaySchedule(), 'meta' => $this->getMeta(), + // نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود (پنل توگل را غیرفعال می‌کند). + 'booking_mode_locked' => $this->getStoredBookingMode() !== null, 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt, ]; diff --git a/tests/Appointment/BookingModeImmutableTest.php b/tests/Appointment/BookingModeImmutableTest.php new file mode 100644 index 00000000..e96fe6d0 --- /dev/null +++ b/tests/Appointment/BookingModeImmutableTest.php @@ -0,0 +1,58 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر قفل'); + $this->em->persist($doctor); + $this->em->flush(); + return [$owner, $doctor]; + } + + public function testFirstSaveCommitsAndSecondSaveKeepsSameMode(): void + { + [$owner, $doctor] = $this->makeDoctor(); + + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'schedule' => [], + 'meta' => ['booking_mode' => 'slot'], + ]); + $this->assertSame(201, $this->responseCode()); + + // همان mode دوباره → مجاز + $this->authJson('PATCH', '/api/v1/appointment-settings/weekly-schedule/' . $doctor->getUuid(), $owner, [ + 'meta' => ['booking_mode' => 'slot'], + ]); + $this->assertSame(200, $this->responseCode()); + } + + public function testCannotChangeModeAfterCommit(): void + { + [$owner, $doctor] = $this->makeDoctor(); + + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'schedule' => [], + 'meta' => ['booking_mode' => 'slot'], + ]); + $this->assertSame(201, $this->responseCode()); + + // تلاش برای تغییر به سرویس → 422 قفل + $res = $this->authJson('PATCH', '/api/v1/appointment-settings/weekly-schedule/' . $doctor->getUuid(), $owner, [ + 'meta' => ['booking_mode' => 'service'], + ]); + $this->assertSame(422, $this->responseCode()); + $this->assertStringContainsString('قابل تغییر نیست', json_encode($res, JSON_UNESCAPED_UNICODE)); + } +}