feat: Implement SMS sending functionality with KavehNegar and Rangineh providers
- Add SendSmsMessage class for encapsulating SMS message data. - Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS. - Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates. - Develop SendSmsHandler for handling SMS sending messages. - Create SmsService to manage SMS dispatching and logging. - Add UserProfileController for managing user profiles with CRUD operations. - Implement UserProfile entity and repository for user profile data management. - Update symfony.lock and bootstrap.php for project dependencies and environment setup.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Command;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:cancel-expired-appointments',
|
||||
description: 'Marks pending appointments whose slot_start is in the past as expired',
|
||||
)]
|
||||
class CancelExpiredAppointmentsCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly AppointmentRepository $appointmentRepo)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$expired = $this->appointmentRepo->findExpiredPending(time());
|
||||
$count = 0;
|
||||
|
||||
foreach ($expired as $appointment) {
|
||||
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
|
||||
$this->appointmentRepo->save($appointment, false);
|
||||
$count++;
|
||||
}
|
||||
|
||||
if ($count > 0) {
|
||||
$this->appointmentRepo->save($expired[0]); // flush once
|
||||
}
|
||||
|
||||
$output->writeln(sprintf('Expired %d appointments.', $count));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Controller;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Appointment\Service\SlotCalculatorService;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\OptimisticLockException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class AppointmentController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly SlotCalculatorService $slotCalculator,
|
||||
) {}
|
||||
|
||||
// ── Public: available slots ───────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-slots', methods: ['GET'])]
|
||||
public function slots(Request $request): JsonResponse
|
||||
{
|
||||
$doctorUuid = trim($request->query->get('doctor_uuid', ''));
|
||||
$date = trim($request->query->get('date', ''));
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
||||
}
|
||||
|
||||
$slots = $this->slotCalculator->getAvailableSlots($doctor, $date);
|
||||
|
||||
return $this->success([
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'date' => $date,
|
||||
'slots' => $slots,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Authenticated: book / manage ─────────────────────────────────────────
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/appointment', methods: ['POST'])]
|
||||
public function book(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$slotStart = (int) ($data['slot_start'] ?? 0);
|
||||
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($this->appointmentRepo->isSlotTaken($doctor, $slotStart, $slotEnd)) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
|
||||
}
|
||||
|
||||
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
||||
if (isset($data['note'])) $appointment->setNote($data['note']);
|
||||
|
||||
$this->appointmentRepo->save($appointment);
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()], 201);
|
||||
}
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/appointment/{uuid}', methods: ['GET'])]
|
||||
public function get(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
||||
if ($appointment === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canView($appointment, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
}
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/appointments/doctor/{doctorUuid}', methods: ['GET'])]
|
||||
public function listByDoctor(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 ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$status = $request->query->get('status');
|
||||
$appointments = $this->appointmentRepo->findByDoctor($doctor, $status);
|
||||
|
||||
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
||||
}
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/appointments/user', methods: ['GET'])]
|
||||
public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$status = $request->query->get('status');
|
||||
$appointments = $this->appointmentRepo->findByUser($user, $status);
|
||||
|
||||
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
||||
}
|
||||
|
||||
private function canView(Appointment $a, User $user): bool
|
||||
{
|
||||
return $a->getUser()->getId() === $user->getId()
|
||||
|| $a->getDoctor()->getUser()->getId() === $user->getId()
|
||||
|| $user->hasRole('ROLE_ADMIN');
|
||||
}
|
||||
|
||||
private function canManage(Appointment $a, User $user): bool
|
||||
{
|
||||
return $a->getUser()->getId() === $user->getId()
|
||||
|| $a->getDoctor()->getUser()->getId() === $user->getId()
|
||||
|| $user->hasRole('ROLE_ADMIN');
|
||||
}
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])]
|
||||
public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
||||
if ($appointment === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canManage($appointment, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$newStatus = trim($data['status'] ?? '');
|
||||
$version = (int) ($data['version'] ?? $appointment->getVersion());
|
||||
|
||||
if (!$appointment->canTransitionTo($newStatus)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
||||
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus
|
||||
), 422);
|
||||
}
|
||||
|
||||
$appointment->transitionTo($newStatus);
|
||||
|
||||
try {
|
||||
$this->appointmentRepo->saveWithLock($appointment, $version);
|
||||
} catch (OptimisticLockException) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Controller;
|
||||
|
||||
use App\Appointment\Entity\DateOverride;
|
||||
use App\Appointment\Entity\Holiday;
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Appointment\Repository\DateOverrideRepository;
|
||||
use App\Appointment\Repository\HolidayRepository;
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class AppointmentSettingsController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly DateOverrideRepository $overrideRepo,
|
||||
private readonly HolidayRepository $holidayRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
) {}
|
||||
|
||||
// ── Weekly Schedule ───────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/weekly-schedule', methods: ['POST'])]
|
||||
public function createSchedule(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
// Only one schedule per doctor — upsert
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
if ($schedule !== null) {
|
||||
$schedule->setSetting($data['schedule'] ?? []);
|
||||
} else {
|
||||
$schedule = new WeeklySchedule($doctor, $data['schedule'] ?? []);
|
||||
}
|
||||
|
||||
$this->scheduleRepo->save($schedule);
|
||||
|
||||
return $this->success(['data' => $schedule->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['PATCH'])]
|
||||
public function updateSchedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
if ($schedule === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (isset($data['schedule'])) {
|
||||
$schedule->setSetting($data['schedule']);
|
||||
}
|
||||
|
||||
$this->scheduleRepo->save($schedule);
|
||||
|
||||
return $this->success(['data' => $schedule->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['GET'])]
|
||||
public function getSchedule(string $uuid): JsonResponse
|
||||
{
|
||||
// Try doctor uuid first, then schedule uuid
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
$schedule = $doctor
|
||||
? $this->scheduleRepo->findByDoctor($doctor)
|
||||
: $this->scheduleRepo->findByUuid($uuid);
|
||||
|
||||
if ($schedule === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $schedule->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/booking-setting/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteSchedule(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$schedule = $this->scheduleRepo->findByUuid($uuid);
|
||||
if ($schedule === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$this->scheduleRepo->remove($schedule);
|
||||
|
||||
return $this->success(['message' => 'برنامه هفتگی با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
// ── Date Overrides ────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/date-override/list/{doctorUuid}', methods: ['GET'])]
|
||||
public function listOverrides(string $doctorUuid): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$overrides = array_map(
|
||||
fn(DateOverride $o) => $o->toArray(),
|
||||
$this->overrideRepo->findByDoctor($doctor)
|
||||
);
|
||||
|
||||
return $this->success(['data' => $overrides]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/date-override', methods: ['POST'])]
|
||||
public function createOverride(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$dateStr = trim($data['date'] ?? '');
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$timestamp = strtotime($dateStr);
|
||||
if ($timestamp === false || $timestamp === -1) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است', 422, 'date');
|
||||
}
|
||||
|
||||
$override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false));
|
||||
if (isset($data['reason'])) $override->setReason($data['reason']);
|
||||
if (isset($data['custom_slots'])) $override->setSetting($data['custom_slots']);
|
||||
|
||||
$this->overrideRepo->save($override);
|
||||
|
||||
return $this->success(['data' => $override->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['PATCH'])]
|
||||
public function updateOverride(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$override = $this->overrideRepo->findByUuid($uuid);
|
||||
if ($override === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('active', $data)) $override->setActive((bool) $data['active']);
|
||||
if (array_key_exists('reason', $data)) $override->setReason($data['reason']);
|
||||
if (array_key_exists('custom_slots', $data)) $override->setSetting($data['custom_slots']);
|
||||
if (!empty($data['date'])) {
|
||||
$ts = strtotime($data['date']);
|
||||
if ($ts !== false) $override->setDate($ts);
|
||||
}
|
||||
|
||||
$this->overrideRepo->save($override);
|
||||
|
||||
return $this->success(['data' => $override->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteOverride(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$override = $this->overrideRepo->findByUuid($uuid);
|
||||
if ($override === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$this->overrideRepo->remove($override);
|
||||
|
||||
return $this->success(['message' => 'Override با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['GET'])]
|
||||
public function getOverride(string $uuid): JsonResponse
|
||||
{
|
||||
$override = $this->overrideRepo->findByUuid($uuid);
|
||||
if ($override === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $override->toArray()]);
|
||||
}
|
||||
|
||||
// ── Holidays ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/holidays', methods: ['POST'])]
|
||||
public function createHoliday(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$startStr = trim($data['start_date'] ?? '');
|
||||
$endStr = trim($data['end_date'] ?? '');
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$startTs = strtotime($startStr);
|
||||
$endTs = strtotime($endStr);
|
||||
|
||||
if (!$startTs || !$endTs || $endTs < $startTs) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ نادرست است', 422);
|
||||
}
|
||||
|
||||
$holiday = new Holiday($doctor, $startTs, $endTs);
|
||||
if (isset($data['reason'])) $holiday->setReason($data['reason']);
|
||||
|
||||
$this->holidayRepo->save($holiday);
|
||||
|
||||
return $this->success(['data' => $holiday->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/holidays/{uuid}', methods: ['PATCH'])]
|
||||
public function updateHoliday(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$holiday = $this->holidayRepo->findByUuid($uuid);
|
||||
if ($holiday === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($holiday->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('active', $data)) $holiday->setActive((bool) $data['active']);
|
||||
if (array_key_exists('reason', $data)) $holiday->setReason($data['reason']);
|
||||
if (!empty($data['start_date'])) {
|
||||
$ts = strtotime($data['start_date']);
|
||||
if ($ts) $holiday->setStartDate($ts);
|
||||
}
|
||||
if (!empty($data['end_date'])) {
|
||||
$ts = strtotime($data['end_date']);
|
||||
if ($ts) $holiday->setEndDate($ts);
|
||||
}
|
||||
|
||||
$this->holidayRepo->save($holiday);
|
||||
|
||||
return $this->success(['data' => $holiday->toArray()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'appointments')]
|
||||
#[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')]
|
||||
#[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')]
|
||||
class Appointment
|
||||
{
|
||||
// Status machine: pending → confirmed → completed
|
||||
// ↘ cancelled_by_doctor / cancelled_by_user
|
||||
// pending → expired (cron)
|
||||
// confirmed → no_show
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_CANCELLED_BY_DOCTOR = 'cancelled_by_doctor';
|
||||
public const STATUS_CANCELLED_BY_USER = 'cancelled_by_user';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_NO_SHOW = 'no_show';
|
||||
|
||||
public const ALLOWED_TRANSITIONS = [
|
||||
self::STATUS_PENDING => [self::STATUS_CONFIRMED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_EXPIRED],
|
||||
self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
|
||||
];
|
||||
|
||||
// Optimistic locking
|
||||
#[ORM\Version]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(name: 'slot_start', type: 'integer')]
|
||||
private int $slotStart;
|
||||
|
||||
#[ORM\Column(name: 'slot_end', type: 'integer')]
|
||||
private int $slotEnd;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 30)]
|
||||
private string $status = self::STATUS_PENDING;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $note = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->user = $user;
|
||||
$this->slotStart = $slotStart;
|
||||
$this->slotEnd = $slotEnd;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getSlotStart(): int { return $this->slotStart; }
|
||||
public function getSlotEnd(): int { return $this->slotEnd; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getNote(): ?string { return $this->note; }
|
||||
public function getVersion(): int { return $this->version; }
|
||||
|
||||
public function setNote(?string $v): self { $this->note = $v; return $this; }
|
||||
|
||||
public function canTransitionTo(string $newStatus): bool
|
||||
{
|
||||
return in_array($newStatus, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true);
|
||||
}
|
||||
|
||||
public function transitionTo(string $newStatus): self
|
||||
{
|
||||
if (!$this->canTransitionTo($newStatus)) {
|
||||
throw new \LogicException(sprintf(
|
||||
'Cannot transition appointment from "%s" to "%s"',
|
||||
$this->status, $newStatus
|
||||
));
|
||||
}
|
||||
$this->status = $newStatus;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor' => [
|
||||
'uuid' => $this->doctor->getUuid(),
|
||||
'name' => $this->doctor->getName(),
|
||||
],
|
||||
'user' => [
|
||||
'uuid' => $this->user->getUuid(),
|
||||
'mobile' => $this->user->getMobileNumber(),
|
||||
],
|
||||
'slot_start' => $this->slotStart,
|
||||
'slot_end' => $this->slotEnd,
|
||||
'status' => $this->status,
|
||||
'note' => $this->note,
|
||||
'version' => $this->version,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'date_overrides')]
|
||||
#[ORM\Index(columns: ['doctor_id', 'date'], name: 'idx_date_overrides_doctor_date')]
|
||||
class DateOverride
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $date;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = false;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $setting = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $reason = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, int $date, bool $active = false)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->date = $date;
|
||||
$this->active = $active;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getDate(): int { return $this->date; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getSetting(): ?array { return $this->setting; }
|
||||
public function getReason(): ?string { return $this->reason; }
|
||||
|
||||
public function setDate(int $v): self { $this->date = $v; $this->touch(); return $this; }
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
public function setSetting(?array $v): self { $this->setting = $v; $this->touch(); return $this; }
|
||||
public function setReason(?string $v): self { $this->reason = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'date' => $this->date,
|
||||
'active' => $this->active,
|
||||
'reason' => $this->reason,
|
||||
'custom_slots' => $this->setting ?? [],
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'holidays')]
|
||||
#[ORM\Index(columns: ['doctor_id', 'start_date', 'end_date'], name: 'idx_holidays_doctor_range')]
|
||||
class Holiday
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(name: 'start_date', type: 'integer')]
|
||||
private int $startDate;
|
||||
|
||||
#[ORM\Column(name: 'end_date', type: 'integer')]
|
||||
private int $endDate;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $reason = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, int $startDate, int $endDate)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->startDate = $startDate;
|
||||
$this->endDate = $endDate;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getStartDate(): int { return $this->startDate; }
|
||||
public function getEndDate(): int { return $this->endDate; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getReason(): ?string { return $this->reason; }
|
||||
|
||||
public function setStartDate(int $v): self { $this->startDate = $v; $this->touch(); return $this; }
|
||||
public function setEndDate(int $v): self { $this->endDate = $v; $this->touch(); return $this; }
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
public function setReason(?string $v): self { $this->reason = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'start_date' => $this->startDate,
|
||||
'end_date' => $this->endDate,
|
||||
'active' => $this->active,
|
||||
'reason' => $this->reason,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Entity;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'weekly_schedules')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor', columns: ['doctor_id'])]
|
||||
class WeeklySchedule
|
||||
{
|
||||
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $setting = [];
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, array $setting)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->setting = $setting;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getSetting(): array { return $this->setting; }
|
||||
|
||||
public function setSetting(array $setting): self { $this->setting = $setting; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'schedule' => $this->setting,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\OptimisticLockException;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class AppointmentRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Appointment::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?Appointment
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** Check if a slot is already taken (confirmed or pending) */
|
||||
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->select('COUNT(a.id)')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.status IN (:activeStatuses)')
|
||||
->andWhere('a.slotStart < :slotEnd')
|
||||
->andWhere('a.slotEnd > :slotStart')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('activeStatuses', [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED])
|
||||
->setParameter('slotStart', $slotStart)
|
||||
->setParameter('slotEnd', $slotEnd);
|
||||
|
||||
if ($excludeId !== null) {
|
||||
$qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId);
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
|
||||
}
|
||||
|
||||
/** @return Appointment[] */
|
||||
public function findByDoctor(Doctor $doctor, ?string $status = null): array
|
||||
{
|
||||
$criteria = ['doctor' => $doctor];
|
||||
if ($status !== null) $criteria['status'] = $status;
|
||||
return $this->findBy($criteria, ['slotStart' => 'ASC']);
|
||||
}
|
||||
|
||||
/** @return Appointment[] */
|
||||
public function findByUser(User $user, ?string $status = null): array
|
||||
{
|
||||
$criteria = ['user' => $user];
|
||||
if ($status !== null) $criteria['status'] = $status;
|
||||
return $this->findBy($criteria, ['slotStart' => 'DESC']);
|
||||
}
|
||||
|
||||
/** @return Appointment[] pending appointments older than given timestamp */
|
||||
public function findExpiredPending(int $before): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.status = :status')
|
||||
->andWhere('a.slotStart < :before')
|
||||
->setParameter('status', Appointment::STATUS_PENDING)
|
||||
->setParameter('before', $before)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(Appointment $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OptimisticLockException
|
||||
*/
|
||||
public function saveWithLock(Appointment $entity, int $expectedVersion): void
|
||||
{
|
||||
$this->getEntityManager()->lock($entity, \Doctrine\DBAL\LockMode::OPTIMISTIC, $expectedVersion);
|
||||
$this->getEntityManager()->persist($entity);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(Appointment $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\DateOverride;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class DateOverrideRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, DateOverride::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?DateOverride
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return DateOverride[] */
|
||||
public function findByDoctor(Doctor $doctor): array
|
||||
{
|
||||
return $this->findBy(['doctor' => $doctor], ['date' => 'ASC']);
|
||||
}
|
||||
|
||||
public function save(DateOverride $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(DateOverride $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\Holiday;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class HolidayRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Holiday::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?Holiday
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return Holiday[] */
|
||||
public function findActiveByDoctor(Doctor $doctor, int $from, int $to): array
|
||||
{
|
||||
return $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();
|
||||
}
|
||||
|
||||
public function save(Holiday $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(Holiday $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Repository;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class WeeklyScheduleRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, WeeklySchedule::class);
|
||||
}
|
||||
|
||||
public function findByDoctor(Doctor $doctor): ?WeeklySchedule
|
||||
{
|
||||
return $this->findOneBy(['doctor' => $doctor]);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?WeeklySchedule
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function save(WeeklySchedule $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(WeeklySchedule $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Appointment\Repository\DateOverrideRepository;
|
||||
use App\Appointment\Repository\HolidayRepository;
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
|
||||
class SlotCalculatorService
|
||||
{
|
||||
private const DAY_MAP = [
|
||||
0 => 'sunday', 1 => 'monday', 2 => 'tuesday', 3 => 'wednesday',
|
||||
4 => 'thursday', 5 => 'friday', 6 => 'saturday',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly DateOverrideRepository $overrideRepo,
|
||||
private readonly HolidayRepository $holidayRepo,
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns available slots for a doctor on a given date.
|
||||
* @param string $date 'Y-m-d' format
|
||||
* @return array[] [{start: int, end: int, start_time: string, end_time: string}]
|
||||
*/
|
||||
public function getAvailableSlots(Doctor $doctor, string $date): array
|
||||
{
|
||||
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||
$dayEnd = $dayStart + 86400;
|
||||
|
||||
// Check if in holiday
|
||||
$holidays = $this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1);
|
||||
if (!empty($holidays)) return [];
|
||||
|
||||
// Check date override first
|
||||
$overrides = $this->overrideRepo->findByDoctor($doctor);
|
||||
foreach ($overrides as $override) {
|
||||
$overDate = date('Y-m-d', $override->getDate());
|
||||
if ($overDate === $date) {
|
||||
if (!$override->isActive()) return [];
|
||||
return $this->buildSlots($override->getSetting() ?? [], $dayStart);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to weekly schedule
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
if ($schedule === null) return [];
|
||||
|
||||
$dow = (int) date('w', $dayStart);
|
||||
$dayName = self::DAY_MAP[$dow];
|
||||
$setting = $schedule->getSetting();
|
||||
|
||||
$dayConfig = $setting[$dayName] ?? null;
|
||||
if ($dayConfig === null || !($dayConfig['active'] ?? false)) return [];
|
||||
|
||||
$rawSlots = $this->buildSlots($dayConfig['slots'] ?? [], $dayStart);
|
||||
|
||||
// Filter out already-booked slots
|
||||
return $this->filterBookedSlots($doctor, $rawSlots);
|
||||
}
|
||||
|
||||
/** @return array[] */
|
||||
private function buildSlots(array $slotConfigs, int $dayStart): array
|
||||
{
|
||||
$slots = [];
|
||||
foreach ($slotConfigs as $config) {
|
||||
$startSec = $this->parseTime($config['start'] ?? '00:00');
|
||||
$endSec = $this->parseTime($config['end'] ?? '00:00');
|
||||
$duration = (int) ($config['duration'] ?? 30) * 60;
|
||||
|
||||
if ($duration <= 0 || $endSec <= $startSec) continue;
|
||||
|
||||
for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) {
|
||||
$slotStart = $dayStart + $t;
|
||||
$slotEnd = $slotStart + $duration;
|
||||
$slots[] = [
|
||||
'start' => $slotStart,
|
||||
'end' => $slotEnd,
|
||||
'start_time' => gmdate('H:i', $t),
|
||||
'end_time' => gmdate('H:i', $t + $duration),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $slots;
|
||||
}
|
||||
|
||||
private function filterBookedSlots(Doctor $doctor, array $slots): array
|
||||
{
|
||||
return array_values(array_filter($slots, function (array $slot) use ($doctor): bool {
|
||||
return !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']);
|
||||
}));
|
||||
}
|
||||
|
||||
private function parseTime(string $time): int
|
||||
{
|
||||
[$h, $m] = explode(':', $time, 2) + [0, 0];
|
||||
return ((int)$h * 3600) + ((int)$m * 60);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user