Service-booking mode now selects services by section like slot mode: appointment-booking-services returns service_section per item; ServiceSlotPicker groups by section (SearchableSelect), accumulates picks across sections into a removable 'section -> service' chip list. Secretaries can override a service's duration for a single appointment without changing the service default: appointment-service-slots accepts durations[uuid] and both create endpoints accept service_durations; the override drives total duration and slot_end. Online (patient) booking is unaffected — it never sends overrides. Backend + frontend tests and docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
778 lines
36 KiB
PHP
778 lines
36 KiB
PHP
<?php
|
|
|
|
namespace App\Appointment\Controller;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Appointment\Entity\WeeklySchedule;
|
|
use App\Appointment\Repository\AppointmentRepository;
|
|
use App\Appointment\Repository\SlotTakenException;
|
|
use App\Appointment\Repository\WeeklyScheduleRepository;
|
|
use App\Appointment\Service\SlotCalculatorService;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Auth\Entity\User;
|
|
use App\Doctor\Repository\DoctorRepository;
|
|
use App\Patient\Service\PatientService;
|
|
use App\Shared\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 PatientService $patientService,
|
|
private readonly WeeklyScheduleRepository $scheduleRepo,
|
|
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,
|
|
) {}
|
|
|
|
// ── Public: available slots ───────────────────────────────────────────────
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointment-slots',
|
|
summary: 'Get available appointment slots for a doctor on a given date',
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'doctor_uuid',
|
|
in: 'query',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
new OA\Parameter(
|
|
name: 'date',
|
|
in: 'query',
|
|
required: true,
|
|
description: 'Date in Y-m-d format',
|
|
schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Available slots returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(
|
|
property: 'data',
|
|
properties: [
|
|
new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'),
|
|
new OA\Property(property: 'date', type: 'string', format: 'date'),
|
|
new OA\Property(
|
|
property: 'slots',
|
|
type: 'array',
|
|
items: new OA\Items(
|
|
properties: [
|
|
new OA\Property(property: 'start', type: 'integer', description: 'Unix timestamp'),
|
|
new OA\Property(property: 'end', type: 'integer', description: 'Unix timestamp'),
|
|
new OA\Property(property: 'available', type: 'boolean'),
|
|
],
|
|
type: 'object'
|
|
)
|
|
),
|
|
],
|
|
type: 'object'
|
|
),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(
|
|
response: 404,
|
|
description: 'Doctor not found',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: false),
|
|
new OA\Property(
|
|
property: 'errors',
|
|
type: 'array',
|
|
items: new OA\Items(
|
|
properties: [
|
|
new OA\Property(property: 'code', type: 'string'),
|
|
new OA\Property(property: 'message', type: 'string'),
|
|
],
|
|
type: 'object'
|
|
)
|
|
),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 422, description: 'Invalid date format'),
|
|
]
|
|
)]
|
|
#[Route('/api/v1/appointment-slots', methods: ['GET'])]
|
|
public function slots(Request $request): JsonResponse
|
|
{
|
|
$doctorUuid = trim($request->query->get('doctor_uuid', ''));
|
|
$date = trim($request->query->get('date', ''));
|
|
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
|
}
|
|
|
|
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date);
|
|
|
|
return $this->success([
|
|
'doctor_uuid' => $doctorUuid,
|
|
'date' => $date,
|
|
'sessions' => $sessions,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* حالت نوبتدهی سرویسی: زمانهای خالیِ کافی برای مجموعِ مدت سرویسهای انتخابشده.
|
|
* فقط سرویسهای «نمایش در نوبتدهی» (bookable) و دارای مدت پذیرفته میشوند.
|
|
*
|
|
* GET /api/v1/appointment-service-slots?doctor_uuid=..&date=Y-m-d&service_item_uuids[]=..
|
|
*/
|
|
#[Route('/api/v1/appointment-service-slots', methods: ['GET'])]
|
|
public function serviceSlots(Request $request): JsonResponse
|
|
{
|
|
$doctorUuid = trim($request->query->get('doctor_uuid', ''));
|
|
$date = trim($request->query->get('date', ''));
|
|
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
|
}
|
|
|
|
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
|
$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
|
|
if ($mode !== WeeklySchedule::MODE_SERVICE) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبتدهی سرویسی نیست', 422);
|
|
}
|
|
|
|
$uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids'))));
|
|
if (empty($uuids)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids');
|
|
}
|
|
|
|
// مدتِ override منشی (فقط برای همین محاسبه؛ پیشفرض سرویس تغییر نمیکند). durations[uuid]=minutes
|
|
$overrides = (array) $request->query->all('durations');
|
|
|
|
$totalMinutes = 0;
|
|
foreach ($uuids as $u) {
|
|
$item = $this->itemRepo->findByUuid($u);
|
|
if ($item === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
|
}
|
|
if (!$item->isBookable()) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
|
}
|
|
$duration = isset($overrides[$u]) && (int) $overrides[$u] > 0
|
|
? (int) $overrides[$u]
|
|
: (int) ($item->getDurationMinutes() ?? 0);
|
|
if ($duration <= 0) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
|
}
|
|
$totalMinutes += $duration;
|
|
}
|
|
|
|
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
|
|
|
return $this->success([
|
|
'doctor_uuid' => $doctorUuid,
|
|
'date' => $date,
|
|
'total_duration_minutes' => $totalMinutes,
|
|
'buffer_minutes' => (int) $meta['buffer_minutes'],
|
|
'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* عمومی: روش نوبتدهی پزشک + سرویسهای قابلانتخاب برای نوبتگیری سرویسی.
|
|
* سایت با این پاسخ تصمیم میگیرد مرحلهٔ انتخاب سرویس را نشان دهد یا جریان اسلاتی.
|
|
*
|
|
* GET /api/v1/appointment-booking-services/{doctorUuid}
|
|
*/
|
|
#[Route('/api/v1/appointment-booking-services/{doctorUuid}', methods: ['GET'])]
|
|
public function bookingServices(string $doctorUuid): JsonResponse
|
|
{
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
|
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
|
|
|
$services = array_map(function (\App\ClinicService\Entity\ServiceItem $i) {
|
|
$section = $i->getSection();
|
|
return [
|
|
'uuid' => $i->getUuid(),
|
|
'name' => $i->getName(),
|
|
'duration_minutes' => $i->getDurationMinutes(),
|
|
'price_rials' => $i->getPriceRials(),
|
|
'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()],
|
|
];
|
|
}, $this->itemRepo->findBookableByEntity('doctor', $doctor->getId()));
|
|
|
|
return $this->success([
|
|
'doctor_uuid' => $doctorUuid,
|
|
'booking_mode' => $meta['booking_mode'],
|
|
'buffer_minutes' => (int) $meta['buffer_minutes'],
|
|
'services' => $services,
|
|
]);
|
|
}
|
|
|
|
#[Route('/api/v1/appointment-settings/month-availability/{doctorUuid}', methods: ['GET'])]
|
|
public function monthAvailability(string $doctorUuid, Request $request): JsonResponse
|
|
{
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
$year = (int) $request->query->get('year');
|
|
$month = (int) $request->query->get('month');
|
|
if ($year < 1970 || $month < 1 || $month > 12) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال یا ماه نامعتبر است', 422, 'month');
|
|
}
|
|
|
|
$daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month)));
|
|
|
|
$disabled = [];
|
|
$enabled = [];
|
|
for ($day = 1; $day <= $daysInMonth; $day++) {
|
|
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
|
if ($this->slotCalculator->hasAnyAvailability($doctor, $date)) {
|
|
$enabled[] = $date;
|
|
} else {
|
|
$disabled[] = $date;
|
|
}
|
|
}
|
|
|
|
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
|
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
|
|
|
return $this->success([
|
|
'year' => $year,
|
|
'month' => $month,
|
|
'disabled_dates' => $disabled,
|
|
'enabled_dates' => $enabled,
|
|
'online_booking_enabled' => (bool) $meta['online_booking_enabled'],
|
|
'booking_window' => [
|
|
'value' => (int) $meta['booking_window_value'],
|
|
'unit' => $meta['booking_window_unit'],
|
|
],
|
|
]);
|
|
}
|
|
|
|
// ── Authenticated: book / manage ─────────────────────────────────────────
|
|
|
|
#[OA\Post(
|
|
path: '/api/v1/appointment',
|
|
summary: 'Book a new appointment',
|
|
security: [['bearerAuth' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['doctor_uuid', 'slot_start', 'slot_end'],
|
|
properties: [
|
|
new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'),
|
|
new OA\Property(property: 'slot_start', type: 'integer', description: 'Slot start Unix timestamp'),
|
|
new OA\Property(property: 'slot_end', type: 'integer', description: 'Slot end Unix timestamp'),
|
|
new OA\Property(property: 'note', type: 'string'),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(
|
|
response: 201,
|
|
description: 'Appointment booked successfully',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(property: 'data', type: 'object', description: 'Appointment object'),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 404, description: 'Doctor not found'),
|
|
new OA\Response(response: 409, description: 'Slot already taken'),
|
|
new OA\Response(response: 422, description: 'Validation error'),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/appointment', methods: ['POST'])]
|
|
public function book(Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
|
$slotStart = (int) ($data['slot_start'] ?? 0);
|
|
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
|
|
|
// حالت نوبتدهی سرویسی: مدت نوبت = مجموع مدت سرویسهای bookableِ انتخابشده،
|
|
// و slot_end سمت سرور محاسبه میشود (به مقدار کلاینت اعتماد نمیشود).
|
|
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
|
$serviceItem = null;
|
|
if (!empty($serviceUuids)) {
|
|
$totalMinutes = 0;
|
|
foreach ($serviceUuids as $u) {
|
|
$item = $this->itemRepo->findByUuid($u);
|
|
if ($item === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
|
}
|
|
if (!$item->isBookable()) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
|
}
|
|
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
|
}
|
|
$totalMinutes += (int) $item->getDurationMinutes();
|
|
$serviceItem ??= $item;
|
|
}
|
|
$slotEnd = $slotStart + $totalMinutes * 60;
|
|
}
|
|
|
|
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422);
|
|
}
|
|
|
|
if ($slotStart < time()) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'زمان این اسلات گذشته است', 422);
|
|
}
|
|
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
$forSelf = (bool) ($data['for_self'] ?? true);
|
|
|
|
// کد ملی و جنسیت بیمار همیشه الزامی است (چه برای خود، چه برای دیگری).
|
|
$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
|
|
// فرانتاند male/female میفرستد؛ به فرمِ متعارفِ بکاند (man/woman) نگاشت میشود.
|
|
$gender = match (strtolower(trim((string) ($data['patient_gender'] ?? '')))) {
|
|
'man', 'male' => 'man',
|
|
'woman', 'female' => 'woman',
|
|
default => '',
|
|
};
|
|
|
|
if ($nationalCode === '') {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی بیمار الزامی است', 422, 'patient_national_code');
|
|
}
|
|
if (!InputValidator::isValidIranNationalCode($nationalCode)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'patient_national_code');
|
|
}
|
|
if ($gender === '') {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'جنسیت بیمار الزامی است', 422, 'patient_gender');
|
|
}
|
|
|
|
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
|
$appointment->setPatientNationalCode($nationalCode);
|
|
$appointment->setPatientGender($gender);
|
|
if ($serviceItem !== null) $appointment->setServiceItem($serviceItem);
|
|
if (isset($data['note'])) $appointment->setNote($data['note']);
|
|
|
|
// نمایندهی دامنهی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظهی
|
|
// پرداخت دوباره از payment.frontend_address محاسبه میشود — این فقط ثبتِ لحظهی رزرو است.
|
|
$bookingCtx = $this->domainResolver->resolve($request->headers->get('origin'));
|
|
if ($bookingCtx->representationId() !== null) {
|
|
$appointment->setBookingRepresentationId($bookingCtx->representationId());
|
|
}
|
|
|
|
// آدرس نوبت از روی session متناظر در برنامهی هفتگی تعیین میشود (location_id).
|
|
$locationId = $this->resolveSlotLocationId($doctor, $slotStart);
|
|
if ($locationId !== null) {
|
|
$appointment->setAddressId($locationId);
|
|
}
|
|
|
|
if ($forSelf) {
|
|
$appointment->setPatientName($user->getRealName());
|
|
$appointment->setPatientMobile($user->getMobileNumber());
|
|
} else {
|
|
$patientName = trim($data['patient_name'] ?? '');
|
|
$patientMobile = trim($data['patient_mobile'] ?? '');
|
|
if ($patientName === '' || $patientMobile === '') {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام و شماره موبایل بیمار الزامی است', 422);
|
|
}
|
|
$appointment->setPatientName($patientName);
|
|
$appointment->setPatientMobile($patientMobile);
|
|
$appointment->setPatientReason($data['patient_reason'] ?? null);
|
|
}
|
|
|
|
$appointment->markPendingWithTtl(Appointment::PAYMENT_TTL);
|
|
|
|
try {
|
|
$this->appointmentRepo->bookAtomically($appointment);
|
|
} catch (SlotTakenException) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
|
|
}
|
|
|
|
return $this->success(['data' => $appointment->toArray()], 201);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointment/{uuid}',
|
|
summary: 'Get a single appointment by UUID',
|
|
security: [['bearerAuth' => []]],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'uuid',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(property: 'data', type: 'object', description: 'Appointment object'),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 403, description: 'Access denied'),
|
|
new OA\Response(response: 404, description: 'Appointment not found'),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/appointment/{uuid}', methods: ['GET'])]
|
|
public function get(string $uuid, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
|
if ($appointment === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
|
}
|
|
|
|
if (!$this->canView($appointment, $user)) {
|
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
return $this->success(['data' => $appointment->toArray()]);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointments/doctor/{doctorUuid}',
|
|
summary: 'List appointments for a specific doctor',
|
|
security: [['bearerAuth' => []]],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'doctorUuid',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
new OA\Parameter(
|
|
name: 'status',
|
|
in: 'query',
|
|
required: false,
|
|
description: 'Filter by appointment status',
|
|
schema: new OA\Schema(type: 'string', example: 'pending')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment list returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(
|
|
property: 'data',
|
|
type: 'array',
|
|
items: new OA\Items(type: 'object', description: 'Appointment object')
|
|
),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 403, description: 'Access denied'),
|
|
new OA\Response(response: 404, description: 'Doctor not found'),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/appointments/doctor/{doctorUuid}', methods: ['GET'])]
|
|
public function listByDoctor(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
$status = $request->query->get('status');
|
|
$appointments = $this->appointmentRepo->findByDoctor($doctor, $status);
|
|
|
|
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointments/user',
|
|
summary: 'List appointments for the authenticated user',
|
|
security: [['bearerAuth' => []]],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'status',
|
|
in: 'query',
|
|
required: false,
|
|
description: 'Filter by appointment status',
|
|
schema: new OA\Schema(type: 'string', example: 'pending')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment list returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(
|
|
property: 'data',
|
|
type: 'array',
|
|
items: new OA\Items(type: 'object', description: 'Appointment object')
|
|
),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/appointments/user', methods: ['GET'])]
|
|
public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$status = $request->query->get('status');
|
|
$appointments = $this->appointmentRepo->findByUser($user, $status);
|
|
|
|
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
|
}
|
|
|
|
private function canView(Appointment $a, User $user): bool
|
|
{
|
|
return $a->getUser()->getId() === $user->getId()
|
|
|| $a->getDoctor()->getUser()->getId() === $user->getId()
|
|
|| $user->hasRole('ROLE_ADMIN');
|
|
}
|
|
|
|
private function canManage(Appointment $a, User $user): bool
|
|
{
|
|
return $a->getUser()->getId() === $user->getId()
|
|
|| $a->getDoctor()->getUser()->getId() === $user->getId()
|
|
|| $user->hasRole('ROLE_ADMIN');
|
|
}
|
|
|
|
private function resolveSlotLocationId(Doctor $doctor, int $slotStart): ?int
|
|
{
|
|
return $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
|
|
}
|
|
|
|
#[OA\Patch(
|
|
path: '/api/v1/appointment/{uuid}/status',
|
|
summary: 'Update the status of an appointment',
|
|
security: [['bearerAuth' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['status'],
|
|
properties: [
|
|
new OA\Property(property: 'status', type: 'string', example: 'confirmed'),
|
|
new OA\Property(property: 'version', type: 'integer', description: 'Optimistic lock version'),
|
|
]
|
|
)
|
|
),
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'uuid',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment status updated',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(property: 'data', type: 'object', description: 'Updated appointment object'),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 403, description: 'Access denied'),
|
|
new OA\Response(response: 404, description: 'Appointment not found'),
|
|
new OA\Response(response: 409, description: 'Optimistic lock conflict'),
|
|
new OA\Response(response: 422, description: 'Invalid status transition'),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])]
|
|
public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
|
if ($appointment === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
|
}
|
|
|
|
if (!$this->canManage($appointment, $user)) {
|
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$newStatus = trim($data['status'] ?? '');
|
|
$version = (int) ($data['version'] ?? $appointment->getVersion());
|
|
|
|
if (!$appointment->canTransitionTo($newStatus)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
|
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus
|
|
), 422);
|
|
}
|
|
|
|
$appointment->transitionTo($newStatus);
|
|
|
|
try {
|
|
$this->appointmentRepo->saveWithLock($appointment, $version);
|
|
} catch (OptimisticLockException) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
|
|
}
|
|
|
|
if ($newStatus === Appointment::STATUS_CONFIRMED) {
|
|
$this->patientService->autoCreateOnAppointmentConfirm($appointment);
|
|
}
|
|
|
|
return $this->success(['data' => $appointment->toArray()]);
|
|
}
|
|
|
|
/**
|
|
* General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی).
|
|
* All fields optional; only what is present in the body changes. Slot moves
|
|
* go through rescheduleTo so active_slot_key stays consistent. Optimistic
|
|
* lock via `version` like the status endpoint.
|
|
*/
|
|
#[Route('/api/v1/appointment/{uuid}', methods: ['PATCH'])]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
|
if ($appointment === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
|
}
|
|
|
|
if (!$this->canManage($appointment, $user)) {
|
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$version = (int) ($data['version'] ?? $appointment->getVersion());
|
|
|
|
// Slot move / reserve toggle — both times together, or neither.
|
|
$hasStart = array_key_exists('slot_start', $data);
|
|
$hasEnd = array_key_exists('slot_end', $data);
|
|
if ($hasStart !== $hasEnd) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'slot_start و slot_end باید با هم ارسال شوند', 422, 'slot_start');
|
|
}
|
|
if ($hasStart || array_key_exists('is_reserve', $data)) {
|
|
$newStart = $hasStart ? (int) $data['slot_start'] : $appointment->getSlotStart();
|
|
$newEnd = $hasStart ? (int) $data['slot_end'] : $appointment->getSlotEnd();
|
|
if ($newEnd < $newStart) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ساعت پایان قبل از شروع است', 422, 'slot_end');
|
|
}
|
|
$isReserve = array_key_exists('is_reserve', $data) ? (bool) $data['is_reserve'] : null;
|
|
$movingToLiveSlot = ($isReserve ?? $appointment->isReserve()) === false;
|
|
if ($movingToLiveSlot && ($newStart !== $appointment->getSlotStart() || $newEnd !== $appointment->getSlotEnd())
|
|
&& $this->appointmentRepo->isSlotTaken($appointment->getDoctor(), $newStart, $newEnd, $appointment->getId())) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start');
|
|
}
|
|
$appointment->rescheduleTo($newStart, $newEnd, $isReserve);
|
|
}
|
|
|
|
// Workflow relations — empty string clears, uuid assigns, unknown → 422.
|
|
foreach ([
|
|
'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'],
|
|
'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'],
|
|
'staff_uuid' => [$this->staffRepo, 'setStaff', 'پرسنل'],
|
|
] as $key => [$repo, $setter, $label]) {
|
|
if (!array_key_exists($key, $data)) {
|
|
continue;
|
|
}
|
|
$value = trim((string) ($data[$key] ?? ''));
|
|
if ($value === '') {
|
|
$appointment->$setter(null);
|
|
continue;
|
|
}
|
|
$entity = $repo->findByUuid($value);
|
|
if ($entity === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, $label . ' یافت نشد', 422, $key);
|
|
}
|
|
$appointment->$setter($entity);
|
|
}
|
|
|
|
if (array_key_exists('deposit_required', $data)) {
|
|
$appointment->setDepositRequired((bool) $data['deposit_required']);
|
|
}
|
|
if (array_key_exists('deposit_amount_rials', $data)) {
|
|
$appointment->setDepositAmountRials($data['deposit_amount_rials'] !== null ? (int) $data['deposit_amount_rials'] : null);
|
|
}
|
|
if (array_key_exists('note', $data)) {
|
|
$appointment->setNote($data['note'] !== null ? trim((string) $data['note']) : null);
|
|
}
|
|
// جایگزینی نوبت — swap the person occupying the slot.
|
|
if (array_key_exists('patient_name', $data)) {
|
|
$appointment->setPatientName($data['patient_name'] !== null ? trim((string) $data['patient_name']) : null);
|
|
}
|
|
if (array_key_exists('patient_mobile', $data)) {
|
|
$appointment->setPatientMobile($data['patient_mobile'] !== null ? trim((string) $data['patient_mobile']) : null);
|
|
}
|
|
|
|
// Optional status transition, same rules as the dedicated endpoint.
|
|
$newStatus = trim((string) ($data['status'] ?? ''));
|
|
if ($newStatus !== '' && $newStatus !== $appointment->getStatus()) {
|
|
if (!$appointment->canTransitionTo($newStatus)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
|
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus
|
|
), 422);
|
|
}
|
|
$appointment->transitionTo($newStatus);
|
|
if ($newStatus === Appointment::STATUS_CONFIRMED) {
|
|
$this->patientService->autoCreateOnAppointmentConfirm($appointment);
|
|
}
|
|
}
|
|
|
|
try {
|
|
$this->appointmentRepo->saveWithLock($appointment, $version);
|
|
} catch (OptimisticLockException) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
|
|
} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
|
|
// race backstop: someone grabbed the slot between the pre-check and the flush
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start');
|
|
}
|
|
|
|
return $this->success(['data' => $appointment->toArray()]);
|
|
}
|
|
}
|