Files
clinicpro/src/Appointment/Controller/AppointmentController.php
T
hamedandClaude Opus 5 a6acf3bfe2 Book for a resource, and manage the services a resource offers
POST /api/v1/appointment now accepts resource_uuid. When the resource is a
doctor the doctor is inferred from it, and the booking clinic is derived from
the resource's branch — sending clinic_uuid separately was only ever a way to
make the two disagree. The doctor-only path is untouched, which the public site
depends on since it sends nothing else.

Two guards before the booking is built. The resource must belong to the same
environment as the booking: it arrives as a uuid from the request body, so
TenantFilter does not cover it and without the check a patient could attach
another clinic's device to this clinic's appointment. And a resource that does
not offer the requested service is refused up front rather than discovered when
the patient turns up. That second check runs over the items the calculator
already validated rather than re-reading uuids, which is also why the
tenant-lookup inventory stays where it was.

GET and PUT /api/v1/resource/{uuid}/services manage the offerings. The list
returns the effective duration and price along with which level produced each,
so the panel can label an empty cell "30 minutes — service default" instead of
leaving the user guessing whether it is unset or zero. PUT replaces wholesale,
like the skills endpoint: a row absent from the body is a row the user removed,
and an empty string clears an override back to inheritance rather than setting
zero.

findEligible now also orders by category coverage — a device registered for
"foot" sorts ahead for a foot service. Ordering, not filtering: a clinic that
categorised only some of its devices would otherwise lose the rest.

Thirteen tests across the two files. Suite 1304 green, phpstan at its 14-error
baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:20:41 +03:30

1378 lines
69 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\Context\EntityContext;
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\Resource\Repository\ClinicResourceRepository $resources,
private readonly \App\Resource\Repository\ResourceServiceOfferingRepository $offerings,
private readonly \App\Clinic\Repository\ClinicRepository $clinicRepo,
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;
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
$hasServices = $serviceUuids !== [];
/**
* منبع می‌تواند جای پزشک بنشیند: نوبتِ «دستگاه لیزر ۲» پزشکی ندارد که uuidش
* فرستاده شود. اگر منبع خودش پزشک باشد، پزشک از آن استنتاج می‌شود.
*/
$resourceUuid = trim((string) ($data['resource_uuid'] ?? ''));
$resource = null;
if ($resourceUuid !== '') {
$resource = $this->resources->findByUuid($resourceUuid);
if ($resource === null || !$resource->isActive()) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منبع یافت نشد', 422, 'resource_uuid');
}
if ($doctorUuid === '' && $resource->subject() instanceof \App\Doctor\Entity\Doctor) {
$doctorUuid = $resource->subject()->getUuid();
}
// رزرو **برای یک منبع** یعنی رزرو در شعبهٔ همان منبع؛ فرستادن جداگانهٔ
// `clinic_uuid` فقط راهی برای ناسازگار کردن این دو بود.
if ($clinicUuid === null && $resource->getAddress()->getClinicId() !== null) {
$clinicUuid = $this->clinicUuidOf($resource->getAddress()->getClinicId());
}
}
// در حالت سرویسی `slot_end` از سرویس‌ها ساخته می‌شود، پس نبودنش خطا نیست.
if ($doctorUuid === '' || $slotStart <= 0 || (!$hasServices && $slotEnd <= $slotStart)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid یا resource_uuid به‌همراه slot_start الزامی است', 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);
/**
* مدت از {@see ServiceBookingCalculator} می‌آید، نه از جمعِ دستیِ `duration_minutes`.
*
* جمع دستی مدتِ solo/additional را نمی‌دید، پس نوبتِ چندسرویسی اینجا مدتی می‌گرفت
* که با اسلات‌های `appointment-service-slots` یکی نبود — یعنی بیمار وقتی را رزرو
* می‌کرد که سرور جای دیگری آزاد حساب کرده بود. calculator خودش هم مالکیت محیط را
* می‌سنجد (همان بررسی‌ای که قبلاً جداگانه صدا زده می‌شد) و خطاهایش همان کد و پیام
* قبلی را دارند.
*/
$duration = null;
if ($hasServices) {
$duration = $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids);
$slotEnd = $duration->endFor($slotStart);
}
if ($resource !== null) {
/**
* منبع با uuid از بدنهٔ درخواست می‌آید و `TenantFilter` پوششش نمی‌دهد، پس
* بدون این بررسی بیمار می‌توانست دستگاه کلینیک دیگری را روی نوبت این کلینیک
* بنشاند.
*/
[$bookingType, $bookingId] = EntityContext::forBooking($doctor, $bookingClinic)->toEntityPair();
if ($resource->getEntityType() !== $bookingType || $resource->getEntityId() !== $bookingId) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منبع یافت نشد', 422, 'resource_uuid');
}
/**
* منبعی که این سرویس را نمی‌دهد، همین‌جا رد می‌شود نه وقتی بیمار سرِ قرار
* حاضر شده. روی `serviceItems`ِ خروجی calculator کار می‌کند نه uuidهای خام:
* مالکیت محیطشان همان‌جا سنجیده شده، پس جست‌وجوی تازه‌ای لازم نیست.
*/
foreach ($duration === null ? [] : $duration->serviceItems as $item) {
if ($this->offerings->hasAnyFor($item)
&& !in_array((int) $resource->getId(), $this->offerings->activeResourceIdsFor($item), true)) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
'این منبع این سرویس را ارائه نمی‌دهد',
422,
'resource_uuid',
);
}
}
}
$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);
$appointment->setResource($resource);
/**
* سرویس‌ها و مدت **ذخیره** می‌شوند، نه فقط برای حساب‌کردن `slot_end` استفاده.
*
* بدون این، نوبتِ ثبت‌شده از سایت عمومی هیچ ردی از سرویس نداشت: پنل بیمار
* («سرویس: …» و مدت) خالی می‌ماند، گزارش‌ها این نوبت را بی‌سرویس می‌دیدند و
* جابه‌جایی هم مدتی برای حفظ‌کردن پیدا نمی‌کرد. مسیر پنل مدیریت این کار را
* می‌کرد و مسیر عمومی نه.
*/
if ($duration !== null) {
$appointment->replaceServiceItems($duration->serviceItems);
$appointment->setServiceDuration($duration->totalMinutes, $duration->bufferMinutes);
}
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 یعنی مطب شخصی پزشک — نه «هر محلی
* که پیدا شد»: با چند برنامهٔ هم‌زمان، حدس‌زدن محل یعنی ثبت خاموشِ نوبت در جای
* اشتباه.
*/
/** uuid کلینیکِ یک شعبه — برای وقتی محل نوبت از منبع مشتق می‌شود. */
private function clinicUuidOf(int $clinicId): ?string
{
return $this->clinicRepo->find($clinicId)?->getUuid();
}
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);
}
/**
* تاریخ 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) {
}
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) {
}
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));
}
}