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
+1 -1
View File
@@ -936,7 +936,7 @@ class AdminApiController extends BaseController
}
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
$appointment->setClinic($bookingClinic);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true);
if ($locationId !== null) $appointment->setAddressId($locationId);
// نوبتِ ثبت‌شده توسط ادمین پرداخت آنلاین ندارد و منتظر چیزی نیست؛ قطعی است.
@@ -158,8 +158,9 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
}
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic);
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic, $forManagement);
return $this->success([
'doctor_uuid' => $doctorUuid,
@@ -168,7 +169,7 @@ class AppointmentController extends BaseController
'sessions' => $sessions,
// خالی‌بودن دلایل مختلفی دارد؛ کلاینت نباید همه را «تعطیل» بنامد.
'empty_reason' => $sessions === []
? $this->slotCalculator->explainEmptyDay($doctor, $date, $clinic)
? $this->slotCalculator->explainEmptyDay($doctor, $date, $clinic, $forManagement)
: null,
]);
}
@@ -234,7 +235,13 @@ class AppointmentController extends BaseController
'total_duration_minutes' => $totalMinutes,
'buffer_minutes' => (int) $meta['buffer_minutes'],
'clinic_uuid' => $clinic?->getUuid(),
'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes, $clinic),
'start_times' => $this->slotCalculator->getServiceStartTimes(
$doctor,
$date,
$totalMinutes,
$clinic,
$this->isManagementContext($request, $doctor, $clinic),
),
]);
}
@@ -306,8 +313,9 @@ class AppointmentController extends BaseController
continue;
}
$meta = $schedule->getMeta();
$address = $byId[$hours[0]['location_id']] ?? $addresses[0];
$meta = $schedule->getMeta();
$address = $byId[$hours[0]['location_id']] ?? $addresses[0];
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
$locations[] = [
'location_uuid' => $address->getUuid(),
@@ -321,10 +329,10 @@ class AppointmentController extends BaseController
'services' => $meta['booking_mode'] === WeeklySchedule::MODE_SERVICE
? $this->bookableServices($doctor, $clinic)
: [],
'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic),
'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic, 30, $forManagement),
'available_on_date' => $date === ''
? null
: $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic) !== [],
: $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic, $forManagement) !== [],
];
}
@@ -352,14 +360,15 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال یا ماه نامعتبر است', 422, 'month');
}
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month)));
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
$daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month)));
$disabled = [];
$enabled = [];
for ($day = 1; $day <= $daysInMonth; $day++) {
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
if ($this->slotCalculator->hasAnyAvailability($doctor, $date, $clinic)) {
if ($this->slotCalculator->hasAnyAvailability($doctor, $date, $clinic, $forManagement)) {
$enabled[] = $date;
} else {
$disabled[] = $date;
@@ -747,6 +756,25 @@ class AppointmentController extends BaseController
return $this->bookingContext->resolve($doctor, $clinicUuid);
}
/**
* آیا این درخواستِ اسلات از پنل مدیریت است (پزشک/منشی/ادمینِ دارای دسترسی)؟
* اندپوینت‌های اسلات عمومی‌اند؛ فقط با management=1 + کاربرِ احرازشده و مجاز،
* توگلِ نوبت‌دهی آنلاین دور زده می‌شود. در غیر این‌صورت مثل رزرو عمومی رفتار می‌شود
* (fail-safe عمومی) تا اسلاتِ خاموش به بازدیدکنندهٔ سایت نشت نکند.
*/
private function isManagementContext(Request $request, Doctor $doctor, ?Clinic $clinic): bool
{
if ($request->query->get('management') !== '1') {
return false;
}
$user = $this->getUser();
if (!$user instanceof User) {
return false;
}
return $this->accessChecker->canManageContext($user, $doctor, $clinic);
}
private function assertServicesMatchContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): ?JsonResponse
{
[$type, $id] = $clinic !== null
@@ -146,7 +146,7 @@ class MyAppointmentsController extends BaseController
// یعنی مطب شخصی، نه «هر برنامه‌ای که پیدا شد».
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
$appointment->setClinic($bookingClinic);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true);
if ($locationId !== null) $appointment->setAddressId($locationId);
// Optional clinic-workflow fields (بخش/سرویس/پرسنل/بیعانه) — unknown uuid → 422.
@@ -78,6 +78,67 @@ class AppointmentAccessChecker
return $this->secretaryCan($appointment, $user, $action);
}
/**
* آیا این کاربر می‌تواند در محیطِ (پزشک + کلینیک) نوبت مدیریت/ثبت کند — بدون آنکه
* هنوز نوبتی وجود داشته باشد. برای اندپوینت‌های اسلات که عمومی‌اند ولی وقتی از پنل
* (با management=1) صدا زده می‌شوند باید توگلِ نوبت‌دهی آنلاین را دور بزنند.
*
* منطق همان can() است اما روی محیط، نه روی یک Appointment مشخص.
*/
public function canManageContext(User $user, \App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic): bool
{
if ($user->hasRole('ROLE_ADMIN')) {
return true;
}
if ($doctor->getUser()->getId() === $user->getId()) {
return true;
}
if ($clinic !== null && $this->clinicPermissions->can($user, $clinic, self::RESOURCE, self::ACTION_UPDATE_STATUS)) {
return true;
}
return $this->secretaryCanContext($user, $doctor, $clinic);
}
/**
* منشی در محیطِ فعالِ خودش، اما روی محیط (پزشک/کلینیک) نه یک نوبت مشخص.
* قرینهٔ secretaryCan() است.
*/
private function secretaryCanContext(User $user, \App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic): bool
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
return false;
}
$ctxClinic = $this->clinicRepo->findByUuid($dbUuid);
if ($ctxClinic !== null) {
if ($clinic === null || $ctxClinic->getId() !== $clinic->getId()) {
return false;
}
$relation = $this->secretaryRepo->findActiveClinicRow($user, $ctxClinic, $doctor);
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, self::ACTION_UPDATE_STATUS);
}
// محیطِ مطب شخصی: نوبت هم باید در همان مطب شخصی باشد (clinic == null).
if ($clinic !== null) {
return false;
}
$ctxDoctor = $this->doctorRepo->findByUuid($dbUuid);
if ($ctxDoctor === null || $ctxDoctor->getId() !== $doctor->getId()) {
return false;
}
$relation = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, self::ACTION_UPDATE_STATUS);
}
/**
* کلینیکی که این کاربر در آن اجازهٔ دیدن نوبت‌های این پزشک را دارد، یا null.
* برای لیست‌هایی که باید به یک محیط محدود شوند (نه تک‌نوبت).
@@ -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 [];
}