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
@@ -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;
}
}