feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays

- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules.
- Updated unique constraints and indexes to accommodate the new clinic context.

feat(command): create AssignScheduleClinicCommand to move schedules

- Added a command to move a doctor's personal weekly schedule into a clinic context.
- Implemented checks to ensure sessions align with the target clinic.

feat(context): implement EntityContext and EntityContextResolver

- Created EntityContext to represent the effective working environment of a request (doctor or clinic).
- Developed EntityContextResolver to determine the execution context based on user roles and active contexts.

test: add ServiceModeContextTest for appointment scheduling

- Implemented tests to ensure service booking respects clinic and personal contexts.
- Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
This commit is contained in:
hamed
2026-07-18 13:32:56 +03:30
parent 2553b45990
commit f1258d206d
28 changed files with 2126 additions and 276 deletions
@@ -8,6 +8,7 @@ use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\SlotTakenException;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Appointment\Service\SlotCalculatorService;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorRepository;
@@ -32,6 +33,8 @@ class AppointmentController extends BaseController
private readonly SlotCalculatorService $slotCalculator,
private readonly PatientService $patientService,
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly \App\Clinic\Repository\ClinicRepository $clinicRepo,
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
@@ -153,10 +156,12 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
}
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date);
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic);
return $this->success([
'doctor_uuid' => $doctorUuid,
'clinic_uuid' => $clinic?->getUuid(),
'date' => $date,
'sessions' => $sessions,
]);
@@ -182,7 +187,8 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
}
$schedule = $this->scheduleRepo->findByDoctor($doctor);
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
if ($mode !== WeeklySchedule::MODE_SERVICE) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبت‌دهی سرویسی نیست', 422);
@@ -221,7 +227,8 @@ class AppointmentController extends BaseController
'date' => $date,
'total_duration_minutes' => $totalMinutes,
'buffer_minutes' => (int) $meta['buffer_minutes'],
'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes),
'clinic_uuid' => $clinic?->getUuid(),
'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes, $clinic),
]);
}
@@ -232,32 +239,68 @@ class AppointmentController extends BaseController
* GET /api/v1/appointment-booking-services/{doctorUuid}
*/
#[Route('/api/v1/appointment-booking-services/{doctorUuid}', methods: ['GET'])]
public function bookingServices(string $doctorUuid): JsonResponse
public function bookingServices(string $doctorUuid, Request $request): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
$schedule = $this->scheduleRepo->findByDoctor($doctor);
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
$services = array_map(function (\App\ClinicService\Entity\ServiceItem $i) {
$section = $i->getSection();
return [
'uuid' => $i->getUuid(),
'name' => $i->getName(),
'duration_minutes' => $i->getDurationMinutes(),
'price_rials' => $i->getPriceRials(),
'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()],
];
}, $this->itemRepo->findBookableByEntity('doctor', $doctor->getId()));
return $this->success([
'doctor_uuid' => $doctorUuid,
'clinic_uuid' => $clinic?->getUuid(),
'booking_mode' => $meta['booking_mode'],
'buffer_minutes' => (int) $meta['buffer_minutes'],
'services' => $services,
'services' => $this->bookableServices($doctor, $clinic),
]);
}
/**
* عمومی: همهٔ محل‌های نوبت‌دهی یک پزشک — مطب شخصی و هر کلینیکی که در آن برنامهٔ
* فعال دارد. سایت باید همه را نشان دهد؛ انتخاب یکی و پنهان‌کردن بقیه یعنی حذف
* بخشی از ظرفیت واقعی پزشک.
*
* GET /api/v1/appointment-booking-locations/{doctorUuid}
*/
#[Route('/api/v1/appointment-booking-locations/{doctorUuid}', methods: ['GET'])]
public function bookingLocations(string $doctorUuid): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
$locations = [];
foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) {
$clinic = $schedule->getClinic();
$meta = $schedule->getMeta();
$address = $this->addressRepo->findForContext($doctor, $clinic?->getId())[0] ?? null;
$locations[] = [
'location_uuid' => $address?->getUuid(),
'type' => $clinic === null ? 'personal' : 'clinic',
'title' => $clinic?->getName() ?? ($address?->getName() ?: 'مطب شخصی'),
'address' => $address?->getAddress(),
'clinic_uuid' => $clinic?->getUuid(),
'booking_mode' => $meta['booking_mode'],
'buffer_minutes' => (int) $meta['buffer_minutes'],
'services' => $meta['booking_mode'] === WeeklySchedule::MODE_SERVICE
? $this->bookableServices($doctor, $clinic)
: [],
'next_available_at' => $this->nextAvailableAt($doctor, $clinic),
];
}
// پیش‌فرضِ سایت = زودترین نوبت آزاد؛ محل‌های بدون ظرفیت به انتها می‌روند.
usort($locations, fn(array $a, array $b) => ($a['next_available_at'] ?? PHP_INT_MAX) <=> ($b['next_available_at'] ?? PHP_INT_MAX));
return $this->success([
'doctor_uuid' => $doctorUuid,
'booking_locations' => $locations,
]);
}
@@ -275,24 +318,26 @@ 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)));
$disabled = [];
$enabled = [];
for ($day = 1; $day <= $daysInMonth; $day++) {
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
if ($this->slotCalculator->hasAnyAvailability($doctor, $date)) {
if ($this->slotCalculator->hasAnyAvailability($doctor, $date, $clinic)) {
$enabled[] = $date;
} else {
$disabled[] = $date;
}
}
$schedule = $this->scheduleRepo->findByDoctor($doctor);
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
return $this->success([
'year' => $year,
'clinic_uuid' => $clinic?->getUuid(),
'month' => $month,
'disabled_dates' => $disabled,
'enabled_dates' => $enabled,
@@ -348,6 +393,7 @@ class AppointmentController extends BaseController
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$slotStart = (int) ($data['slot_start'] ?? 0);
$slotEnd = (int) ($data['slot_end'] ?? 0);
$clinicUuid = $data['clinic_uuid'] ?? null;
// حالت نوبت‌دهی سرویسی: مدت نوبت = مجموع مدت سرویس‌های bookableِ انتخاب‌شده،
// و slot_end سمت سرور محاسبه می‌شود (به مقدار کلاینت اعتماد نمی‌شود).
@@ -385,6 +431,14 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
$bookingClinic = $this->bookingClinic($doctor, $clinicUuid);
// سرویس باید متعلق به همان محلی باشد که نوبت در آن ثبت می‌شود؛ وگرنه بیمار
// می‌توانست سرویس کلینیک را روی نوبت مطب شخصی بنشاند.
if ($serviceItem !== null && ($err = $this->assertServicesMatchContext($serviceUuids, $doctor, $bookingClinic)) !== null) {
return $err;
}
$forSelf = (bool) ($data['for_self'] ?? true);
// کد ملی و جنسیت بیمار همیشه الزامی است (چه برای خود، چه برای دیگری).
@@ -420,7 +474,7 @@ class AppointmentController extends BaseController
}
// آدرس نوبت از روی session متناظر در برنامه‌ی هفتگی تعیین می‌شود (location_id).
$locationId = $this->resolveSlotLocationId($doctor, $slotStart);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
if ($locationId !== null) {
$appointment->setAddressId($locationId);
}
@@ -610,11 +664,75 @@ class AppointmentController extends BaseController
|| $user->hasRole('ROLE_ADMIN');
}
private function resolveSlotLocationId(Doctor $doctor, int $slotStart): ?int
/**
* محلِ نوبت‌دهی این درخواست. بدون clinic_uuid یعنی مطب شخصی پزشک — نه «هر محلی
* که پیدا شد»: با چند برنامهٔ هم‌زمان، حدس‌زدن محل یعنی ثبت خاموشِ نوبت در جای
* اشتباه.
*/
private function bookingClinic(Doctor $doctor, ?string $clinicUuid): ?Clinic
{
return $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
if ($clinicUuid === null || trim($clinicUuid) === '') {
return null;
}
$clinic = $this->clinicRepo->findByUuid(trim($clinicUuid));
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_VALIDATION_002, 'محل نوبت‌دهی یافت نشد', 404);
}
return $clinic;
}
private function assertServicesMatchContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): ?JsonResponse
{
[$type, $id] = $clinic !== null
? ['clinic', $clinic->getId()]
: ['doctor', $doctor->getId()];
foreach ($serviceUuids as $uuid) {
$section = $this->itemRepo->findByUuid($uuid)?->getSection();
if ($section === null || $section->getEntityType() !== $type || $section->getEntityId() !== $id) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', 422, 'service_item_uuids');
}
}
return null;
}
/** @return array<int, array<string, mixed>> */
private function bookableServices(Doctor $doctor, ?Clinic $clinic): array
{
[$type, $id] = $clinic !== null
? ['clinic', $clinic->getId()]
: ['doctor', $doctor->getId()];
return array_map(function (\App\ClinicService\Entity\ServiceItem $i): array {
$section = $i->getSection();
return [
'uuid' => $i->getUuid(),
'name' => $i->getName(),
'duration_minutes' => $i->getDurationMinutes(),
'price_rials' => $i->getPriceRials(),
'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()],
];
}, $this->itemRepo->findBookableByEntity($type, $id));
}
/** زودترین اسلات آزاد در ۳۰ روز آینده، یا null اگر ظرفیتی نباشد. */
private function nextAvailableAt(Doctor $doctor, ?Clinic $clinic): ?int
{
for ($i = 0; $i < 30; $i++) {
$date = date('Y-m-d', strtotime("today +{$i} day"));
$slots = $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic);
if (!empty($slots)) {
return (int) $slots[0]['start'];
}
}
return null;
}
#[OA\Patch(
path: '/api/v1/appointment/{uuid}/status',
summary: 'Update the status of an appointment',
@@ -11,11 +11,14 @@ use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Repository\DoctorAddressRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContext;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
@@ -39,12 +42,9 @@ class AppointmentSettingsController extends BaseController
) {}
/**
* در حالت نوبت‌دهی سرویسی، پزشک باید حداقل یک سرویسِ «نمایش در نوبت‌دهی»
* (bookable) داشته باشد؛ وگرنه هیچ نوبتی قابل‌محاسبه نیست.
*/
/**
* نوع نوبت‌دهی پس از اولین ثبت غیرقابل‌تغییر است. اگر قبلاً mode ذخیره شده بود
* ($prevMode !== null) و meta جدید آن را تغییر دهد، خطای 422 برمی‌گرداند.
* نوع نوبت‌دهی پس از اولین ثبت غیرقابل‌تغییر است — اما فقط داخل همان context.
* پزشکی که در مطب شخصی نوبت‌دهی اسلاتی دارد، همچنان می‌تواند در کلینیک سرویسی
* انتخاب کند.
*/
private function assertModeImmutable(?string $prevMode, array $newMeta): ?JsonResponse
{
@@ -54,10 +54,56 @@ class AppointmentSettingsController extends BaseController
return null;
}
private function serviceModeHasNoBookable(array $meta, \App\Doctor\Entity\Doctor $doctor): bool
/**
* در حالت نوبت‌دهی سرویسی، صاحبِ همین context باید حداقل یک سرویسِ
* «نمایش در نوبت‌دهی» داشته باشد؛ وگرنه هیچ نوبتی قابل‌محاسبه نیست.
*
* سرویس‌ها polymorphic‌اند و بین پزشک و کلینیک مشترک نمی‌شوند، پس شمارش باید با
* همان (entity_type, entity_id) محیط انجام شود — نه همیشه 'doctor'.
*/
private function serviceModeHasNoBookable(array $meta, Doctor $doctor, ?Clinic $clinic): bool
{
return ($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) === WeeklySchedule::MODE_SERVICE
&& $this->itemRepo->countBookableByEntity('doctor', $doctor->getId()) === 0;
if (($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) !== WeeklySchedule::MODE_SERVICE) {
return false;
}
[$type, $id] = $clinic !== null
? [EntityContext::TYPE_CLINIC, $clinic->getId()]
: [EntityContext::TYPE_DOCTOR, $doctor->getId()];
return $this->itemRepo->countBookableByEntity($type, $id) === 0;
}
private function noBookableServiceError(?Clinic $clinic): JsonResponse
{
$message = $clinic !== null
? 'برای نوبت‌دهی سرویسی، کلینیک باید حداقل یک سرویس با «نمایش در نوبت‌دهی» داشته باشد'
: 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است';
return $this->error(ErrorCodes::ERR_VALIDATION_001, $message, 422, 'booking_mode');
}
/**
* محیطی که این درخواست در آن اجرا می‌شود: کلینیکِ داده‌شده، یا null یعنی مطب
* شخصی پزشک. پزشک حتماً باید عضو آن کلینیک باشد، وگرنه اصلاً چنین محیطی وجود
* ندارد.
*/
private function contextClinic(?string $clinicUuid, Doctor $doctor): ?Clinic
{
if ($clinicUuid === null || trim($clinicUuid) === '') {
return null;
}
$clinic = $this->clinicRepo->findByUuid(trim($clinicUuid));
if ($clinic === null) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
if (!$clinic->hasDoctor($doctor)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این پزشک عضو کلینیک انتخاب‌شده نیست', 422);
}
return $clinic;
}
// ── Weekly Schedule ───────────────────────────────────────────────────────
@@ -73,21 +119,23 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($doctor, $user, 'update')) !== null) {
$clinic = $this->contextClinic($data['clinic_uuid'] ?? null, $doctor);
if (($err = $this->denyDoctorAccess($doctor, $user, 'update', $clinic)) !== null) {
return $err;
}
if (($err = $this->validateSessionsHaveLocation($data['schedule'] ?? [])) !== null) {
if (($err = $this->validateSessions($data['schedule'] ?? [], $doctor, $clinic)) !== null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, $err, 422);
}
// Only one schedule per doctor — upsert
$schedule = $this->scheduleRepo->findByDoctor($doctor);
// یک برنامه به ازای هر context — upsert
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
$prevMode = $schedule?->getStoredBookingMode();
if ($schedule !== null) {
$schedule->setSetting($data['schedule'] ?? []);
} else {
$schedule = new WeeklySchedule($doctor, $data['schedule'] ?? []);
$schedule = new WeeklySchedule($doctor, $data['schedule'] ?? [], $clinic);
}
if (isset($data['meta']) && is_array($data['meta'])) {
@@ -98,8 +146,8 @@ class AppointmentSettingsController extends BaseController
return $err;
}
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است', 422, 'booking_mode');
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor, $clinic)) {
return $this->noBookableServiceError($clinic);
}
$this->scheduleRepo->save($schedule);
@@ -110,25 +158,32 @@ class AppointmentSettingsController extends BaseController
#[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['PATCH'])]
public function updateSchedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
// uuid may be doctor uuid or schedule uuid
$schedule = $this->scheduleRepo->findByUuid($uuid);
if ($schedule === null) {
$doctor = $this->doctorRepo->findByUuid($uuid);
$schedule = $doctor ? $this->scheduleRepo->findByDoctor($doctor) : null;
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
}
$clinic = $this->contextClinic($data['clinic_uuid'] ?? $request->query->get('clinic_uuid'), $doctor);
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
} else {
$clinic = $schedule->getClinic();
}
if ($schedule === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update')) !== null) {
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update', $clinic)) !== null) {
return $err;
}
$prevMode = $schedule->getStoredBookingMode();
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['schedule'])) {
if (($err = $this->validateSessionsHaveLocation($data['schedule'])) !== null) {
if (($err = $this->validateSessions($data['schedule'], $schedule->getDoctor(), $clinic)) !== null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, $err, 422);
}
$schedule->setSetting($data['schedule']);
@@ -141,8 +196,8 @@ class AppointmentSettingsController extends BaseController
return $err;
}
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor())) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است', 422, 'booking_mode');
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor(), $clinic)) {
return $this->noBookableServiceError($clinic);
}
$this->scheduleRepo->save($schedule);
@@ -151,19 +206,23 @@ class AppointmentSettingsController extends BaseController
}
#[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['GET'])]
public function getSchedule(string $uuid, #[CurrentUser] User $user): JsonResponse
public function getSchedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// Try doctor uuid first, then schedule uuid
$doctor = $this->doctorRepo->findByUuid($uuid);
$schedule = $doctor
? $this->scheduleRepo->findByDoctor($doctor)
: $this->scheduleRepo->findByUuid($uuid);
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor !== null) {
$clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor);
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
} else {
$schedule = $this->scheduleRepo->findByUuid($uuid);
$clinic = $schedule?->getClinic();
}
if ($schedule === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'view')) !== null) {
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'view', $clinic)) !== null) {
return $err;
}
@@ -178,7 +237,7 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update')) !== null) {
if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update', $schedule->getClinic())) !== null) {
return $err;
}
@@ -190,20 +249,22 @@ class AppointmentSettingsController extends BaseController
// ── Date Overrides ────────────────────────────────────────────────────────
#[Route('/api/v1/appointment-settings/date-override/list/{doctorUuid}', methods: ['GET'])]
public function listOverrides(string $doctorUuid, #[CurrentUser] User $user): JsonResponse
public function listOverrides(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($doctor, $user, 'view')) !== null) {
$clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor);
if (($err = $this->denyDoctorAccess($doctor, $user, 'view', $clinic)) !== null) {
return $err;
}
$overrides = array_map(
fn(DateOverride $o) => $o->toArray(),
$this->overrideRepo->findByDoctor($doctor)
$this->overrideRepo->findByDoctorAndClinic($doctor, $clinic)
);
return $this->success(['data' => $overrides]);
@@ -221,7 +282,9 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($doctor, $user, 'update')) !== null) {
$clinic = $this->contextClinic($data['clinic_uuid'] ?? null, $doctor);
if (($err = $this->denyDoctorAccess($doctor, $user, 'update', $clinic)) !== null) {
return $err;
}
@@ -230,7 +293,7 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است', 422, 'date');
}
$override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false));
$override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false), $clinic);
if (isset($data['reason'])) $override->setReason($data['reason']);
if (isset($data['custom_slots'])) $override->setSetting($data['custom_slots']);
@@ -247,7 +310,7 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update')) !== null) {
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update', $override->getClinic())) !== null) {
return $err;
}
@@ -273,7 +336,7 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update')) !== null) {
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update', $override->getClinic())) !== null) {
return $err;
}
@@ -290,7 +353,7 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'view')) !== null) {
if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'view', $override->getClinic())) !== null) {
return $err;
}
@@ -300,18 +363,26 @@ class AppointmentSettingsController extends BaseController
// ── Holidays ──────────────────────────────────────────────────────────────
#[Route('/api/v1/appointment-settings/holidays/list/{doctorUuid}', methods: ['GET'])]
public function listHolidays(string $doctorUuid, #[CurrentUser] User $user): JsonResponse
public function listHolidays(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($doctor, $user, 'view')) !== null) {
$clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor);
if (($err = $this->denyDoctorAccess($doctor, $user, 'view', $clinic)) !== null) {
return $err;
}
$items = array_map(fn(Holiday $h) => $h->toArray(), $this->holidayRepo->findAllByDoctor($doctor));
// محیط کلینیک تعطیلی سراسری پزشک را هم می‌بیند (باید بداند پزشک نیست)، اما
// editable=false یعنی اجازهٔ تغییرش را ندارد.
$items = array_map(function (Holiday $h) use ($clinic): array {
$data = $h->toArray();
$data['editable'] = $clinic === null || $h->getClinic() !== null;
return $data;
}, $this->holidayRepo->findAllByDoctorInContext($doctor, $clinic));
return $this->success(['data' => $items]);
}
@@ -324,7 +395,7 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update')) !== null) {
if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update', $holiday->getClinic())) !== null) {
return $err;
}
@@ -346,10 +417,18 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($doctor, $user, 'update')) !== null) {
$clinic = $this->contextClinic($data['clinic_uuid'] ?? null, $doctor);
if (($err = $this->denyDoctorAccess($doctor, $user, 'update', $clinic)) !== null) {
return $err;
}
// تعطیلی سراسری (بدون clinic_uuid) یعنی «پزشک در هیچ محلی نیست» و مطب شخصی
// را هم می‌بندد؛ فقط خود پزشک یا ادمین حق چنین کاری دارد.
if ($clinic === null && !$user->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $user->getId()) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'کلینیک فقط می‌تواند تعطیلی مخصوص خودش را ثبت کند', 403, 'clinic_uuid');
}
$startTs = strtotime($startStr);
$endTs = strtotime($endStr);
@@ -357,7 +436,7 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ نادرست است', 422);
}
$holiday = new Holiday($doctor, $startTs, $endTs);
$holiday = new Holiday($doctor, $startTs, $endTs, $clinic);
if (isset($data['reason'])) $holiday->setReason($data['reason']);
$this->holidayRepo->save($holiday);
@@ -373,7 +452,7 @@ class AppointmentSettingsController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update')) !== null) {
if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update', $holiday->getClinic())) !== null) {
return $err;
}
@@ -397,31 +476,23 @@ class AppointmentSettingsController extends BaseController
// ── Available Locations ───────────────────────────────────────────────────
#[Route('/api/v1/appointment-settings/available-locations/{doctorUuid}', methods: ['GET'])]
public function availableLocations(string $doctorUuid, #[CurrentUser] User $user): JsonResponse
public function availableLocations(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if (($err = $this->denyDoctorAccess($doctor, $user, 'view')) !== null) {
$clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor);
if (($err = $this->denyDoctorAccess($doctor, $user, 'view', $clinic)) !== null) {
return $err;
}
$clinics = $this->clinicRepo->findByDoctor($doctor);
$clinicIds = array_map(fn(Clinic $c) => $c->getId(), $clinics);
$clinicMap = [];
foreach ($clinics as $clinic) {
$clinicMap[$clinic->getId()] = $clinic->getName();
}
$addresses = $this->addressRepo->findAvailableForDoctor($doctor, $clinicIds);
$result = array_map(function (DoctorAddress $a) use ($clinicMap): array {
$data = $a->toArray();
$data['clinic_name'] = $a->getClinicId() !== null ? ($clinicMap[$a->getClinicId()] ?? null) : null;
return $data;
}, $addresses);
$result = array_map(
fn(DoctorAddress $a): array => $a->toArray($clinic?->getName()),
$this->addressRepo->findForContext($doctor, $clinic?->getId())
);
return $this->success(['data' => $result]);
}
@@ -429,39 +500,57 @@ class AppointmentSettingsController extends BaseController
/**
* تنها نقطهٔ تصمیم‌گیری دربارهٔ «چه کسی تنظیمات نوبت‌دهی این پزشک را می‌بیند/می‌نویسد».
*
* مجاز: ادمین، خود پزشک، مالکِ کلینیکی که پزشک عضو آن است، و پزشکِ عضوِ همان
* کلینیک در صورت داشتن مجوز appointment_settings مربوطه.
* تصمیم به context وابسته است و نه فقط به شخص:
* • مطب شخصی ($clinic === null) فقط برای خود پزشک و ادمین باز است — مالک کلینیک
* هیچ کاری با برنامهٔ شخصی پزشک ندارد.
* • محیط کلینیک با مجوز appointment_settings همان کلینیک سنجیده می‌شود، نه
* حلقه روی همهٔ کلینیک‌های پزشک.
*
* @param 'view'|'update' $action
*/
private function denyDoctorAccess(\App\Doctor\Entity\Doctor $doctor, User $user, string $action): ?JsonResponse
private function denyDoctorAccess(Doctor $doctor, User $user, string $action, ?Clinic $clinic): ?JsonResponse
{
if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) {
return null;
}
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
if ($this->permChecker->can($user, $clinic, 'appointment_settings', $action)) {
return null;
}
if ($clinic !== null && $this->permChecker->can($user, $clinic, 'appointment_settings', $action)) {
return null;
}
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
/**
* هر session فعال در برنامه‌ی هفتگی باید آدرس (location_id) داشته باشد.
* در صورت نقص، پیام خطا برمی‌گرداند؛ در غیر این صورت null.
* هر شیفت فعال باید آدرسی داشته باشد که به همین context تعلق دارد. بدون بررسی
* دوم، کلینیک می‌توانست شیفت را روی آدرس مطب شخصی پزشک بنشاند (و برعکس).
*/
private function validateSessionsHaveLocation(array $schedule): ?string
private function validateSessions(array $schedule, Doctor $doctor, ?Clinic $clinic): ?string
{
$allowed = [];
foreach ($this->addressRepo->findForContext($doctor, $clinic?->getId()) as $address) {
$allowed[(string) $address->getId()] = true;
}
foreach ($schedule as $day) {
foreach (($day['sessions'] ?? []) as $session) {
if (($session['active'] ?? false) && empty($session['location_id'])) {
if (!($session['active'] ?? false)) {
continue;
}
$locationId = (string) ($session['location_id'] ?? '');
if ($locationId === '') {
return 'برای هر شیفت فعال باید آدرس (مطب/کلینیک) انتخاب شود';
}
if (!isset($allowed[$locationId])) {
return $clinic !== null
? 'آدرس انتخاب‌شده متعلق به این کلینیک نیست'
: 'آدرس انتخاب‌شده متعلق به مطب شخصی این پزشک نیست';
}
}
}
return null;
}
}