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, ]); } /** * حالت نوبت‌دهی سرویسی: زمان‌های خالیِ کافی برای مجموعِ مدت سرویس‌های انتخاب‌شده. * فقط سرویس‌های «نمایش در نوبت‌دهی» (bookable) و دارای مدت پذیرفته می‌شوند. * * GET /api/v1/appointment-service-slots?doctor_uuid=..&date=Y-m-d&service_item_uuids[]=.. */ #[Route('/api/v1/appointment-service-slots', methods: ['GET'])] public function serviceSlots(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'); } $schedule = $this->scheduleRepo->findByDoctor($doctor); $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); } $uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids')))); if (empty($uuids)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids'); } // مدتِ override منشی (فقط برای همین محاسبه؛ پیش‌فرض سرویس تغییر نمی‌کند). durations[uuid]=minutes $overrides = (array) $request->query->all('durations'); $totalMinutes = 0; foreach ($uuids as $u) { $item = $this->itemRepo->findByUuid($u); if ($item === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids'); } if (!$item->isBookable()) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids'); } $duration = isset($overrides[$u]) && (int) $overrides[$u] > 0 ? (int) $overrides[$u] : (int) ($item->getDurationMinutes() ?? 0); if ($duration <= 0) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); } $totalMinutes += $duration; } $meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META; return $this->success([ 'doctor_uuid' => $doctorUuid, 'date' => $date, 'total_duration_minutes' => $totalMinutes, 'buffer_minutes' => (int) $meta['buffer_minutes'], 'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes), ]); } /** * عمومی: روش نوبت‌دهی پزشک + سرویس‌های قابل‌انتخاب برای نوبت‌گیری سرویسی. * سایت با این پاسخ تصمیم می‌گیرد مرحلهٔ انتخاب سرویس را نشان دهد یا جریان اسلاتی. * * GET /api/v1/appointment-booking-services/{doctorUuid} */ #[Route('/api/v1/appointment-booking-services/{doctorUuid}', methods: ['GET'])] public function bookingServices(string $doctorUuid): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $schedule = $this->scheduleRepo->findByDoctor($doctor); $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, 'booking_mode' => $meta['booking_mode'], 'buffer_minutes' => (int) $meta['buffer_minutes'], 'services' => $services, ]); } #[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); // حالت نوبت‌دهی سرویسی: مدت نوبت = مجموع مدت سرویس‌های bookableِ انتخاب‌شده، // و slot_end سمت سرور محاسبه می‌شود (به مقدار کلاینت اعتماد نمی‌شود). $serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? [])))); $serviceItem = null; if (!empty($serviceUuids)) { $totalMinutes = 0; foreach ($serviceUuids as $u) { $item = $this->itemRepo->findByUuid($u); if ($item === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids'); } if (!$item->isBookable()) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids'); } if (($item->getDurationMinutes() ?? 0) <= 0) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); } $totalMinutes += (int) $item->getDurationMinutes(); $serviceItem ??= $item; } $slotEnd = $slotStart + $totalMinutes * 60; } 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); // کد ملی و جنسیت بیمار همیشه الزامی است (چه برای خود، چه برای دیگری). $nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? ''))); // فرانت‌اند male/female می‌فرستد؛ به فرمِ متعارفِ بک‌اند (man/woman) نگاشت می‌شود. $gender = match (strtolower(trim((string) ($data['patient_gender'] ?? '')))) { 'man', 'male' => 'man', 'woman', 'female' => 'woman', default => '', }; if ($nationalCode === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی بیمار الزامی است', 422, 'patient_national_code'); } if (!InputValidator::isValidIranNationalCode($nationalCode)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'patient_national_code'); } if ($gender === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'جنسیت بیمار الزامی است', 422, 'patient_gender'); } $appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); $appointment->setPatientNationalCode($nationalCode); $appointment->setPatientGender($gender); if ($serviceItem !== null) $appointment->setServiceItem($serviceItem); if (isset($data['note'])) $appointment->setNote($data['note']); // نماینده‌ی دامنه‌ی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظه‌ی // پرداخت دوباره از payment.frontend_address محاسبه می‌شود — این فقط ثبتِ لحظه‌ی رزرو است. $bookingCtx = $this->domainResolver->resolve($request->headers->get('origin')); if ($bookingCtx->representationId() !== null) { $appointment->setBookingRepresentationId($bookingCtx->representationId()); } // آدرس نوبت از روی 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->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()]); } /** * General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). * All fields optional; only what is present in the body changes. Slot moves * go through rescheduleTo so active_slot_key stays consistent. Optimistic * lock via `version` like the status endpoint. */ #[Route('/api/v1/appointment/{uuid}', methods: ['PATCH'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function update(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) ?? []; $version = (int) ($data['version'] ?? $appointment->getVersion()); // Slot move / reserve toggle — both times together, or neither. $hasStart = array_key_exists('slot_start', $data); $hasEnd = array_key_exists('slot_end', $data); if ($hasStart !== $hasEnd) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'slot_start و slot_end باید با هم ارسال شوند', 422, 'slot_start'); } if ($hasStart || array_key_exists('is_reserve', $data)) { $newStart = $hasStart ? (int) $data['slot_start'] : $appointment->getSlotStart(); $newEnd = $hasStart ? (int) $data['slot_end'] : $appointment->getSlotEnd(); if ($newEnd < $newStart) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ساعت پایان قبل از شروع است', 422, 'slot_end'); } $isReserve = array_key_exists('is_reserve', $data) ? (bool) $data['is_reserve'] : null; $movingToLiveSlot = ($isReserve ?? $appointment->isReserve()) === false; if ($movingToLiveSlot && ($newStart !== $appointment->getSlotStart() || $newEnd !== $appointment->getSlotEnd()) && $this->appointmentRepo->isSlotTaken($appointment->getDoctor(), $newStart, $newEnd, $appointment->getId())) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start'); } $appointment->rescheduleTo($newStart, $newEnd, $isReserve); } // Workflow relations — empty string clears, uuid assigns, unknown → 422. foreach ([ 'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'], 'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'], 'staff_uuid' => [$this->staffRepo, 'setStaff', 'پرسنل'], ] as $key => [$repo, $setter, $label]) { if (!array_key_exists($key, $data)) { continue; } $value = trim((string) ($data[$key] ?? '')); if ($value === '') { $appointment->$setter(null); continue; } $entity = $repo->findByUuid($value); if ($entity === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, $label . ' یافت نشد', 422, $key); } $appointment->$setter($entity); } if (array_key_exists('deposit_required', $data)) { $appointment->setDepositRequired((bool) $data['deposit_required']); } if (array_key_exists('deposit_amount_rials', $data)) { $appointment->setDepositAmountRials($data['deposit_amount_rials'] !== null ? (int) $data['deposit_amount_rials'] : null); } if (array_key_exists('note', $data)) { $appointment->setNote($data['note'] !== null ? trim((string) $data['note']) : null); } // جایگزینی نوبت — swap the person occupying the slot. if (array_key_exists('patient_name', $data)) { $appointment->setPatientName($data['patient_name'] !== null ? trim((string) $data['patient_name']) : null); } if (array_key_exists('patient_mobile', $data)) { $appointment->setPatientMobile($data['patient_mobile'] !== null ? trim((string) $data['patient_mobile']) : null); } // Optional status transition, same rules as the dedicated endpoint. $newStatus = trim((string) ($data['status'] ?? '')); if ($newStatus !== '' && $newStatus !== $appointment->getStatus()) { if (!$appointment->canTransitionTo($newStatus)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf( 'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus ), 422); } $appointment->transitionTo($newStatus); if ($newStatus === Appointment::STATUS_CONFIRMED) { $this->patientService->autoCreateOnAppointmentConfirm($appointment); } } try { $this->appointmentRepo->saveWithLock($appointment, $version); } catch (OptimisticLockException) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409); } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) { // race backstop: someone grabbed the slot between the pre-check and the flush return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start'); } return $this->success(['data' => $appointment->toArray()]); } }