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
@@ -0,0 +1,112 @@
<?php
namespace App\Appointment\Command;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorAddressRepository;
use App\Doctor\Repository\DoctorRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Moves an existing weekly schedule from the personal context into a clinic.
*
* The clinic_id migration marks every pre-existing schedule as personal, because
* nothing in the data says otherwise. A schedule whose sessions actually point at
* a clinic address needs to be moved by hand — this command does that, and refuses
* when the sessions do not agree with the target clinic.
*/
#[AsCommand(name: 'app:schedule:assign-clinic', description: 'Move a doctor\'s personal weekly schedule into a clinic context')]
class AssignScheduleClinicCommand extends Command
{
public function __construct(
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorAddressRepository $addressRepo,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('doctor-uuid', InputArgument::REQUIRED, 'Doctor uuid')
->addArgument('clinic-uuid', InputArgument::REQUIRED, 'Target clinic uuid')
->addOption('force', null, InputOption::VALUE_NONE, 'Move even when some sessions use an address outside the clinic');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$doctor = $this->doctorRepo->findByUuid((string) $input->getArgument('doctor-uuid'));
$clinic = $this->clinicRepo->findByUuid((string) $input->getArgument('clinic-uuid'));
if ($doctor === null || $clinic === null) {
$io->error('Doctor or clinic not found.');
return Command::FAILURE;
}
if (!$clinic->hasDoctor($doctor)) {
$io->error('This doctor is not a member of that clinic.');
return Command::FAILURE;
}
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, null);
if ($schedule === null) {
$io->warning('This doctor has no personal schedule to move.');
return Command::SUCCESS;
}
if ($this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic) !== null) {
$io->error('A schedule already exists for this doctor in that clinic; merge it manually.');
return Command::FAILURE;
}
$foreign = $this->sessionsOutsideClinic($schedule->getDaySchedule(), $doctor, $clinic->getId());
if ($foreign !== [] && !$input->getOption('force')) {
$io->error(sprintf(
'Sessions use address ids outside the clinic: %s. Re-run with --force to move anyway.',
implode(', ', $foreign)
));
return Command::FAILURE;
}
$schedule->setClinic($clinic);
$this->em->flush();
$io->success(sprintf('Schedule %s moved to clinic "%s".', $schedule->getUuid(), $clinic->getName()));
return Command::SUCCESS;
}
/** @return int[] address ids referenced by the schedule that the clinic does not own */
private function sessionsOutsideClinic(array $daySchedule, \App\Doctor\Entity\Doctor $doctor, int $clinicId): array
{
$owned = [];
foreach ($this->addressRepo->findForContext($doctor, $clinicId) as $address) {
$owned[(int) $address->getId()] = true;
}
$foreign = [];
foreach ($daySchedule as $day) {
foreach (($day['sessions'] ?? []) as $session) {
$id = (int) ($session['location_id'] ?? 0);
if ($id > 0 && !isset($owned[$id])) {
$foreign[$id] = true;
}
}
}
return array_keys($foreign);
}
}
@@ -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;
}
}
+20 -2
View File
@@ -2,14 +2,19 @@
namespace App\Appointment\Entity;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use Doctrine\ORM\Mapping as ORM;
use App\Appointment\Repository\DateOverrideRepository;
use Symfony\Component\Uid\Uuid;
/**
* استثنای ساعت کاری یک روز خاص. چون خودِ ساعت کاری per-context است، استثنای آن هم
* per-context است: clinic باید همان clinic برنامهٔ هفتگی متناظر باشد (NULL = شخصی).
*/
#[ORM\Entity(repositoryClass: DateOverrideRepository::class)]
#[ORM\Table(name: 'date_overrides')]
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_date', columns: ['doctor_id', 'date'])]
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_clinic_date', columns: ['doctor_id', 'clinic_key', 'date'])]
class DateOverride
{
#[ORM\Id]
@@ -24,6 +29,15 @@ class DateOverride
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
/** NULL = مطب شخصی پزشک. */
#[ORM\ManyToOne(targetEntity: Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Clinic $clinic = null;
/** ستون تولیدشده: IFNULL(clinic_id, 0) — تا یکتایی با clinic_id تهی هم برقرار بماند. */
#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')]
private int $clinicKey = 0;
#[ORM\Column(type: 'integer')]
private int $date;
@@ -42,10 +56,11 @@ class DateOverride
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(Doctor $doctor, int $date, bool $active = false)
public function __construct(Doctor $doctor, int $date, bool $active = false, ?Clinic $clinic = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->doctor = $doctor;
$this->clinic = $clinic;
$this->date = $date;
$this->active = $active;
$this->createdAt = time();
@@ -55,6 +70,7 @@ class DateOverride
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getClinic(): ?Clinic { return $this->clinic; }
public function getDate(): int { return $this->date; }
public function isActive(): bool { return $this->active; }
public function getSetting(): ?array { return $this->setting; }
@@ -72,6 +88,8 @@ class DateOverride
return [
'uuid' => $this->uuid,
'doctor_uuid' => $this->doctor->getUuid(),
'clinic_uuid' => $this->clinic?->getUuid(),
'context' => $this->clinic === null ? 'personal' : 'clinic',
'date' => $this->date,
'active' => $this->active,
'reason' => $this->reason,
+17 -1
View File
@@ -2,11 +2,17 @@
namespace App\Appointment\Entity;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use Doctrine\ORM\Mapping as ORM;
use App\Appointment\Repository\HolidayRepository;
use Symfony\Component\Uid\Uuid;
/**
* تعطیلی پزشک. برخلاف WeeklySchedule و DateOverride، تعطیلی پیش‌فرضاً سراسری است:
* «پزشک آن روز نیست» یک واقعیت فیزیکی است و هم‌زمان روی مطب شخصی و همهٔ کلینیک‌ها
* اثر می‌گذارد (clinic = null). مقدار غیر-NULL یعنی پزشک فقط در همان کلینیک نیست.
*/
#[ORM\Entity(repositoryClass: HolidayRepository::class)]
#[ORM\Table(name: 'holidays')]
#[ORM\Index(columns: ['doctor_id', 'start_date', 'end_date'], name: 'idx_holidays_doctor_range')]
@@ -24,6 +30,11 @@ class Holiday
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
/** NULL = پزشک در هیچ محلی نیست (همهٔ contextها). */
#[ORM\ManyToOne(targetEntity: Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Clinic $clinic = null;
#[ORM\Column(name: 'start_date', type: 'integer')]
private int $startDate;
@@ -42,10 +53,11 @@ class Holiday
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(Doctor $doctor, int $startDate, int $endDate)
public function __construct(Doctor $doctor, int $startDate, int $endDate, ?Clinic $clinic = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->doctor = $doctor;
$this->clinic = $clinic;
$this->startDate = $startDate;
$this->endDate = $endDate;
$this->createdAt = time();
@@ -55,6 +67,8 @@ class Holiday
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getClinic(): ?Clinic { return $this->clinic; }
public function isGlobal(): bool { return $this->clinic === null; }
public function getStartDate(): int { return $this->startDate; }
public function getEndDate(): int { return $this->endDate; }
public function isActive(): bool { return $this->active; }
@@ -72,6 +86,8 @@ class Holiday
return [
'uuid' => $this->uuid,
'doctor_uuid' => $this->doctor->getUuid(),
'clinic_uuid' => $this->clinic?->getUuid(),
'scope' => $this->clinic === null ? 'global' : 'clinic',
'start_date' => $this->startDate,
'end_date' => $this->endDate,
'active' => $this->active,
+38 -3
View File
@@ -2,14 +2,22 @@
namespace App\Appointment\Entity;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use Doctrine\ORM\Mapping as ORM;
use App\Appointment\Repository\WeeklyScheduleRepository;
use Symfony\Component\Uid\Uuid;
/**
* برنامهٔ هفتگی نوبت‌دهی یک پزشک در یک context مشخص.
*
* context با ستون clinic_id بیان می‌شود: NULL یعنی مطب شخصی پزشک، و مقدار غیر-NULL
* یعنی همان پزشک در آن کلینیک. یک پزشک می‌تواند هم‌زمان چند برنامه داشته باشد
* (شخصی + یکی به ازای هر کلینیک) و این برنامه‌ها کاملاً مستقل‌اند.
*/
#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)]
#[ORM\Table(name: 'weekly_schedules')]
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor', columns: ['doctor_id'])]
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_key'])]
class WeeklySchedule
{
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
@@ -35,10 +43,25 @@ class WeeklySchedule
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\OneToOne(targetEntity: Doctor::class)]
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
/** NULL = مطب شخصی پزشک. */
#[ORM\ManyToOne(targetEntity: Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Clinic $clinic = null;
/**
* ستون تولیدشدهٔ پایگاه‌داده: IFNULL(clinic_id, 0).
*
* MySQL/MariaDB مقادیر NULL را در unique index متمایز می‌شمارند، پس
* UNIQUE(doctor_id, clinic_id) جلوی دو برنامهٔ شخصی برای یک پزشک را نمی‌گرفت.
* این ستون NULL را به 0 نگاشت می‌کند تا یکتایی در سطح دیتابیس تضمین شود.
*/
#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')]
private int $clinicKey = 0;
#[ORM\Column(type: 'json')]
private array $setting = [];
@@ -48,10 +71,11 @@ class WeeklySchedule
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(Doctor $doctor, array $setting)
public function __construct(Doctor $doctor, array $setting, ?Clinic $clinic = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->doctor = $doctor;
$this->clinic = $clinic;
$this->setting = $setting;
$this->createdAt = time();
$this->updatedAt = time();
@@ -60,6 +84,15 @@ class WeeklySchedule
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getClinic(): ?Clinic { return $this->clinic; }
/** فقط برای انتقال دستی برنامه‌های قدیمی به محیط کلینیک (app:schedule:assign-clinic). */
public function setClinic(?Clinic $clinic): self
{
$this->clinic = $clinic;
$this->updatedAt = time();
return $this;
}
public function getSetting(): array { return $this->setting; }
public function setSetting(array $setting): self
@@ -119,6 +152,8 @@ class WeeklySchedule
return [
'uuid' => $this->uuid,
'doctor_uuid' => $this->doctor->getUuid(),
'clinic_uuid' => $this->clinic?->getUuid(),
'context' => $this->clinic === null ? 'personal' : 'clinic',
'schedule' => $this->getDaySchedule(),
'meta' => $this->getMeta(),
// نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود (پنل توگل را غیرفعال می‌کند).
@@ -3,6 +3,7 @@
namespace App\Appointment\Repository;
use App\Appointment\Entity\DateOverride;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -19,10 +20,32 @@ class DateOverrideRepository extends ServiceEntityRepository
return $this->findOneBy(['uuid' => $uuid]);
}
/**
* استثناهای یک context. برخلاف تعطیلی، هیچ اجتماعی در کار نیست: استثنای کلینیک
* فقط در همان کلینیک دیده می‌شود و استثنای شخصی فقط در مطب شخصی.
*
* @return DateOverride[]
*/
public function findByDoctorAndClinic(Doctor $doctor, ?Clinic $clinic): array
{
$qb = $this->createQueryBuilder('o')
->where('o.doctor = :doctor')
->setParameter('doctor', $doctor)
->orderBy('o.date', 'ASC');
if ($clinic === null) {
$qb->andWhere('o.clinic IS NULL');
} else {
$qb->andWhere('o.clinic = :clinic')->setParameter('clinic', $clinic);
}
return $qb->getQuery()->getResult();
}
/** @return DateOverride[] */
public function findByDoctor(Doctor $doctor): array
{
return $this->findBy(['doctor' => $doctor], ['date' => 'ASC']);
return $this->findByDoctorAndClinic($doctor, null);
}
public function save(DateOverride $entity, bool $flush = true): void
@@ -3,6 +3,7 @@
namespace App\Appointment\Repository;
use App\Appointment\Entity\Holiday;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -30,19 +31,48 @@ class HolidayRepository extends ServiceEntityRepository
->getResult();
}
/** @return Holiday[] */
public function findActiveByDoctor(Doctor $doctor, int $from, int $to): array
/**
* تعطیلی‌های دیده‌شده در یک context: اجتماع تعطیلی‌های سراسری پزشک با
* تعطیلی‌های مخصوص همان کلینیک. $clinic === null یعنی مطب شخصی، که فقط
* تعطیلی سراسری می‌بیند.
*
* @return Holiday[]
*/
public function findAllByDoctorInContext(Doctor $doctor, ?Clinic $clinic): array
{
return $this->createQueryBuilder('h')
$qb = $this->createQueryBuilder('h')
->where('h.doctor = :doctor')
->setParameter('doctor', $doctor)
->orderBy('h.startDate', 'DESC');
if ($clinic === null) {
$qb->andWhere('h.clinic IS NULL');
} else {
$qb->andWhere('h.clinic IS NULL OR h.clinic = :clinic')->setParameter('clinic', $clinic);
}
return $qb->getQuery()->getResult();
}
/** @return Holiday[] */
public function findActiveByDoctor(Doctor $doctor, int $from, int $to, ?Clinic $clinic = null): array
{
$qb = $this->createQueryBuilder('h')
->where('h.doctor = :doctor')
->andWhere('h.active = true')
->andWhere('h.startDate <= :to')
->andWhere('h.endDate >= :from')
->setParameter('doctor', $doctor)
->setParameter('from', $from)
->setParameter('to', $to)
->getQuery()
->getResult();
->setParameter('to', $to);
if ($clinic === null) {
$qb->andWhere('h.clinic IS NULL');
} else {
$qb->andWhere('h.clinic IS NULL OR h.clinic = :clinic')->setParameter('clinic', $clinic);
}
return $qb->getQuery()->getResult();
}
public function save(Holiday $entity, bool $flush = true): void
@@ -3,6 +3,7 @@
namespace App\Appointment\Repository;
use App\Appointment\Entity\WeeklySchedule;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -14,9 +15,45 @@ class WeeklyScheduleRepository extends ServiceEntityRepository
parent::__construct($registry, WeeklySchedule::class);
}
/**
* برنامهٔ یک context مشخص. $clinic === null یعنی مطب شخصی.
*
* چون MySQL در unique index مقادیر NULL را متمایز می‌شمارد، یکتایی رکورد شخصی
* را همین متد تضمین می‌کند: قبل از ساخت برنامهٔ جدید همیشه صدا زده می‌شود.
*/
public function findByDoctorAndClinic(Doctor $doctor, ?Clinic $clinic): ?WeeklySchedule
{
$qb = $this->createQueryBuilder('ws')
->where('ws.doctor = :doctor')
->setParameter('doctor', $doctor);
if ($clinic === null) {
$qb->andWhere('ws.clinic IS NULL');
} else {
$qb->andWhere('ws.clinic = :clinic')->setParameter('clinic', $clinic);
}
return $qb->setMaxResults(1)->getQuery()->getOneOrNullResult();
}
/** همهٔ برنامه‌های پزشک در همهٔ contextها (شخصی + هر کلینیک). @return WeeklySchedule[] */
public function findAllByDoctor(Doctor $doctor): array
{
return $this->createQueryBuilder('ws')
->where('ws.doctor = :doctor')
->setParameter('doctor', $doctor)
->orderBy('ws.clinic', 'ASC')
->getQuery()
->getResult();
}
/**
* @deprecated برنامهٔ context شخصی را برمی‌گرداند. برای کد جدید از
* findByDoctorAndClinic() استفاده کن تا context صریح باشد.
*/
public function findByDoctor(Doctor $doctor): ?WeeklySchedule
{
return $this->findOneBy(['doctor' => $doctor]);
return $this->findByDoctorAndClinic($doctor, null);
}
/** @param Doctor[] $doctors @return WeeklySchedule[] */
@@ -7,6 +7,7 @@ use App\Appointment\Repository\DateOverrideRepository;
use App\Appointment\Repository\HolidayRepository;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Appointment\Entity\WeeklySchedule;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
@@ -25,9 +26,9 @@ class SlotCalculatorService
*
* @return array[] [{start, end, start_time, end_time, location_id}]
*/
public function getAvailableSlots(Doctor $doctor, string $date): array
public function getAvailableSlots(Doctor $doctor, string $date, ?Clinic $clinic = null): array
{
$sessions = $this->buildAllSessions($doctor, $date);
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
if (empty($sessions)) return [];
$flat = array_merge(...array_map(fn($s) => $s['slots'], $sessions));
return $this->filterBookedSlots($doctor, $flat);
@@ -36,10 +37,10 @@ class SlotCalculatorService
/**
* آدرس (location_id) متناظر با اسلاتِ شروع‌شده در تاریخ مشخص. اگر پیدا نشد null.
*/
public function resolveSlotLocationId(Doctor $doctor, int $slotStart): ?int
public function resolveSlotLocationId(Doctor $doctor, int $slotStart, ?Clinic $clinic = null): ?int
{
$date = date('Y-m-d', $slotStart);
$sessions = $this->buildAllSessions($doctor, $date);
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
foreach ($sessions as $session) {
foreach (($session['slots'] ?? []) as $slot) {
if ((int) ($slot['start'] ?? 0) === $slotStart) {
@@ -57,9 +58,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): array
public function getAllSlotsWithAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null): array
{
$sessions = $this->buildAllSessions($doctor, $date);
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
$now = time();
return array_map(fn(array $session) => [
'start_time' => $session['start_time'],
@@ -75,9 +76,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): bool
public function hasAnyAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null): bool
{
return !empty($this->buildAllSessions($doctor, $date));
return !empty($this->buildAllSessions($doctor, $date, $clinic));
}
/**
@@ -92,15 +93,15 @@ 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): array
public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null): array
{
if ($durationMinutes <= 0) return [];
$buffer = (int)($this->getBookingMeta($doctor)['buffer_minutes'] ?? 0);
$buffer = (int)($this->getBookingMeta($doctor, $clinic)['buffer_minutes'] ?? 0);
$durSec = $durationMinutes * 60;
$needSec = $durSec + $buffer * 60; // فضای لازم شامل بافر
$sessions = $this->buildAllSessions($doctor, $date); // window/holiday/override/booking-window رعایت می‌شود
$sessions = $this->buildAllSessions($doctor, $date, $clinic); // window/holiday/override/booking-window رعایت می‌شود
if (empty($sessions)) return [];
$dayStart = (int) strtotime($date . ' 00:00:00');
@@ -152,14 +153,14 @@ class SlotCalculatorService
* Booking is allowed only when online booking is enabled and the date is
* today..(today + window). Past dates are always rejected.
*/
private function isWithinBookingWindow(Doctor $doctor, int $dayStart): bool
private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic): bool
{
$todayStart = (int) strtotime('today 00:00:00');
if ($dayStart < $todayStart) {
return false;
}
$meta = $this->getBookingMeta($doctor);
$meta = $this->getBookingMeta($doctor, $clinic);
if (!($meta['online_booking_enabled'] ?? true)) {
return false;
}
@@ -171,9 +172,9 @@ class SlotCalculatorService
return $dayStart <= $maxStart;
}
private function getBookingMeta(Doctor $doctor): array
private function getBookingMeta(Doctor $doctor, ?Clinic $clinic): array
{
$schedule = $this->scheduleRepo->findByDoctor($doctor);
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
return $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
}
@@ -182,23 +183,23 @@ class SlotCalculatorService
*
* @return array[] [{start_time: string, end_time: string, slots: array[]}]
*/
private function buildAllSessions(Doctor $doctor, string $date): array
private function buildAllSessions(Doctor $doctor, string $date, ?Clinic $clinic = null): 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)) {
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic)) {
return [];
}
// 1. Blocked by holiday
if (!empty($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1))) {
if (!empty($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1, $clinic))) {
return [];
}
// 2. Date override takes precedence over weekly schedule
foreach ($this->overrideRepo->findByDoctor($doctor) as $override) {
foreach ($this->overrideRepo->findByDoctorAndClinic($doctor, $clinic) as $override) {
if (date('Y-m-d', $override->getDate()) === $date) {
if (!$override->isActive()) return [];
return $this->buildSessionsFromOverride($override->getSetting() ?? [], $dayStart);
@@ -206,7 +207,7 @@ class SlotCalculatorService
}
// 3. Weekly schedule
$schedule = $this->scheduleRepo->findByDoctor($doctor);
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
if ($schedule === null) return [];
// Convert PHP date('w') (0=Sunday) to Iranian week index (0=Saturday)