Appointments now persist address_id resolved from the weekly-schedule session (location_id) across all booking paths (online, secretary, admin). On confirm, the patient is added to the clinic owning that address, or to the doctor's single clinic as fallback. Weekly-schedule create/update now requires location_id on every active session. PatientSession exposes doctor_uuid/doctor_name so clinic records show which doctor each visit is for. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
517 lines
22 KiB
PHP
517 lines
22 KiB
PHP
<?php
|
|
|
|
namespace App\Appointment\Controller;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Appointment\Entity\WeeklySchedule;
|
|
use App\Appointment\Repository\AppointmentRepository;
|
|
use App\Appointment\Repository\SlotTakenException;
|
|
use App\Appointment\Repository\WeeklyScheduleRepository;
|
|
use App\Appointment\Service\SlotCalculatorService;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Auth\Entity\User;
|
|
use App\Doctor\Repository\DoctorRepository;
|
|
use App\Patient\Service\PatientService;
|
|
use App\Shared\Constant\ErrorCodes;
|
|
use App\Shared\Controller\BaseController;
|
|
use Doctrine\ORM\OptimisticLockException;
|
|
use OpenApi\Attributes as OA;
|
|
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;
|
|
|
|
#[OA\Tag(name: 'Appointments')]
|
|
class AppointmentController extends BaseController
|
|
{
|
|
public function __construct(
|
|
private readonly AppointmentRepository $appointmentRepo,
|
|
private readonly DoctorRepository $doctorRepo,
|
|
private readonly SlotCalculatorService $slotCalculator,
|
|
private readonly PatientService $patientService,
|
|
private readonly WeeklyScheduleRepository $scheduleRepo,
|
|
) {}
|
|
|
|
// ── Public: available slots ───────────────────────────────────────────────
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointment-slots',
|
|
summary: 'Get available appointment slots for a doctor on a given date',
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'doctor_uuid',
|
|
in: 'query',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
new OA\Parameter(
|
|
name: 'date',
|
|
in: 'query',
|
|
required: true,
|
|
description: 'Date in Y-m-d format',
|
|
schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Available slots returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(
|
|
property: 'data',
|
|
properties: [
|
|
new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'),
|
|
new OA\Property(property: 'date', type: 'string', format: 'date'),
|
|
new OA\Property(
|
|
property: 'slots',
|
|
type: 'array',
|
|
items: new OA\Items(
|
|
properties: [
|
|
new OA\Property(property: 'start', type: 'integer', description: 'Unix timestamp'),
|
|
new OA\Property(property: 'end', type: 'integer', description: 'Unix timestamp'),
|
|
new OA\Property(property: 'available', type: 'boolean'),
|
|
],
|
|
type: 'object'
|
|
)
|
|
),
|
|
],
|
|
type: 'object'
|
|
),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(
|
|
response: 404,
|
|
description: 'Doctor not found',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: false),
|
|
new OA\Property(
|
|
property: 'errors',
|
|
type: 'array',
|
|
items: new OA\Items(
|
|
properties: [
|
|
new OA\Property(property: 'code', type: 'string'),
|
|
new OA\Property(property: 'message', type: 'string'),
|
|
],
|
|
type: 'object'
|
|
)
|
|
),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 422, description: 'Invalid date format'),
|
|
]
|
|
)]
|
|
#[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');
|
|
}
|
|
|
|
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date);
|
|
|
|
return $this->success([
|
|
'doctor_uuid' => $doctorUuid,
|
|
'date' => $date,
|
|
'sessions' => $sessions,
|
|
]);
|
|
}
|
|
|
|
#[Route('/api/v1/appointment-settings/month-availability/{doctorUuid}', methods: ['GET'])]
|
|
public function monthAvailability(string $doctorUuid, Request $request): JsonResponse
|
|
{
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
$year = (int) $request->query->get('year');
|
|
$month = (int) $request->query->get('month');
|
|
if ($year < 1970 || $month < 1 || $month > 12) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال یا ماه نامعتبر است', 422, 'month');
|
|
}
|
|
|
|
$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)) {
|
|
$enabled[] = $date;
|
|
} else {
|
|
$disabled[] = $date;
|
|
}
|
|
}
|
|
|
|
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
|
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
|
|
|
return $this->success([
|
|
'year' => $year,
|
|
'month' => $month,
|
|
'disabled_dates' => $disabled,
|
|
'enabled_dates' => $enabled,
|
|
'online_booking_enabled' => (bool) $meta['online_booking_enabled'],
|
|
'booking_window' => [
|
|
'value' => (int) $meta['booking_window_value'],
|
|
'unit' => $meta['booking_window_unit'],
|
|
],
|
|
]);
|
|
}
|
|
|
|
// ── Authenticated: book / manage ─────────────────────────────────────────
|
|
|
|
#[OA\Post(
|
|
path: '/api/v1/appointment',
|
|
summary: 'Book a new appointment',
|
|
security: [['bearerAuth' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['doctor_uuid', 'slot_start', 'slot_end'],
|
|
properties: [
|
|
new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'),
|
|
new OA\Property(property: 'slot_start', type: 'integer', description: 'Slot start Unix timestamp'),
|
|
new OA\Property(property: 'slot_end', type: 'integer', description: 'Slot end Unix timestamp'),
|
|
new OA\Property(property: 'note', type: 'string'),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(
|
|
response: 201,
|
|
description: 'Appointment booked successfully',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(property: 'data', type: 'object', description: 'Appointment object'),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 404, description: 'Doctor not found'),
|
|
new OA\Response(response: 409, description: 'Slot already taken'),
|
|
new OA\Response(response: 422, description: 'Validation error'),
|
|
]
|
|
)]
|
|
#[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);
|
|
}
|
|
|
|
if ($slotStart < time()) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'زمان این اسلات گذشته است', 422);
|
|
}
|
|
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
$forSelf = (bool) ($data['for_self'] ?? true);
|
|
|
|
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
|
if (isset($data['note'])) $appointment->setNote($data['note']);
|
|
|
|
// آدرس نوبت از روی session متناظر در برنامهی هفتگی تعیین میشود (location_id).
|
|
$locationId = $this->resolveSlotLocationId($doctor, $slotStart);
|
|
if ($locationId !== null) {
|
|
$appointment->setAddressId($locationId);
|
|
}
|
|
|
|
if ($forSelf) {
|
|
$appointment->setPatientName($user->getRealName());
|
|
$appointment->setPatientMobile($user->getMobileNumber());
|
|
} else {
|
|
$patientName = trim($data['patient_name'] ?? '');
|
|
$patientMobile = trim($data['patient_mobile'] ?? '');
|
|
if ($patientName === '' || $patientMobile === '') {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام و شماره موبایل بیمار الزامی است', 422);
|
|
}
|
|
$appointment->setPatientName($patientName);
|
|
$appointment->setPatientMobile($patientMobile);
|
|
$appointment->setPatientNationalCode($data['patient_national_code'] ?? null);
|
|
$appointment->setPatientGender($data['patient_gender'] ?? null);
|
|
$appointment->setPatientReason($data['patient_reason'] ?? null);
|
|
}
|
|
|
|
$appointment->markPendingWithTtl(Appointment::PAYMENT_TTL);
|
|
|
|
try {
|
|
$this->appointmentRepo->bookAtomically($appointment);
|
|
} catch (SlotTakenException) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
|
|
}
|
|
|
|
return $this->success(['data' => $appointment->toArray()], 201);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointment/{uuid}',
|
|
summary: 'Get a single appointment by UUID',
|
|
security: [['bearerAuth' => []]],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'uuid',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(property: 'data', type: 'object', description: 'Appointment object'),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 403, description: 'Access denied'),
|
|
new OA\Response(response: 404, description: 'Appointment not found'),
|
|
]
|
|
)]
|
|
#[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()]);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointments/doctor/{doctorUuid}',
|
|
summary: 'List appointments for a specific doctor',
|
|
security: [['bearerAuth' => []]],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'doctorUuid',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
new OA\Parameter(
|
|
name: 'status',
|
|
in: 'query',
|
|
required: false,
|
|
description: 'Filter by appointment status',
|
|
schema: new OA\Schema(type: 'string', example: 'pending')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment list returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(
|
|
property: 'data',
|
|
type: 'array',
|
|
items: new OA\Items(type: 'object', description: 'Appointment object')
|
|
),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 403, description: 'Access denied'),
|
|
new OA\Response(response: 404, description: 'Doctor not found'),
|
|
]
|
|
)]
|
|
#[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)]);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointments/user',
|
|
summary: 'List appointments for the authenticated user',
|
|
security: [['bearerAuth' => []]],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'status',
|
|
in: 'query',
|
|
required: false,
|
|
description: 'Filter by appointment status',
|
|
schema: new OA\Schema(type: 'string', example: 'pending')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment list returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(
|
|
property: 'data',
|
|
type: 'array',
|
|
items: new OA\Items(type: 'object', description: 'Appointment object')
|
|
),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
]
|
|
)]
|
|
#[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');
|
|
}
|
|
|
|
private function resolveSlotLocationId(Doctor $doctor, int $slotStart): ?int
|
|
{
|
|
return $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
|
|
}
|
|
|
|
#[OA\Patch(
|
|
path: '/api/v1/appointment/{uuid}/status',
|
|
summary: 'Update the status of an appointment',
|
|
security: [['bearerAuth' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['status'],
|
|
properties: [
|
|
new OA\Property(property: 'status', type: 'string', example: 'confirmed'),
|
|
new OA\Property(property: 'version', type: 'integer', description: 'Optimistic lock version'),
|
|
]
|
|
)
|
|
),
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'uuid',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment status updated',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(property: 'data', type: 'object', description: 'Updated appointment object'),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 403, description: 'Access denied'),
|
|
new OA\Response(response: 404, description: 'Appointment not found'),
|
|
new OA\Response(response: 409, description: 'Optimistic lock conflict'),
|
|
new OA\Response(response: 422, description: 'Invalid status transition'),
|
|
]
|
|
)]
|
|
#[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);
|
|
}
|
|
|
|
if ($newStatus === Appointment::STATUS_CONFIRMED) {
|
|
$this->patientService->autoCreateOnAppointmentConfirm($appointment);
|
|
}
|
|
|
|
return $this->success(['data' => $appointment->toArray()]);
|
|
}
|
|
}
|