Files
clinicpro/src/Appointment/Controller/AppointmentController.php
T
hamedandClaude Opus 5 50759bf663 feat(admin): service-aware time picking on the appointment edit page
In service mode the page now mounts the existing ServiceSlotPicker and hides the
three free-form time inputs plus the single-service select: a 45-minute service
could previously be shortened to 20 and the next patient would sit on top of it.
Hidden rather than disabled — a disabled field reads as "you must do something
here".

Saving splits in two: the service-aware endpoint takes the time and services
(the client sends no duration), then the usual PATCH carries deposit, insurance,
status and note without slot_start/slot_end/version, since the reschedule already
advanced the optimistic-lock version.

Booking mode is read from the appointment's own schedule via an explicit
clinic_uuid, not from the panel's current environment: a doctor can be slot-based
in their office and service-based in a clinic. That required exposing clinic_uuid
in Appointment::toArray(), which was missing.

appointment-service-slots accepts exclude_appointment_uuid, gated on canManage of
that appointment — an ungated parameter would let anyone fabricate availability.

ServiceSlotPicker gained two optional props; its existing callers pass neither and
are unaffected. Its reset-on-doctor-change effect now skips the first run, which
would otherwise wipe the initial selection.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:57:21 +03:30

1319 lines
65 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\Security\AppointmentAccessChecker;
use App\Appointment\Service\AppointmentConfirmationService;
use App\Appointment\Service\SlotCalculatorService;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Service\InputValidator;
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 AppointmentConfirmationService $appointmentConfirmation,
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo,
private readonly \App\Appointment\Repository\AppointmentEventRepository $eventRepo,
private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker,
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
private readonly \Psr\Log\LoggerInterface $logger,
) {}
private const CANCEL_STATUSES = [
Appointment::STATUS_CANCELLED_BY_DOCTOR,
Appointment::STATUS_CANCELLED_BY_USER,
];
/**
* ثبت رویداد لغو در Timeline نوبت + لاگ سطح warning (تا در app_log هم persist شود).
* بعد از ذخیره‌ی موفق نوبت صدا زده می‌شود.
*/
private function recordCancellation(Appointment $appointment, string $status, ?string $reason, User $user): void
{
$actorName = $user->getRealName() ?: $user->getMobileNumber();
$event = new \App\Appointment\Entity\AppointmentEvent($appointment, \App\Appointment\Entity\AppointmentEvent::TYPE_CANCELLED, 'نوبت لغو شد');
$event->setActor($user->getId(), $actorName);
$event->setReason($reason);
$this->eventRepo->save($event);
$this->logger->warning(sprintf(
'Appointment cancelled: uuid=%s status=%s by user=%d(%s) reason=%s',
$appointment->getUuid(), $status, (int) $user->getId(), $actorName, $reason ?? '-'
));
}
// ── 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');
}
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic, $forManagement);
return $this->success([
'doctor_uuid' => $doctorUuid,
'clinic_uuid' => $clinic?->getUuid(),
'date' => $date,
'sessions' => $sessions,
// خالی‌بودن دلایل مختلفی دارد؛ کلاینت نباید همه را «تعطیل» بنامد.
'empty_reason' => $sessions === []
? $this->slotCalculator->explainEmptyDay($doctor, $date, $clinic, $forManagement)
: null,
]);
}
/**
* حالت نوبت‌دهی سرویسی: زمان‌های خالیِ کافی برای مجموعِ مدت سرویس‌های انتخاب‌شده.
* فقط سرویس‌های «نمایش در نوبت‌دهی» (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');
}
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
if ($mode !== WeeklySchedule::MODE_SERVICE) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبت‌دهی سرویسی نیست', 422);
}
$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
// مالکیت محیط، وجود، فعال‌بودن و مدت — همه داخل calculate() و با همان ترتیب و
// همان کد/پیام/فیلدِ قبلی (AppException و $this->error() یک envelope می‌سازند).
$duration = $this->serviceCalculator->calculate(
$doctor,
$clinic,
$uuids,
(array) $request->query->all('durations'),
);
// جابه‌جایی: بازهٔ خودِ نوبتِ در حال ویرایش نباید اشغال حساب شود، وگرنه زمان
// فعلی‌اش هرگز در فهرست نمی‌آید. فقط برای کسی که همان نوبت را مدیریت می‌کند —
// این پارامتر آزاد نیست، چون در غیر این‌صورت هر کسی می‌توانست با uuid دلخواه
// ظرفیتِ ساختگی ببیند.
$excludeId = null;
$excludeUuid = trim((string) $request->query->get('exclude_appointment_uuid', ''));
if ($excludeUuid !== '') {
$excluded = $this->appointmentRepo->findByUuid($excludeUuid);
$actor = $this->getUser();
if ($excluded === null || !$actor instanceof User || !$this->canManage($excluded, $actor)) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403, 'exclude_appointment_uuid');
}
if ($excluded->getDoctor()->getId() !== $doctor->getId()) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوبت انتخابی به این پزشک تعلق ندارد', 422, 'exclude_appointment_uuid');
}
$excludeId = $excluded->getId();
}
return $this->success([
'doctor_uuid' => $doctorUuid,
'date' => $date,
'total_duration_minutes' => $duration->totalMinutes,
'buffer_minutes' => $duration->bufferMinutes,
'clinic_uuid' => $clinic?->getUuid(),
'start_times' => $this->slotCalculator->getServiceStartTimes(
$doctor,
$date,
$duration->totalMinutes,
$clinic,
$this->isManagementContext($request, $doctor, $clinic),
$excludeId,
),
]);
}
/**
* عمومی: روش نوبت‌دهی پزشک + سرویس‌های قابل‌انتخاب برای نوبت‌گیری سرویسی.
* سایت با این پاسخ تصمیم می‌گیرد مرحلهٔ انتخاب سرویس را نشان دهد یا جریان اسلاتی.
*
* GET /api/v1/appointment-booking-services/{doctorUuid}
*/
#[Route('/api/v1/appointment-booking-services/{doctorUuid}', methods: ['GET'])]
public function bookingServices(string $doctorUuid, Request $request): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
return $this->success([
'doctor_uuid' => $doctorUuid,
'clinic_uuid' => $clinic?->getUuid(),
'booking_mode' => $meta['booking_mode'],
'buffer_minutes' => (int) $meta['buffer_minutes'],
'services' => $this->bookableServices($doctor, $clinic),
]);
}
/**
* عمومی: همهٔ محل‌های نوبت‌دهی یک پزشک — مطب شخصی و هر کلینیکی که در آن برنامهٔ
* فعال دارد. سایت باید همه را نشان دهد؛ انتخاب یکی و پنهان‌کردن بقیه یعنی حذف
* بخشی از ظرفیت واقعی پزشک.
*
* GET /api/v1/appointment-booking-locations/{doctorUuid}
*/
#[Route('/api/v1/appointment-booking-locations/{doctorUuid}', methods: ['GET'])]
public function bookingLocations(string $doctorUuid, Request $request): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
$date = trim((string) $request->query->get('date', ''));
if ($date !== '' && !$this->isCalendarDate($date)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
}
$locations = [];
foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) {
$clinic = $schedule->getClinic();
$addresses = $this->addressRepo->findForContext($doctor, $clinic?->getId());
// محلی که آدرسی ندارد، محل نیست — چیزی برای مراجعهٔ بیمار وجود ندارد.
if ($addresses === []) {
continue;
}
$byId = [];
foreach ($addresses as $a) {
$byId[(int) $a->getId()] = $a;
}
// و برنامه‌ای که هیچ شیفتش روی آدرس‌های همین محیط ننشسته، قابل رزرو نیست.
$hours = $this->openingHours($schedule, $byId);
if ($hours === []) {
continue;
}
$meta = $schedule->getMeta();
$address = $byId[$hours[0]['location_id']] ?? $addresses[0];
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
$locations[] = [
'location_uuid' => $address->getUuid(),
'type' => $clinic === null ? 'personal' : 'clinic',
'title' => $clinic?->getName() ?? ($address->getName() ?: 'مطب شخصی'),
'address' => $address->getAddress(),
'clinic_uuid' => $clinic?->getUuid(),
'booking_mode' => $meta['booking_mode'],
'buffer_minutes' => (int) $meta['buffer_minutes'],
'opening_hours' => $hours,
'services' => $meta['booking_mode'] === WeeklySchedule::MODE_SERVICE
? $this->bookableServices($doctor, $clinic)
: [],
'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic, 30, $forManagement),
'available_on_date' => $date === ''
? null
: $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic, $forManagement) !== [],
];
}
// پیش‌فرضِ سایت = زودترین نوبت آزاد؛ محل‌های بدون ظرفیت به انتها می‌روند.
usort($locations, fn(array $a, array $b) => ($a['next_available_at'] ?? PHP_INT_MAX) <=> ($b['next_available_at'] ?? PHP_INT_MAX));
return $this->success([
'doctor_uuid' => $doctorUuid,
'date' => $date !== '' ? $date : null,
'booking_locations' => $locations,
]);
}
#[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');
}
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
$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, $clinic, $forManagement)) {
$enabled[] = $date;
} else {
$disabled[] = $date;
}
}
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
return $this->success([
'year' => $year,
'clinic_uuid' => $clinic?->getUuid(),
'month' => $month,
'disabled_dates' => $disabled,
'enabled_dates' => $enabled,
'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);
$clinicUuid = $data['clinic_uuid'] ?? null;
// حالت نوبت‌دهی سرویسی: مدت نوبت = مجموع مدت سرویس‌های 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);
}
$bookingClinic = $this->bookingClinic($doctor, $clinicUuid);
// سرویس باید متعلق به همان محلی باشد که نوبت در آن ثبت می‌شود؛ وگرنه بیمار
// می‌توانست سرویس کلینیک را روی نوبت مطب شخصی بنشاند.
if ($serviceItem !== null && ($err = $this->assertServicesMatchContext($serviceUuids, $doctor, $bookingClinic)) !== null) {
return $err;
}
$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).
$appointment->setClinic($bookingClinic);
$appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $bookingClinic));
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
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_ACCESS_DENIED, 'دسترسی ممنوع', 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);
}
$isOwner = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN');
// مدیر کلینیک لیست پزشک عضو را می‌بیند، ولی فقط نوبت‌های همان کلینیک —
// نوبت‌های مطب شخصی پزشک به کلینیک نشت نمی‌کند.
$scopeClinic = $isOwner ? null : $this->accessChecker->viewableClinicFor($user, $doctor);
if (!$isOwner && $scopeClinic === null) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
// بدون هیچ پارامتر فیلتر/صفحه‌بندی، رفتار قدیمی (لیست کامل، پاسخ تودرتو) حفظ
// می‌شود تا کلاینت‌های موجود نشکنند. با هر فیلتری پاسخ صفحه‌بندی‌شده می‌آید.
$filterKeys = ['statuses', 'from', 'to', 'q', 'service_uuid', 'page', 'limit'];
$isFiltered = (bool) array_filter($filterKeys, fn(string $k) => $request->query->has($k));
if (!$isFiltered) {
$status = $request->query->get('status');
$appointments = $this->appointmentRepo->findByDoctor($doctor, $status, $scopeClinic);
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
}
// هم `statuses[]=a&statuses[]=b` و هم `statuses=a,b` پذیرفته می‌شود؛ سینتکس
// دوم بدون براکت در Symfony به رشته تبدیل می‌شود و all() استثنا می‌دهد.
$rawStatuses = $request->query->has('statuses') ? $request->query->all()['statuses'] : [];
$statuses = is_array($rawStatuses) ? $rawStatuses : explode(',', (string) $rawStatuses);
if ($statuses === [] && $request->query->get('status')) {
$statuses = [$request->query->get('status')];
}
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
$result = $this->appointmentRepo->searchByDoctor(
doctor: $doctor,
statuses: array_values(array_filter($statuses, fn($s) => is_string($s) && $s !== '')),
clinic: $scopeClinic,
from: $request->query->has('from') ? (int) $request->query->get('from') : null,
to: $request->query->has('to') ? (int) $request->query->get('to') : null,
query: $request->query->get('q'),
serviceUuid: $request->query->get('service_uuid'),
page: $page,
limit: $limit,
);
return $this->paginated(
array_map(fn(Appointment $a) => $a->toArray(), $result['items']),
$result['total'],
$page,
$limit,
);
}
#[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 $this->accessChecker->canView($a, $user);
}
private function canManage(Appointment $a, User $user): bool
{
return $this->accessChecker->canManage($a, $user);
}
/**
* محلِ نوبت‌دهی این درخواست. بدون clinic_uuid یعنی مطب شخصی پزشک — نه «هر محلی
* که پیدا شد»: با چند برنامهٔ هم‌زمان، حدس‌زدن محل یعنی ثبت خاموشِ نوبت در جای
* اشتباه.
*/
private function bookingClinic(Doctor $doctor, ?string $clinicUuid): ?Clinic
{
return $this->bookingContext->resolve($doctor, $clinicUuid);
}
/**
* آیا این درخواستِ اسلات از پنل مدیریت است (پزشک/منشی/ادمینِ دارای دسترسی)؟
* اندپوینت‌های اسلات عمومی‌اند؛ فقط با management=1 + کاربرِ احرازشده و مجاز،
* توگلِ نوبت‌دهی آنلاین دور زده می‌شود. در غیر این‌صورت مثل رزرو عمومی رفتار می‌شود
* (fail-safe عمومی) تا اسلاتِ خاموش به بازدیدکنندهٔ سایت نشت نکند.
*/
private function isManagementContext(Request $request, Doctor $doctor, ?Clinic $clinic): bool
{
if ($request->query->get('management') !== '1') {
return false;
}
$user = $this->getUser();
if (!$user instanceof User) {
return false;
}
return $this->accessChecker->canManageContext($user, $doctor, $clinic);
}
private function assertServicesMatchContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): ?JsonResponse
{
[$type, $id] = $clinic !== null
? ['clinic', $clinic->getId()]
: ['doctor', $doctor->getId()];
foreach ($serviceUuids as $uuid) {
$section = $this->itemRepo->findByUuid($uuid)?->getSection();
if ($section === null || $section->getEntityType() !== $type || $section->getEntityId() !== $id) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', 422, 'service_item_uuids');
}
}
return null;
}
/**
* تاریخ Y-m-d که واقعاً روی تقویم وجود دارد. regex تنها کافی نیست: «2026-13-99»
* الگو را پاس می‌کند ولی روزی نیست.
*/
private function isCalendarDate(string $date): bool
{
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
return $parsed !== false && $parsed->format('Y-m-d') === $date;
}
/** @return array<int, array<string, mixed>> */
private function bookableServices(Doctor $doctor, ?Clinic $clinic): array
{
[$type, $id] = $clinic !== null
? ['clinic', $clinic->getId()]
: ['doctor', $doctor->getId()];
return array_map(function (\App\ClinicService\Entity\ServiceItem $i): array {
$section = $i->getSection();
return [
'uuid' => $i->getUuid(),
'name' => $i->getName(),
'duration_minutes' => $i->getDurationMinutes(),
'price_rials' => $i->getPriceRials(),
'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()],
];
}, $this->itemRepo->findBookableByEntity($type, $id));
}
/**
* شیفت‌های فعال هفته به‌صورت تخت، با نام انگلیسی روز — آمادهٔ نگاشت به
* openingHoursSpecification در schema.org. کلیدهای برنامه 0..6 هستند و 0 شنبه است.
*
* فقط شیفت‌هایی برمی‌گردند که آدرسشان در $allowedAddressIds باشد.
*
* @param array<int, \App\Doctor\Entity\DoctorAddress> $allowedAddressIds
*
* @return array<int, array{day: string, day_index: int, location_id: int, opens: string, closes: string}>
*/
private function openingHours(WeeklySchedule $schedule, array $allowedAddressIds): array
{
$hours = [];
foreach ($schedule->getDaySchedule() as $dayIndex => $day) {
$dayName = WeeklySchedule::DAYS[(int) $dayIndex] ?? null;
if ($dayName === null) {
continue;
}
foreach (($day['sessions'] ?? []) as $session) {
if (!($session['active'] ?? false)) {
continue;
}
// شیفتی که آدرس ندارد یا به آدرسی خارج از این محیط اشاره می‌کند،
// قابل رزرو نیست و نباید ساعت کاری تولید کند.
$locationId = (int) ($session['location_id'] ?? 0);
if ($locationId === 0 || !isset($allowedAddressIds[$locationId])) {
continue;
}
$opens = $session['start_time'] ?? null;
$closes = $session['end_time'] ?? null;
if ($opens === null || $closes === null) {
continue;
}
$hours[] = [
'day' => ucfirst($dayName),
'day_index' => (int) $dayIndex,
'location_id' => $locationId,
'opens' => $opens,
'closes' => $closes,
];
}
}
return $hours;
}
#[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);
}
$data = json_decode($request->getContent(), true) ?? [];
$newStatus = trim($data['status'] ?? '');
$version = (int) ($data['version'] ?? $appointment->getVersion());
// لغو مجوز جداگانه دارد: منشی به‌صورت پیش‌فرض اجازهٔ لغو ندارد ولی وضعیت‌های
// دیگر را تغییر می‌دهد.
$action = in_array($newStatus, self::CANCEL_STATUSES, true)
? AppointmentAccessChecker::ACTION_CANCEL
: AppointmentAccessChecker::ACTION_UPDATE_STATUS;
if (!$this->accessChecker->can($appointment, $user, $action)) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
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->appointmentConfirmation->onConfirmed($appointment);
}
if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
$reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : '';
$this->recordCancellation($appointment, $newStatus, $reason !== '' ? $reason : null, $user);
}
return $this->success(['data' => $appointment->toArray()]);
}
/**
* قطعی‌کردن نوبت به‌همراه پرداخت — «ثبت‌شده» → «قطعی».
*
* یک عملِ اتمیک: انتقال وضعیت، ساخت/یافتنِ پروندهٔ همان محیط با سرویس‌های نوبت،
* و ثبت پرداخت‌های کامل یا جزئی روی همان مراجعه. اگر هر مرحله شکست بخورد هیچ‌کدام
* ثبت نمی‌شوند.
*/
#[OA\Post(
path: '/api/v1/appointment/{uuid}/confirm',
summary: 'Confirm an appointment and register its payments on the patient case file',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(response: 200, description: 'Appointment confirmed'),
new OA\Response(response: 403, description: 'Access denied, or payments sent without the patient_records feature'),
new OA\Response(response: 404, description: 'Appointment not found'),
new OA\Response(response: 409, description: 'Version conflict'),
new OA\Response(response: 422, description: 'Invalid transition or payment'),
]
)]
#[Route('/api/v1/appointment/{uuid}/confirm', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function confirm(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->accessChecker->can($appointment, $user, AppointmentAccessChecker::ACTION_UPDATE_STATUS)) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$version = (int) ($data['version'] ?? $appointment->getVersion());
if (!$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), Appointment::STATUS_CONFIRMED
), 422);
}
// پرداختِ چندروشی (split): هر ردیف یک روش + مبلغ، و به‌اختیار جزئیاتِ روش
// (uuid کارت‌خوان/حساب بانکیِ ثبت‌شده + شناسه تراکنش).
$payments = [];
foreach ((array) ($data['payments'] ?? []) as $row) {
$method = trim((string) ($row['method'] ?? ''));
$amount = (int) ($row['amount_rials'] ?? 0);
if (!in_array($method, \App\Patient\Entity\SessionPayment::METHODS, true)) {
return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'method');
}
if ($amount <= 0) {
return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'amount_rials');
}
$methodUuid = trim((string) ($row['payment_method_uuid'] ?? ''));
$reference = trim((string) ($row['reference'] ?? ''));
$payments[] = [
'method' => $method,
'amount_rials' => $amount,
'payment_method_uuid' => $methodUuid !== '' ? mb_substr($methodUuid, 0, 36) : null,
'reference' => $reference !== '' ? mb_substr($reference, 0, 255) : null,
];
}
// انتخاب بیمه سرِ پذیرش: قبل از ساخت مراجعه روی نوبت می‌نشیند تا سهم‌ها با
// همان بیمه محاسبه شوند.
$this->appointmentInsurance->apply($appointment, $data);
try {
$session = $this->appointmentConfirmation->confirmWithPayments($appointment, $version, $payments, $user);
} catch (OptimisticLockException) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
}
return $this->success([
'appointment' => $appointment->toArray(),
'session' => $session === null ? null : [
'uuid' => $session->getUuid(),
'visit_price_rials' => $session->getVisitPriceRials(),
'services_total_rials' => $session->getServicesTotalRials(),
'final_price_rials' => $session->getFinalPriceRials(),
'discount_rials' => $session->getDiscountRials(),
'paid_total_rials' => $session->getPaidTotalRials(),
'remaining_rials' => $session->getRemainingRials(),
'is_paid' => $session->getRemainingRials() === 0,
// تفکیک بیمه — مودالِ قطعی‌کردن همان مبلغی را نشان می‌دهد که ثبت شده.
'insurance_service_category' => $session->getInsuranceServiceCategory()?->value,
'insurance_base_id' => $session->getInsuranceBaseId(),
'insurance_supplementary_id' => $session->getInsuranceSupplementaryId(),
'gross_total_rials' => $session->getGrossTotalRials(),
'base_insurance_rials' => $session->getBaseInsuranceRials(),
'supplementary_insurance_rials' => $session->getSupplementaryInsuranceRials(),
'patient_share_rials' => $session->getPatientShareRials(),
],
]);
}
/**
* 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_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$version = (int) ($data['version'] ?? $appointment->getVersion());
// status درون‌خطی نباید گیت لغو را دور بزند.
if (in_array(trim((string) ($data['status'] ?? '')), self::CANCEL_STATUSES, true)
&& !$this->accessChecker->canCancel($appointment, $user)) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
// ── حالت نوبت‌دهی سرویسی: مدت، داده است نه ورودی ──────────────────────────
// سرویس‌ها باید پیش از بلوک زمان حل شوند، چون مدتِ مجاز به آن‌ها وابسته است.
// در حالت اسلاتی هیچ‌کدام از این خطوط اجرا نمی‌شود و رفتار بیت‌به‌بیت همان می‌ماند.
$serviceMode = $this->serviceCalculator->isServiceMode($appointment->getDoctor(), $appointment->getClinic());
$hasServiceSet = array_key_exists('service_item_uuids', $data);
$duration = null;
if ($serviceMode) {
$requestedUuids = $hasServiceSet
? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids'])))
: $appointment->currentServiceUuids();
if ($requestedUuids !== []) {
$duration = $this->serviceCalculator->calculate(
$appointment->getDoctor(),
$appointment->getClinic(),
$requestedUuids,
(array) ($data['durations'] ?? []),
// سرویسِ غیرفعالِ نوبتِ موجود نباید نوبت را برای همیشه قفل کند؛ ولی
// افزودن سرویس غیرفعالِ تازه رد می‌شود.
allowInactive: !$hasServiceSet,
);
}
}
// 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;
// نوبتِ زمان‌دارِ سرویسی نمی‌تواند مدت دلخواه بگیرد. نوبت رزرو معاف است:
// slot_start == slot_end دارد و بازه‌ای اشغال نمی‌کند.
if ($movingToLiveSlot && $duration !== null && $newEnd !== $duration->endFor($newStart)) {
return $this->error(
ErrorCodes::ERR_APPOINTMENT_003,
sprintf('مدت این نوبت باید %d دقیقه باشد', $duration->totalMinutes),
422,
'slot_end',
);
}
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');
}
// rescheduleTo() خودش refreshActiveSlotKey() را صدا می‌زند، پس تبدیل رزرو به
// نوبت زمان‌دار (is_reserve: false) کلید یکتایی را بازتولید می‌کند.
$appointment->rescheduleTo($newStart, $newEnd, $isReserve);
}
if ($duration !== null) {
if ($hasServiceSet) {
$appointment->replaceServiceItems($duration->serviceItems);
}
$appointment->setServiceDuration($duration->totalMinutes, $duration->bufferMinutes);
}
// Workflow relations — empty string clears, uuid assigns, unknown → 422.
// `service_item_uuid` تکی وقتی نادیده گرفته می‌شود که فهرست کامل آمده باشد،
// وگرنه دو منبع برای یک چیز به نوبتِ ناسازگار می‌رسد.
foreach ([
'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'],
'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'],
'staff_uuid' => [$this->staffRepo, 'setStaff', 'پرسنل'],
] as $key => [$repo, $setter, $label]) {
if ($key === 'service_item_uuid' && $hasServiceSet) {
continue;
}
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);
}
$this->appointmentInsurance->apply($appointment, $data);
// جایگزینی نوبت — 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'] ?? ''));
$cancelledTo = null;
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->appointmentConfirmation->onConfirmed($appointment);
}
if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
$cancelledTo = $newStatus;
}
}
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');
}
if ($cancelledTo !== null) {
$reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : '';
$this->recordCancellation($appointment, $cancelledTo, $reason !== '' ? $reason : null, $user);
}
return $this->success(['data' => $appointment->toArray()]);
}
/**
* جابه‌جایی سرویس‌آگاه — فقط حالت نوبت‌دهی سرویسی.
*
* کلاینت مدت نمی‌فرستد: `start` می‌دهد و سرور مدت را از سرویس‌های نوبت (یا فهرست
* صریحِ درخواست) حساب می‌کند. این تفاوت با `PATCH` است که کلاینت باید `slot_end`
* درست را از قبل بداند.
*
* POST /api/v1/appointment/{uuid}/service-reschedule
*/
#[Route('/api/v1/appointment/{uuid}/service-reschedule', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function serviceReschedule(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_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$start = (int) ($data['start'] ?? 0);
if ($start <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'زمان شروع الزامی است', 422, 'start');
}
$serviceUuids = array_key_exists('service_item_uuids', $data)
? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids'])))
: null;
try {
$duration = $this->rescheduleService->reschedule(
$appointment,
$start,
$serviceUuids,
(array) ($data['durations'] ?? []),
$this->accessChecker->canManageContext($user, $appointment->getDoctor(), $appointment->getClinic()),
array_key_exists('version', $data) ? (int) $data['version'] : null,
);
} catch (OptimisticLockException) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'start');
}
return $this->success([
'uuid' => $appointment->getUuid(),
'slot_start' => $appointment->getSlotStart(),
'slot_end' => $appointment->getSlotEnd(),
'total_duration_minutes' => $duration->totalMinutes,
'buffer_minutes' => $duration->bufferMinutes,
'warnings' => $duration->warnings,
]);
}
// ── Timeline: رویدادهای یک نوبت ───────────────────────────────────────────
#[Route('/api/v1/appointment/{uuid}/events', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function events(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_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
return $this->success($this->eventRepo->findByAppointmentUuid($uuid));
}
}