feat: Enhance appointment management by decoupling online booking toggle for admin context

- Introduced management mode for appointment slots, allowing doctors, admins, and clinic managers to view and book slots regardless of the online booking status.
- Updated SlotCalculatorService to accept a management context parameter, bypassing online booking restrictions.
- Modified appointment-related endpoints to handle management context and ensure proper authorization checks.
- Added tests to verify that management users can access slots even when online booking is disabled, while public users are still restricted.
- Improved documentation for API endpoints to reflect new management parameters and behaviors.
This commit is contained in:
hamed
2026-07-22 16:43:56 +03:30
parent 5507b42fd8
commit ed516c81a8
16 changed files with 658 additions and 83 deletions
@@ -32,9 +32,9 @@ class SlotCalculatorService
*
* @return array[] [{start, end, start_time, end_time, location_id}]
*/
public function getAvailableSlots(Doctor $doctor, string $date, ?Clinic $clinic = null): array
public function getAvailableSlots(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
{
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
$sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement);
if (empty($sessions)) return [];
$flat = array_merge(...array_map(fn($s) => $s['slots'], $sessions));
return $this->filterBookedSlots($doctor, $flat);
@@ -42,11 +42,14 @@ class SlotCalculatorService
/**
* آدرس (location_id) متناظر با اسلاتِ شروع‌شده در تاریخ مشخص. اگر پیدا نشد null.
*
* برای ثبتِ نوبت از پنل ($forManagement=true) نباید خاموش‌بودنِ نوبت‌دهی آنلاین
* باعث گم‌شدنِ location شود؛ وگرنه نوبتِ دستی بدون آدرس ثبت می‌شد.
*/
public function resolveSlotLocationId(Doctor $doctor, int $slotStart, ?Clinic $clinic = null): ?int
public function resolveSlotLocationId(Doctor $doctor, int $slotStart, ?Clinic $clinic = null, bool $forManagement = false): ?int
{
$date = date('Y-m-d', $slotStart);
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
$sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement);
foreach ($sessions as $session) {
foreach (($session['slots'] ?? []) as $slot) {
if ((int) ($slot['start'] ?? 0) === $slotStart) {
@@ -64,9 +67,9 @@ class SlotCalculatorService
*
* @return array[] [{start_time, end_time, slots: [{start, end, start_time, end_time, location_id, is_available}]}]
*/
public function getAllSlotsWithAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null): array
public function getAllSlotsWithAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
{
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
$sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement);
$now = time();
return array_map(fn(array $session) => [
'start_time' => $session['start_time'],
@@ -82,9 +85,9 @@ class SlotCalculatorService
* Whether a doctor has at least one slot on the given date.
* Lightweight check for the month-availability endpoint.
*/
public function hasAnyAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null): bool
public function hasAnyAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): bool
{
return !empty($this->buildAllSessions($doctor, $date, $clinic));
return !empty($this->buildAllSessions($doctor, $date, $clinic, $forManagement));
}
/**
@@ -99,7 +102,7 @@ class SlotCalculatorService
*
* @return array<array{start:int,end:int,start_time:string,end_time:string,location_id:?int}>
*/
public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null): array
public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null, bool $forManagement = false): array
{
if ($durationMinutes <= 0) return [];
@@ -107,7 +110,7 @@ class SlotCalculatorService
$durSec = $durationMinutes * 60;
$needSec = $durSec + $buffer * 60; // فضای لازم شامل بافر
$sessions = $this->buildAllSessions($doctor, $date, $clinic); // window/holiday/override/booking-window رعایت می‌شود
$sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement); // window/holiday/override/booking-window رعایت می‌شود
if (empty($sessions)) return [];
$dayStart = (int) strtotime($date . ' 00:00:00');
@@ -148,9 +151,9 @@ class SlotCalculatorService
* بدون شیفت و خارج‌بودن از بازهٔ نوبت‌دهی چهار چیز متفاوت‌اند و کاربر باید
* بداند کدام‌یک رخ داده تا بداند چه کاری باید بکند.
*/
public function explainEmptyDay(Doctor $doctor, string $date, ?Clinic $clinic = null): ?string
public function explainEmptyDay(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): ?string
{
if ($this->buildAllSessions($doctor, $date, $clinic) !== []) {
if ($this->buildAllSessions($doctor, $date, $clinic, $forManagement) !== []) {
return null;
}
@@ -165,7 +168,7 @@ class SlotCalculatorService
return self::EMPTY_HOLIDAY;
}
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic)) {
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic, $forManagement)) {
return self::EMPTY_OUTSIDE_WINDOW;
}
@@ -179,7 +182,7 @@ class SlotCalculatorService
* و نوبت‌های اشغال یک‌بار برای کل بازه واکشی می‌شوند و بقیه در حافظه محاسبه
* می‌شود: ۴ کوئری ثابت به‌جای رشدِ خطی با تعداد روز و اسلات.
*/
public function findNextAvailableStart(Doctor $doctor, ?Clinic $clinic = null, int $daysAhead = 30): ?int
public function findNextAvailableStart(Doctor $doctor, ?Clinic $clinic = null, int $daysAhead = 30, bool $forManagement = false): ?int
{
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
if ($schedule === null) {
@@ -187,7 +190,7 @@ class SlotCalculatorService
}
$meta = $schedule->getMeta();
if (!($meta['online_booking_enabled'] ?? true)) {
if (!$forManagement && !($meta['online_booking_enabled'] ?? true)) {
return null;
}
@@ -285,14 +288,22 @@ class SlotCalculatorService
/**
* Booking is allowed only when online booking is enabled and the date is
* today..(today + window). Past dates are always rejected.
*
* مدیریت پنل ($forManagement=true): خاموش‌بودنِ نوبت‌دهی آنلاین و سقفِ بازهٔ
* مجاز رزرو (advance window) فقط قواعد رزرو عمومی از سایت‌اند و نباید جلوی
* نمایش/ثبتِ نوبت توسط پزشک/منشی/ادمین را بگیرند. تاریخِ گذشته همچنان رد می‌شود.
*/
private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic): bool
private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic, bool $forManagement = false): bool
{
$todayStart = (int) strtotime('today 00:00:00');
if ($dayStart < $todayStart) {
return false;
}
if ($forManagement) {
return true;
}
$meta = $this->getBookingMeta($doctor, $clinic);
if (!($meta['online_booking_enabled'] ?? true)) {
return false;
@@ -316,13 +327,14 @@ class SlotCalculatorService
*
* @return array[] [{start_time: string, end_time: string, slots: array[]}]
*/
private function buildAllSessions(Doctor $doctor, string $date, ?Clinic $clinic = null): array
private function buildAllSessions(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
{
$dayStart = (int) strtotime($date . ' 00:00:00');
$dayEnd = $dayStart + 86400;
// 0. Online booking disabled or date outside the booking window
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic)) {
// (در کانتکست مدیریت این دو نادیده گرفته می‌شوند — رجوع به isWithinBookingWindow)
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic, $forManagement)) {
return [];
}