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)
@@ -15,17 +15,17 @@ use App\ClinicService\Repository\ServiceSectionRepository;
use App\ClinicService\Repository\TariffRepository;
use App\ClinicService\Service\ServiceItemAuditService;
use App\ClinicService\Service\TariffService;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Inventory\Repository\InventoryItemRepository;
use App\Inventory\Repository\InventoryPackageRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Staff\Repository\ClinicStaffRepository;
use App\Subscription\Service\SubscriptionService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -40,8 +40,6 @@ class ClinicServiceController extends BaseController
private readonly ServiceItemRepository $itemRepo,
private readonly ClinicStaffRepository $staffRepo,
private readonly SubscriptionService $subscriptionService,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly TariffRepository $tariffRepo,
private readonly TariffService $tariffService,
private readonly InventoryPackageRepository $packageRepo,
@@ -49,6 +47,8 @@ class ClinicServiceController extends BaseController
private readonly ServiceItemAuditService $auditService,
private readonly ServiceItemAuditLogRepository $auditLogRepo,
private readonly EntityManagerInterface $em,
private readonly EntityContextResolver $contextResolver,
private readonly RequestStack $requestStack,
) {}
/**
@@ -489,19 +489,38 @@ class ClinicServiceController extends BaseController
return null;
}
/**
* صاحب سرویس‌های این درخواست. clinic_uuid درخواست (query یا body) مقدم است، بعد
* محیط فعال کاربر، و در آخر نقش — منطق کامل در EntityContextResolver.
*
* @return array{0: string, 1: ?int}
*/
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
return $this->contextResolver->resolve($user, $this->requestedClinicUuid())->toEntityPair();
}
private function requestedClinicUuid(): ?string
{
$request = $this->requestStack->getCurrentRequest();
if ($request === null) {
return null;
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
$fromQuery = $request->query->get('clinic_uuid');
if (is_string($fromQuery) && $fromQuery !== '') {
return $fromQuery;
}
return ['unknown', null];
if (!in_array($request->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
return null;
}
$body = json_decode($request->getContent(), true);
return is_array($body) && is_string($body['clinic_uuid'] ?? null) && $body['clinic_uuid'] !== ''
? $body['clinic_uuid']
: null;
}
private function assertServicesGate(string $entityType, ?int $entityId): void
+112 -60
View File
@@ -11,6 +11,7 @@ use App\Patient\Repository\PatientSessionRepository;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Controller\BaseController;
use App\Sms\Service\SmsWalletService;
use Doctrine\ORM\EntityManagerInterface;
@@ -33,6 +34,9 @@ class DashboardController extends BaseController
private readonly PatientRecordRepository $patientRecordRepo,
private readonly PatientSessionRepository $patientSessionRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly EntityContextResolver $contextResolver,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
) {}
// ── Clinic Dashboard ────────────────────────────────────────────────────
@@ -186,6 +190,11 @@ class DashboardController extends BaseController
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
}
// محیط فعال تعیین می‌کند این داشبورد شخصی است یا داخل یک کلینیک. بدون این،
// پزشکِ دعوت‌شده در محیط کلینیک آمار و درآمد مطب شخصی خودش را می‌دید.
$context = $this->contextResolver->tryResolve($user, $request->query->get('clinic_uuid'));
$clinic = $context?->clinic;
$doctorId = $doctor->getId();
$todayStart = strtotime('today midnight');
$todayEnd = strtotime('tomorrow midnight') - 1;
@@ -197,23 +206,9 @@ class DashboardController extends BaseController
$to = $request->query->get('to') ? (int) $request->query->get('to') : time();
// آمار
$todayCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['doctor' => $doctor, 's' => $todayStart, 'e' => $todayEnd])
->getSingleScalarResult();
$tmrCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['doctor' => $doctor, 's' => $tmrStart, 'e' => $tmrEnd])
->getSingleScalarResult();
$monthCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :doctor AND a.slotStart >= :s
')->setParameters(['doctor' => $doctor, 's' => $monthStart])
->getSingleScalarResult();
$todayCount = $this->countAppointments($doctor, $clinic, $todayStart, $todayEnd);
$tmrCount = $this->countAppointments($doctor, $clinic, $tmrStart, $tmrEnd);
$monthCount = $this->countAppointments($doctor, $clinic, $monthStart, PHP_INT_MAX);
// میانگین و تعداد امتیاز
$ratingRow = $this->em->createQuery('
@@ -223,21 +218,24 @@ class DashboardController extends BaseController
')->setParameter('doctor', $doctor)->getOneOrNullResult() ?? [];
// نوبت‌های امروز
$todayAppts = $this->em->createQuery('
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
d.name AS doctor_name, si.name AS service_name,
a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status
FROM App\Appointment\Entity\Appointment a
JOIN a.user u
JOIN a.doctor d
LEFT JOIN a.serviceItem si
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
ORDER BY a.slotStart ASC
')->setMaxResults(10)->setParameters([
'doctor' => $doctor,
's' => $todayStart,
'e' => $todayEnd,
])->getArrayResult();
$todayApptsQb = $this->em->createQueryBuilder()
->select('a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
d.name AS doctor_name, si.name AS service_name,
a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status')
->from(\App\Appointment\Entity\Appointment::class, 'a')
->join('a.user', 'u')
->join('a.doctor', 'd')
->leftJoin('a.serviceItem', 'si')
->where('a.doctor = :doctor')
->andWhere('a.slotStart >= :s AND a.slotStart <= :e')
->setParameter('doctor', $doctor)
->setParameter('s', $todayStart)
->setParameter('e', $todayEnd)
->orderBy('a.slotStart', 'ASC')
->setMaxResults(10);
$this->restrictToClinicAddresses($todayApptsQb, $clinic);
$todayAppts = $todayApptsQb->getQuery()->getArrayResult();
// کلینیک‌های عضو
$clinics = $this->em->createQuery('
@@ -247,18 +245,35 @@ class DashboardController extends BaseController
WHERE d.id = :doctorId
')->setParameter('doctorId', $doctorId)->getArrayResult();
$smsBalance = $this->smsWalletService->getBalance('doctor', $doctorId);
$uniquePatients = $this->patientRecordRepo->countUnique('doctor', $doctorId, $from, $to);
$revenuePeriod = $this->patientSessionRepo->sumRevenue('doctor', $doctorId, $from, $to);
$totalPatients = $this->patientRecordRepo->countUnique('doctor', $doctorId, 0, time());
$apptByDay = $this->appointmentsDaily(
fn(int $ds, int $de): int => $this->countAppointments($doctor, $clinic, $ds, $de)
);
$rev = $this->revenueDaily('doctor', $doctorId);
$apptByDay = $this->appointmentsDaily(function (int $ds, int $de) use ($doctor): int {
return (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :d AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['d' => $doctor, 's' => $ds, 'e' => $de])->getSingleScalarResult();
});
$stats = [
'today_appointments' => $todayCount,
'tomorrow_appointments' => $tmrCount,
'this_month_appointments' => $monthCount,
'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null,
'total_ratings' => (int) ($ratingRow['total'] ?? 0),
];
$charts = ['appointments_by_day' => $apptByDay];
// ارقام مالی و کیف پول پیامک به مطب شخصی تعلق دارند. در محیط کلینیک اصلاً
// برگردانده نمی‌شوند مگر کاربر مالک همان کلینیک باشد — مخفی‌کردن در UI کافی
// نیست، چون endpoint مستقیماً قابل صدا زدن است.
if ($this->maySeeFinancials($user, $clinic)) {
$rev = $this->revenueDaily('doctor', $doctorId);
$stats['sms_wallet_balance'] = $this->smsWalletService->getBalance('doctor', $doctorId);
$stats['unique_patients_count'] = $this->patientRecordRepo->countUnique('doctor', $doctorId, $from, $to);
$stats['total_patients'] = $this->patientRecordRepo->countUnique('doctor', $doctorId, 0, time());
$stats['revenue_period_rials'] = $this->patientSessionRepo->sumRevenue('doctor', $doctorId, $from, $to);
$stats['today_payments_rials'] = $rev['today_payments_rials'];
$stats['week_payments_rials'] = $rev['week_payments_rials'];
$charts['revenue_by_day'] = $rev['revenue'];
}
return $this->success([
'doctor' => [
@@ -266,29 +281,66 @@ class DashboardController extends BaseController
'name' => $doctor->getName(),
'degree' => $doctor->getDegree(),
],
'stats' => [
'today_appointments' => $todayCount,
'tomorrow_appointments' => $tmrCount,
'this_month_appointments' => $monthCount,
'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null,
'total_ratings' => (int) ($ratingRow['total'] ?? 0),
'sms_wallet_balance' => $smsBalance,
'unique_patients_count' => $uniquePatients,
'total_patients' => $totalPatients,
'revenue_period_rials' => $revenuePeriod,
'today_payments_rials' => $rev['today_payments_rials'],
'week_payments_rials' => $rev['week_payments_rials'],
],
'charts' => [
'revenue_by_day' => $rev['revenue'],
'appointments_by_day' => $apptByDay,
'context' => [
'type' => $clinic === null ? 'personal' : 'clinic',
'clinic_uuid' => $clinic?->getUuid(),
'clinic_name' => $clinic?->getName(),
],
'stats' => $stats,
'charts' => $charts,
'period' => ['from' => $from, 'to' => $to],
'today_appointments' => $todayAppts,
'clinics' => $clinics,
// فهرست کلینیک‌ها فقط در محیط شخصی معنا دارد.
'clinics' => $clinic === null ? $clinics : [],
]);
}
/**
* نوبت‌های پزشک در یک بازه، محدود به محیط جاری. در محیط کلینیک فقط نوبت‌هایی
* شمرده می‌شوند که آدرسشان متعلق به همان کلینیک است.
*/
private function countAppointments(\App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic, int $from, int $to): int
{
$qb = $this->em->createQueryBuilder()
->select('COUNT(a.id)')
->from(\App\Appointment\Entity\Appointment::class, 'a')
->where('a.doctor = :doctor')
->andWhere('a.slotStart >= :s AND a.slotStart <= :e')
->setParameter('doctor', $doctor)
->setParameter('s', $from)
->setParameter('e', $to);
$this->restrictToClinicAddresses($qb, $clinic);
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function restrictToClinicAddresses(\Doctrine\ORM\QueryBuilder $qb, ?\App\Clinic\Entity\Clinic $clinic): void
{
if ($clinic === null) {
return;
}
$addressIds = array_map(
fn(\App\Doctor\Entity\DoctorAddress $a): int => (int) $a->getId(),
$this->addressRepo->findForContext($qb->getParameter('doctor')->getValue(), $clinic->getId())
);
$qb->andWhere('a.addressId IN (:addressIds)')
->setParameter('addressIds', $addressIds ?: [0]);
}
/** فقط محیط شخصی خود پزشک، یا مالک همان کلینیک. */
private function maySeeFinancials(User $user, ?\App\Clinic\Entity\Clinic $clinic): bool
{
if ($clinic === null) {
return true;
}
return $clinic->getUser()->getId() === $user->getId()
&& $this->permChecker->can($user, $clinic, 'payments', 'view');
}
/**
* سری ۷ روز اخیر درآمد (بر اساس PatientSession) + پرداختی امروز و هفته.
* @return array{revenue: array<int, array{label:string, amount_rials:int}>, today_payments_rials:int, week_payments_rials:int}
@@ -62,6 +62,32 @@ class DoctorAddressRepository extends ServiceEntityRepository
->getSingleScalarResult();
}
/**
* آدرس‌های قابل‌انتخاب در یک context. مطب شخصی فقط آدرس‌های شخصی خود پزشک را
* می‌بیند و کلینیک فقط آدرس‌های خودش — این دو هرگز union نمی‌شوند.
*
* @return DoctorAddress[]
*/
public function findForContext(Doctor $doctor, ?int $clinicId): array
{
$qb = $this->createQueryBuilder('a');
if ($clinicId === null) {
$qb->where('a.doctor = :doctor')
->andWhere('a.type = :personal')
->setParameter('doctor', $doctor)
->setParameter('personal', DoctorAddress::TYPE_PERSONAL);
} else {
$qb->where('a.clinicId = :clinicId')
->andWhere('a.type = :clinic')
->setParameter('clinicId', $clinicId)
->setParameter('clinic', DoctorAddress::TYPE_CLINIC);
}
return $qb->orderBy('a.id', 'ASC')->getQuery()->getResult();
}
/** @deprecated آدرس‌های دو محیط را union می‌کند؛ از findForContext() استفاده کن. */
public function findAvailableForDoctor(Doctor $doctor, array $clinicIds): array
{
$qb = $this->createQueryBuilder('a');
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Shared\Context;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
/**
* محیط کاری مؤثر یک درخواست: یا مطب شخصی یک پزشک، یا یک کلینیک.
*
* سرویس‌ها، آدرس‌ها و برنامهٔ نوبت‌دهی همگی به یکی از این دو تعلق دارند و هرگز بین
* آن‌ها مشترک نمی‌شوند. type/id دقیقاً همان جفتی است که ServiceSection با
* entity_type/entity_id ذخیره می‌کند.
*/
final class EntityContext
{
public const TYPE_DOCTOR = 'doctor';
public const TYPE_CLINIC = 'clinic';
public const TYPE_UNKNOWN = 'unknown';
private function __construct(
public readonly string $type,
public readonly ?int $id,
public readonly ?Clinic $clinic = null,
public readonly ?Doctor $doctor = null,
) {}
public static function forDoctor(?Doctor $doctor): self
{
return new self(self::TYPE_DOCTOR, $doctor?->getId(), null, $doctor);
}
public static function forClinic(Clinic $clinic): self
{
return new self(self::TYPE_CLINIC, $clinic->getId(), $clinic);
}
public static function unknown(): self
{
return new self(self::TYPE_UNKNOWN, null);
}
public function isClinic(): bool { return $this->type === self::TYPE_CLINIC; }
public function isResolved(): bool { return $this->id !== null; }
/** @return array{0: string, 1: ?int} جفت (entity_type, entity_id) برای ServiceSection */
public function toEntityPair(): array
{
return [$this->type, $this->id];
}
}
@@ -0,0 +1,124 @@
<?php
namespace App\Shared\Context;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* تنها نقطهٔ تصمیم‌گیری دربارهٔ «این درخواست در کدام محیط اجرا می‌شود؟».
*
* اولویت: clinic_uuid صریحِ درخواست > محیط فعالِ ذخیره‌شدهٔ کاربر > نقش کاربر.
*
* نقش به‌تنهایی برای کاربری که هم پزشک است و هم مالک کلینیک جواب نمی‌دهد: چنین
* کاربری همیشه به‌عنوان پزشک حل می‌شد و هرگز به سرویس‌های کلینیک خودش نمی‌رسید.
* UserActiveContext تعیین‌کننده است و نقش فقط fallback آخر.
*/
class EntityContextResolver
{
public function __construct(
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $activeContextRepo,
) {}
/**
* @param string|null $clinicUuid اگر داده شود، محیط کلینیک اجباری می‌شود و در
* صورت نداشتن دسترسی، خطای ۴۰۳ پرتاب می‌شود.
*/
public function resolve(User $user, ?string $clinicUuid = null): EntityContext
{
if ($clinicUuid !== null && $clinicUuid !== '') {
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
if ($clinic === null) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
$this->assertCanActInClinic($user, $clinic);
return EntityContext::forClinic($clinic);
}
$fromActive = $this->fromActiveContext($user);
if ($fromActive !== null) {
return $fromActive;
}
return $this->fromRole($user);
}
/**
* محیط را بدون پرتاب خطا حل می‌کند؛ اگر کاربر به کلینیکِ خواسته‌شده دسترسی
* نداشته باشد null برمی‌گرداند. برای مسیرهایی که خودشان authorization جدا دارند.
*/
public function tryResolve(User $user, ?string $clinicUuid = null): ?EntityContext
{
try {
return $this->resolve($user, $clinicUuid);
} catch (AppException) {
return null;
}
}
/** مالک کلینیک، ادمین، یا پزشکِ عضو همان کلینیک. */
public function canActInClinic(User $user, Clinic $clinic): bool
{
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
return true;
}
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null && $clinic->hasDoctor($doctor);
}
public function assertCanActInClinic(User $user, Clinic $clinic): void
{
if (!$this->canActInClinic($user, $clinic)) {
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'به این کلینیک دسترسی ندارید', 403);
}
}
/**
* محیط فعالِ ذخیره‌شده. db_uuid یا uuid کلینیک است یا uuid پزشک؛ کلینیک اول
* بررسی می‌شود چون پزشکِ دعوت‌شده هم db_uuid کلینیک را ذخیره می‌کند.
*/
private function fromActiveContext(User $user): ?EntityContext
{
$active = $this->activeContextRepo->findByUser($user);
if ($active === null) {
return null;
}
$clinic = $this->clinicRepo->findByUuid($active->getDbUuid());
if ($clinic !== null) {
return $this->canActInClinic($user, $clinic) ? EntityContext::forClinic($clinic) : null;
}
$doctor = $this->doctorRepo->findByUuid($active->getDbUuid());
if ($doctor !== null && $doctor->getUser()->getId() === $user->getId()) {
return EntityContext::forDoctor($doctor);
}
return null;
}
private function fromRole(User $user): EntityContext
{
if ($user->hasRole('ROLE_DOCTOR')) {
return EntityContext::forDoctor($this->doctorRepo->findByUser($user));
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
}
return EntityContext::unknown();
}
}