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:
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user