Files
clinicpro/src/Appointment/Controller/AppointmentController.php
T
hamedandClaude Opus 4.8 af881231d0 fix: store appointment address from schedule and auto-add patient to clinic
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>
2026-06-23 16:10:36 +03:30

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()]);
}
}