Files
clinicpro/src/Appointment/Controller/AppointmentController.php
T
hamedandClaude Opus 5 4049daf071 feat: close the last four domain events, and the panel paths they describe
Every one of the fourteen named events now has an emit point. The four that
were missing all sat on paths owned by earlier tasks:

- AppointmentCompleted fires from both status-change routes, after the row is
  saved. A rejected transition or a version conflict leaves no event; otherwise
  the completed count runs ahead of the appointments themselves.
- AppointmentRescheduled is a third event, not a replacement. A rebook is a
  confirm plus a cancel, and a consumer that only hears the cancel messages a
  patient who still has an appointment.
- ResourceBlocked / ResourceReleased are a pair. Capacity coming back has to be
  as audible as capacity going away, or the resource reads as permanently taken.

Publishing is now on the scheduler rather than an unregistered command: the
logic moved out of PublishDomainEventsCommand into OutboxPublisher so the
recurring message and the manual command share it, and the existing
worker-scheduler container consumes it. The scheduler message carries no data
on purpose — what to publish is read from the table, so an event recorded
between two ticks is not skipped. DomainEventMessage routes to async, since a
slow consumer was otherwise slowing the drain itself and its failure marked a
row failed that had in fact been delivered.

Panel work that these paths made reachable:

- Cancelling from the appointment page now goes through the policy-aware
  endpoint and shows the penalty preview before the confirm, so the operator
  does not discover the patient's penalty after the fact. The cancellation
  service writes the timeline entry itself and accepts a reason, which that
  path previously dropped on the floor.
- Rescheduling reuses the booking page under ?rebook=<uuid> — the search and
  hold steps are identical and only the final step differs. The doctor picker
  is hidden there: a reschedule is not an invitation to change doctors.
- A new GET /appointment/{uuid}/segments exposes the recorded plan. An empty
  list is not an error, it means the appointment is slot-based, and that is
  exactly what gates the resource-mode reschedule button.

AppointmentInvoiceCard no longer crashes the whole detail page when an older
invoice has no discount breakdown.

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

1351 lines
67 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 \App\Shared\Event\DomainEventPublisher $domainEvents,
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 ?? '-'
));
}
/**
* ثبت رویداد دامنهٔ «نوبت انجام شد».
*
* جدا از `AppointmentEvent` است و جایگزینش نمی‌شود: آن، تایم‌لاینِ خوانده‌شده توسط
* اپراتور است و این، صندوق خروجی برای مصرف‌کننده‌های بیرونی. هر دو مسیرِ تغییر
* وضعیت (اندپوینت اختصاصی و `PATCH`) بعد از ذخیرهٔ موفق به اینجا می‌رسند، چون
* رویدادِ کاری که هنوز ذخیره نشده، دروغ است.
*/
private function recordCompletion(Appointment $appointment): void
{
$this->domainEvents->recordAndFlush(
$appointment->getEntityType(),
$appointment->getEntityId(),
\App\Shared\Event\DomainEvents::APPOINTMENT_COMPLETED,
[
'appointment_uuid' => $appointment->getUuid(),
'slot_start' => $appointment->getSlotStart(),
],
);
}
// ── 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);
}
if ($newStatus === Appointment::STATUS_COMPLETED) {
$this->recordCompletion($appointment);
}
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;
$completed = false;
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;
}
$completed = $newStatus === Appointment::STATUS_COMPLETED;
}
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);
}
if ($completed) {
$this->recordCompletion($appointment);
}
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));
}
}