diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx index 80571180..91efd49d 100644 --- a/assets/admin/components/schedule/ScheduleSection.tsx +++ b/assets/admin/components/schedule/ScheduleSection.tsx @@ -56,13 +56,19 @@ type NewScheduleMap = Record; interface BookingMeta { online_booking_enabled: boolean; booking_window_value: number; - booking_window_unit: 'week' | 'month'; + booking_window_unit: BookingWindowUnit; booking_mode: 'slot' | 'service'; buffer_minutes: number; } +type BookingWindowUnit = 'day' | 'week' | 'month'; +const BOOKING_WINDOW_UNITS: { value: BookingWindowUnit; label: string }[] = [ + { value: 'day', label: 'روز' }, + { value: 'week', label: 'هفته' }, + { value: 'month', label: 'ماه' }, +]; const DEFAULT_BOOKING_META: BookingMeta = { online_booking_enabled: true, - booking_window_value: 1, + booking_window_value: 3, booking_window_unit: 'month', booking_mode: 'slot', buffer_minutes: 0, @@ -778,9 +784,9 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly />
setMeta(m => ({ ...m, booking_window_unit: (v as 'week' | 'month') }))} + onChange={(v) => setMeta(m => ({ ...m, booking_window_unit: (v as BookingWindowUnit) }))} isDisabled={!meta.online_booking_enabled} height={38} /> diff --git a/docs/api/appointment-settings.md b/docs/api/appointment-settings.md index 9ae2ff2c..14be425f 100644 --- a/docs/api/appointment-settings.md +++ b/docs/api/appointment-settings.md @@ -140,11 +140,11 @@ Create or update the weekly schedule for a doctor (upsert). |-------|------|----------|-------------| | `online_booking_enabled` | boolean | ❌ | `false` = no online booking; the public slot/month endpoints return no availability | | `booking_window_value` | integer | ❌ | How far ahead patients may book (≥ 1) | -| `booking_window_unit` | string | ❌ | `"week"` or `"month"` | +| `booking_window_unit` | string | ❌ | `"day"`, `"week"` or `"month"` (invalid value keeps the current one) | | `booking_mode` | string | ❌ | `"slot"` (پیش‌فرض) = نوبت‌دهی اسلاتی با مدت ثابت (`duration_per_patient`). `"service"` = مدت هر نوبت از `duration_minutes` سرویسِ انتخاب‌شده؛ زمان‌ها با `GET /api/v1/appointment-service-slots` گرفته می‌شوند. مقدار نامعتبر نادیده گرفته می‌شود | | `buffer_minutes` | integer | ❌ | فقط حالت سرویسی: فاصلهٔ بین نوبت‌ها (دقیقه، ≥ 0). در `slot_end` ذخیره نمی‌شود؛ فقط فاصلهٔ بین زمان‌های پیشنهادی | -> 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. +> Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 3, 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`) رد می‌شود. > @@ -185,7 +185,7 @@ Create or update the weekly schedule for a doctor (upsert). }, "meta": { "online_booking_enabled": true, - "booking_window_value": 1, + "booking_window_value": 3, "booking_window_unit": "month", "booking_mode": "slot", "buffer_minutes": 0 diff --git a/docs/api/appointment.md b/docs/api/appointment.md index ca5aa789..496f04ac 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -170,7 +170,7 @@ Which days of a month are bookable — used by the public calendar to grey out u "disabled_dates": ["2026-06-01", "2026-06-17", "2026-06-26"], "enabled_dates": ["2026-06-15", "2026-06-16", "2026-06-18"], "online_booking_enabled": true, - "booking_window": { "value": 1, "unit": "month" } + "booking_window": { "value": 3, "unit": "month" } } } ``` @@ -180,7 +180,7 @@ Which days of a month are bookable — used by the public calendar to grey out u | `disabled_dates` | string[] | `Y-m-d` days with no bookable slot (holiday / closed override / non-working / past / out-of-window) | | `enabled_dates` | string[] | `Y-m-d` days with at least one slot | | `online_booking_enabled` | boolean | Doctor's online-booking flag | -| `booking_window` | object | `{ value, unit }` — `unit` is `week` or `month` | +| `booking_window` | object | `{ value, unit }` — `unit` is `day`, `week` or `month` (default `3 month`) | ### Errors | Code | HTTP | Description | diff --git a/src/Appointment/Entity/WeeklySchedule.php b/src/Appointment/Entity/WeeklySchedule.php index a8a30456..cbc6d349 100644 --- a/src/Appointment/Entity/WeeklySchedule.php +++ b/src/Appointment/Entity/WeeklySchedule.php @@ -27,9 +27,12 @@ class WeeklySchedule public const MODE_SLOT = 'slot'; // نوبت‌دهی اسلاتی (رفتار پیش‌فرض) public const MODE_SERVICE = 'service'; // نوبت‌دهی بر اساس مدت سرویس + /** واحدهای مجاز بازهٔ رزرو آنلاین؛ همان کلیدواژه‌های strtotime. */ + public const BOOKING_WINDOW_UNITS = ['day', 'week', 'month']; + public const DEFAULT_META = [ 'online_booking_enabled' => true, - 'booking_window_value' => 1, + 'booking_window_value' => 3, 'booking_window_unit' => 'month', 'booking_mode' => self::MODE_SLOT, 'buffer_minutes' => 0, @@ -128,7 +131,7 @@ class WeeklySchedule $this->setting[self::META_KEY] = [ 'online_booking_enabled' => (bool)($meta['online_booking_enabled'] ?? $current['online_booking_enabled']), 'booking_window_value' => max(1, (int)($meta['booking_window_value'] ?? $current['booking_window_value'])), - 'booking_window_unit' => in_array($meta['booking_window_unit'] ?? null, ['week', 'month'], true) + 'booking_window_unit' => in_array($meta['booking_window_unit'] ?? null, self::BOOKING_WINDOW_UNITS, true) ? $meta['booking_window_unit'] : $current['booking_window_unit'], 'booking_mode' => in_array($meta['booking_mode'] ?? null, [self::MODE_SLOT, self::MODE_SERVICE], true) diff --git a/src/Appointment/Service/SlotCalculatorService.php b/src/Appointment/Service/SlotCalculatorService.php index 79b3b028..0fef932e 100644 --- a/src/Appointment/Service/SlotCalculatorService.php +++ b/src/Appointment/Service/SlotCalculatorService.php @@ -263,10 +263,13 @@ class SlotCalculatorService return false; } + /** آخرین روزی که رزرو عمومی روی آن مجاز است (شروعِ آن روز، 00:00). */ private function bookingWindowEnd(array $meta): int { - $value = max(1, (int) ($meta['booking_window_value'] ?? 1)); - $unit = ($meta['booking_window_unit'] ?? 'month') === 'week' ? 'week' : 'month'; + $value = max(1, (int) ($meta['booking_window_value'] ?? WeeklySchedule::DEFAULT_META['booking_window_value'])); + $unit = in_array($meta['booking_window_unit'] ?? null, WeeklySchedule::BOOKING_WINDOW_UNITS, true) + ? $meta['booking_window_unit'] + : WeeklySchedule::DEFAULT_META['booking_window_unit']; return (int) strtotime("today +{$value} {$unit} 00:00:00"); } @@ -309,11 +312,7 @@ class SlotCalculatorService return false; } - $value = max(1, (int)($meta['booking_window_value'] ?? 1)); - $unit = ($meta['booking_window_unit'] ?? 'month') === 'week' ? 'week' : 'month'; - $maxStart = (int) strtotime("today +{$value} {$unit} 00:00:00"); - - return $dayStart <= $maxStart; + return $dayStart <= $this->bookingWindowEnd($meta); } private function getBookingMeta(Doctor $doctor, ?Clinic $clinic): array diff --git a/tests/Appointment/BookingWindowUnitTest.php b/tests/Appointment/BookingWindowUnitTest.php new file mode 100644 index 00000000..63607a87 --- /dev/null +++ b/tests/Appointment/BookingWindowUnitTest.php @@ -0,0 +1,105 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر بازهٔ رزرو'); + $this->em->persist($doctor); + + $days = []; + foreach (range(0, 6) as $dayKey) { + $days[(string) $dayKey] = ['sessions' => [[ + 'active' => true, + 'start_time' => '10:00', + 'end_time' => '12:00', + 'duration_per_patient' => 30, + 'location_id' => 1, + ]]]; + } + + $schedule = new WeeklySchedule($doctor, $days); + if ($meta !== []) { + $schedule->setMeta($meta); + } + $this->em->persist($schedule); + $this->em->flush(); + + return $doctor; + } + + private function assertBookable(Doctor $doctor, string $relative): void + { + $calc = static::getContainer()->get(SlotCalculatorService::class); + $date = date('Y-m-d', strtotime($relative)); + + $this->assertNotSame([], $calc->getAllSlotsWithAvailability($doctor, $date), "باید داخل بازه باشد: {$relative}"); + } + + private function assertOutsideWindow(Doctor $doctor, string $relative): void + { + $calc = static::getContainer()->get(SlotCalculatorService::class); + $date = date('Y-m-d', strtotime($relative)); + + $this->assertSame([], $calc->getAllSlotsWithAvailability($doctor, $date), "باید بیرون بازه باشد: {$relative}"); + $this->assertSame(SlotCalculatorService::EMPTY_OUTSIDE_WINDOW, $calc->explainEmptyDay($doctor, $date)); + } + + public function testDayUnitLimitsWindowToExactDays(): void + { + $doctor = $this->makeAlwaysOpenDoctor([ + 'booking_window_value' => 2, + 'booking_window_unit' => 'day', + ]); + + $this->assertBookable($doctor, '+2 days'); + $this->assertOutsideWindow($doctor, '+3 days'); + } + + public function testWeekUnitStillSupported(): void + { + $doctor = $this->makeAlwaysOpenDoctor([ + 'booking_window_value' => 1, + 'booking_window_unit' => 'week', + ]); + + $this->assertBookable($doctor, '+7 days'); + $this->assertOutsideWindow($doctor, '+8 days'); + } + + public function testDefaultWindowIsThreeMonths(): void + { + $this->assertSame(3, WeeklySchedule::DEFAULT_META['booking_window_value']); + $this->assertSame('month', WeeklySchedule::DEFAULT_META['booking_window_unit']); + + $doctor = $this->makeAlwaysOpenDoctor(); + + $this->assertBookable($doctor, '+2 months'); + $this->assertOutsideWindow($doctor, '+3 months +2 days'); + } + + public function testInvalidUnitKeepsCurrentValue(): void + { + $owner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر واحد نامعتبر'); + $schedule = new WeeklySchedule($doctor, []); + + $schedule->setMeta(['booking_window_unit' => 'day', 'booking_window_value' => 10]); + $schedule->setMeta(['booking_window_unit' => 'year']); + + $this->assertSame('day', $schedule->getMeta()['booking_window_unit']); + $this->assertSame(10, $schedule->getMeta()['booking_window_value']); + } +}