In service mode the page now mounts the existing ServiceSlotPicker and hides the three free-form time inputs plus the single-service select: a 45-minute service could previously be shortened to 20 and the next patient would sit on top of it. Hidden rather than disabled — a disabled field reads as "you must do something here". Saving splits in two: the service-aware endpoint takes the time and services (the client sends no duration), then the usual PATCH carries deposit, insurance, status and note without slot_start/slot_end/version, since the reschedule already advanced the optimistic-lock version. Booking mode is read from the appointment's own schedule via an explicit clinic_uuid, not from the panel's current environment: a doctor can be slot-based in their office and service-based in a clinic. That required exposing clinic_uuid in Appointment::toArray(), which was missing. appointment-service-slots accepts exclude_appointment_uuid, gated on canManage of that appointment — an ungated parameter would let anyone fabricate availability. ServiceSlotPicker gained two optional props; its existing callers pass neither and are unaffected. Its reset-on-doctor-change effect now skips the first run, which would otherwise wipe the initial selection. Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1319 lines
65 KiB
PHP
1319 lines
65 KiB
PHP
<?php
|
|
|
|
namespace App\Appointment\Controller;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Appointment\Entity\WeeklySchedule;
|
|
use App\Appointment\Repository\AppointmentRepository;
|
|
use App\Appointment\Repository\SlotTakenException;
|
|
use App\Appointment\Repository\WeeklyScheduleRepository;
|
|
use App\Appointment\Security\AppointmentAccessChecker;
|
|
use App\Appointment\Service\AppointmentConfirmationService;
|
|
use App\Appointment\Service\SlotCalculatorService;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Auth\Entity\User;
|
|
use App\Doctor\Repository\DoctorRepository;
|
|
use App\Shared\Service\InputValidator;
|
|
use App\Shared\Constant\ErrorCodes;
|
|
use App\Shared\Controller\BaseController;
|
|
use Doctrine\ORM\OptimisticLockException;
|
|
use OpenApi\Attributes as OA;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|
|
|
#[OA\Tag(name: 'Appointments')]
|
|
class AppointmentController extends BaseController
|
|
{
|
|
public function __construct(
|
|
private readonly AppointmentRepository $appointmentRepo,
|
|
private readonly DoctorRepository $doctorRepo,
|
|
private readonly SlotCalculatorService $slotCalculator,
|
|
private readonly AppointmentConfirmationService $appointmentConfirmation,
|
|
private readonly WeeklyScheduleRepository $scheduleRepo,
|
|
private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
|
|
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
|
|
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
|
private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
|
|
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
|
|
private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo,
|
|
private readonly \App\Appointment\Repository\AppointmentEventRepository $eventRepo,
|
|
private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker,
|
|
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
|
|
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
|
|
private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
|
|
private readonly \Psr\Log\LoggerInterface $logger,
|
|
) {}
|
|
|
|
private const CANCEL_STATUSES = [
|
|
Appointment::STATUS_CANCELLED_BY_DOCTOR,
|
|
Appointment::STATUS_CANCELLED_BY_USER,
|
|
];
|
|
|
|
/**
|
|
* ثبت رویداد لغو در Timeline نوبت + لاگ سطح warning (تا در app_log هم persist شود).
|
|
* بعد از ذخیرهی موفق نوبت صدا زده میشود.
|
|
*/
|
|
private function recordCancellation(Appointment $appointment, string $status, ?string $reason, User $user): void
|
|
{
|
|
$actorName = $user->getRealName() ?: $user->getMobileNumber();
|
|
|
|
$event = new \App\Appointment\Entity\AppointmentEvent($appointment, \App\Appointment\Entity\AppointmentEvent::TYPE_CANCELLED, 'نوبت لغو شد');
|
|
$event->setActor($user->getId(), $actorName);
|
|
$event->setReason($reason);
|
|
$this->eventRepo->save($event);
|
|
|
|
$this->logger->warning(sprintf(
|
|
'Appointment cancelled: uuid=%s status=%s by user=%d(%s) reason=%s',
|
|
$appointment->getUuid(), $status, (int) $user->getId(), $actorName, $reason ?? '-'
|
|
));
|
|
}
|
|
|
|
// ── Public: available slots ───────────────────────────────────────────────
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointment-slots',
|
|
summary: 'Get available appointment slots for a doctor on a given date',
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'doctor_uuid',
|
|
in: 'query',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
new OA\Parameter(
|
|
name: 'date',
|
|
in: 'query',
|
|
required: true,
|
|
description: 'Date in Y-m-d format',
|
|
schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Available slots returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(
|
|
property: 'data',
|
|
properties: [
|
|
new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'),
|
|
new OA\Property(property: 'date', type: 'string', format: 'date'),
|
|
new OA\Property(
|
|
property: 'slots',
|
|
type: 'array',
|
|
items: new OA\Items(
|
|
properties: [
|
|
new OA\Property(property: 'start', type: 'integer', description: 'Unix timestamp'),
|
|
new OA\Property(property: 'end', type: 'integer', description: 'Unix timestamp'),
|
|
new OA\Property(property: 'available', type: 'boolean'),
|
|
],
|
|
type: 'object'
|
|
)
|
|
),
|
|
],
|
|
type: 'object'
|
|
),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(
|
|
response: 404,
|
|
description: 'Doctor not found',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: false),
|
|
new OA\Property(
|
|
property: 'errors',
|
|
type: 'array',
|
|
items: new OA\Items(
|
|
properties: [
|
|
new OA\Property(property: 'code', type: 'string'),
|
|
new OA\Property(property: 'message', type: 'string'),
|
|
],
|
|
type: 'object'
|
|
)
|
|
),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 422, description: 'Invalid date format'),
|
|
]
|
|
)]
|
|
#[Route('/api/v1/appointment-slots', methods: ['GET'])]
|
|
public function slots(Request $request): JsonResponse
|
|
{
|
|
$doctorUuid = trim($request->query->get('doctor_uuid', ''));
|
|
$date = trim($request->query->get('date', ''));
|
|
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
|
}
|
|
|
|
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
|
|
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
|
|
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic, $forManagement);
|
|
|
|
return $this->success([
|
|
'doctor_uuid' => $doctorUuid,
|
|
'clinic_uuid' => $clinic?->getUuid(),
|
|
'date' => $date,
|
|
'sessions' => $sessions,
|
|
// خالیبودن دلایل مختلفی دارد؛ کلاینت نباید همه را «تعطیل» بنامد.
|
|
'empty_reason' => $sessions === []
|
|
? $this->slotCalculator->explainEmptyDay($doctor, $date, $clinic, $forManagement)
|
|
: null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* حالت نوبتدهی سرویسی: زمانهای خالیِ کافی برای مجموعِ مدت سرویسهای انتخابشده.
|
|
* فقط سرویسهای «نمایش در نوبتدهی» (bookable) و دارای مدت پذیرفته میشوند.
|
|
*
|
|
* GET /api/v1/appointment-service-slots?doctor_uuid=..&date=Y-m-d&service_item_uuids[]=..
|
|
*/
|
|
#[Route('/api/v1/appointment-service-slots', methods: ['GET'])]
|
|
public function serviceSlots(Request $request): JsonResponse
|
|
{
|
|
$doctorUuid = trim($request->query->get('doctor_uuid', ''));
|
|
$date = trim($request->query->get('date', ''));
|
|
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
|
}
|
|
|
|
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
|
|
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
|
$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
|
|
if ($mode !== WeeklySchedule::MODE_SERVICE) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبتدهی سرویسی نیست', 422);
|
|
}
|
|
|
|
$uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids'))));
|
|
if (empty($uuids)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids');
|
|
}
|
|
|
|
// مدتِ override منشی (فقط برای همین محاسبه؛ پیشفرض سرویس تغییر نمیکند). durations[uuid]=minutes
|
|
// مالکیت محیط، وجود، فعالبودن و مدت — همه داخل calculate() و با همان ترتیب و
|
|
// همان کد/پیام/فیلدِ قبلی (AppException و $this->error() یک envelope میسازند).
|
|
$duration = $this->serviceCalculator->calculate(
|
|
$doctor,
|
|
$clinic,
|
|
$uuids,
|
|
(array) $request->query->all('durations'),
|
|
);
|
|
|
|
// جابهجایی: بازهٔ خودِ نوبتِ در حال ویرایش نباید اشغال حساب شود، وگرنه زمان
|
|
// فعلیاش هرگز در فهرست نمیآید. فقط برای کسی که همان نوبت را مدیریت میکند —
|
|
// این پارامتر آزاد نیست، چون در غیر اینصورت هر کسی میتوانست با uuid دلخواه
|
|
// ظرفیتِ ساختگی ببیند.
|
|
$excludeId = null;
|
|
$excludeUuid = trim((string) $request->query->get('exclude_appointment_uuid', ''));
|
|
if ($excludeUuid !== '') {
|
|
$excluded = $this->appointmentRepo->findByUuid($excludeUuid);
|
|
$actor = $this->getUser();
|
|
if ($excluded === null || !$actor instanceof User || !$this->canManage($excluded, $actor)) {
|
|
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403, 'exclude_appointment_uuid');
|
|
}
|
|
if ($excluded->getDoctor()->getId() !== $doctor->getId()) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوبت انتخابی به این پزشک تعلق ندارد', 422, 'exclude_appointment_uuid');
|
|
}
|
|
$excludeId = $excluded->getId();
|
|
}
|
|
|
|
return $this->success([
|
|
'doctor_uuid' => $doctorUuid,
|
|
'date' => $date,
|
|
'total_duration_minutes' => $duration->totalMinutes,
|
|
'buffer_minutes' => $duration->bufferMinutes,
|
|
'clinic_uuid' => $clinic?->getUuid(),
|
|
'start_times' => $this->slotCalculator->getServiceStartTimes(
|
|
$doctor,
|
|
$date,
|
|
$duration->totalMinutes,
|
|
$clinic,
|
|
$this->isManagementContext($request, $doctor, $clinic),
|
|
$excludeId,
|
|
),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* عمومی: روش نوبتدهی پزشک + سرویسهای قابلانتخاب برای نوبتگیری سرویسی.
|
|
* سایت با این پاسخ تصمیم میگیرد مرحلهٔ انتخاب سرویس را نشان دهد یا جریان اسلاتی.
|
|
*
|
|
* GET /api/v1/appointment-booking-services/{doctorUuid}
|
|
*/
|
|
#[Route('/api/v1/appointment-booking-services/{doctorUuid}', methods: ['GET'])]
|
|
public function bookingServices(string $doctorUuid, Request $request): JsonResponse
|
|
{
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
|
|
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
|
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
|
|
|
return $this->success([
|
|
'doctor_uuid' => $doctorUuid,
|
|
'clinic_uuid' => $clinic?->getUuid(),
|
|
'booking_mode' => $meta['booking_mode'],
|
|
'buffer_minutes' => (int) $meta['buffer_minutes'],
|
|
'services' => $this->bookableServices($doctor, $clinic),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* عمومی: همهٔ محلهای نوبتدهی یک پزشک — مطب شخصی و هر کلینیکی که در آن برنامهٔ
|
|
* فعال دارد. سایت باید همه را نشان دهد؛ انتخاب یکی و پنهانکردن بقیه یعنی حذف
|
|
* بخشی از ظرفیت واقعی پزشک.
|
|
*
|
|
* GET /api/v1/appointment-booking-locations/{doctorUuid}
|
|
*/
|
|
#[Route('/api/v1/appointment-booking-locations/{doctorUuid}', methods: ['GET'])]
|
|
public function bookingLocations(string $doctorUuid, Request $request): JsonResponse
|
|
{
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
$date = trim((string) $request->query->get('date', ''));
|
|
if ($date !== '' && !$this->isCalendarDate($date)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
|
}
|
|
|
|
$locations = [];
|
|
foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) {
|
|
$clinic = $schedule->getClinic();
|
|
$addresses = $this->addressRepo->findForContext($doctor, $clinic?->getId());
|
|
|
|
// محلی که آدرسی ندارد، محل نیست — چیزی برای مراجعهٔ بیمار وجود ندارد.
|
|
if ($addresses === []) {
|
|
continue;
|
|
}
|
|
|
|
$byId = [];
|
|
foreach ($addresses as $a) {
|
|
$byId[(int) $a->getId()] = $a;
|
|
}
|
|
|
|
// و برنامهای که هیچ شیفتش روی آدرسهای همین محیط ننشسته، قابل رزرو نیست.
|
|
$hours = $this->openingHours($schedule, $byId);
|
|
if ($hours === []) {
|
|
continue;
|
|
}
|
|
|
|
$meta = $schedule->getMeta();
|
|
$address = $byId[$hours[0]['location_id']] ?? $addresses[0];
|
|
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
|
|
|
|
$locations[] = [
|
|
'location_uuid' => $address->getUuid(),
|
|
'type' => $clinic === null ? 'personal' : 'clinic',
|
|
'title' => $clinic?->getName() ?? ($address->getName() ?: 'مطب شخصی'),
|
|
'address' => $address->getAddress(),
|
|
'clinic_uuid' => $clinic?->getUuid(),
|
|
'booking_mode' => $meta['booking_mode'],
|
|
'buffer_minutes' => (int) $meta['buffer_minutes'],
|
|
'opening_hours' => $hours,
|
|
'services' => $meta['booking_mode'] === WeeklySchedule::MODE_SERVICE
|
|
? $this->bookableServices($doctor, $clinic)
|
|
: [],
|
|
'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic, 30, $forManagement),
|
|
'available_on_date' => $date === ''
|
|
? null
|
|
: $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic, $forManagement) !== [],
|
|
];
|
|
}
|
|
|
|
// پیشفرضِ سایت = زودترین نوبت آزاد؛ محلهای بدون ظرفیت به انتها میروند.
|
|
usort($locations, fn(array $a, array $b) => ($a['next_available_at'] ?? PHP_INT_MAX) <=> ($b['next_available_at'] ?? PHP_INT_MAX));
|
|
|
|
return $this->success([
|
|
'doctor_uuid' => $doctorUuid,
|
|
'date' => $date !== '' ? $date : null,
|
|
'booking_locations' => $locations,
|
|
]);
|
|
}
|
|
|
|
#[Route('/api/v1/appointment-settings/month-availability/{doctorUuid}', methods: ['GET'])]
|
|
public function monthAvailability(string $doctorUuid, Request $request): JsonResponse
|
|
{
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
$year = (int) $request->query->get('year');
|
|
$month = (int) $request->query->get('month');
|
|
if ($year < 1970 || $month < 1 || $month > 12) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال یا ماه نامعتبر است', 422, 'month');
|
|
}
|
|
|
|
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
|
|
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
|
|
$daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month)));
|
|
|
|
$disabled = [];
|
|
$enabled = [];
|
|
for ($day = 1; $day <= $daysInMonth; $day++) {
|
|
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
|
if ($this->slotCalculator->hasAnyAvailability($doctor, $date, $clinic, $forManagement)) {
|
|
$enabled[] = $date;
|
|
} else {
|
|
$disabled[] = $date;
|
|
}
|
|
}
|
|
|
|
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
|
|
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
|
|
|
return $this->success([
|
|
'year' => $year,
|
|
'clinic_uuid' => $clinic?->getUuid(),
|
|
'month' => $month,
|
|
'disabled_dates' => $disabled,
|
|
'enabled_dates' => $enabled,
|
|
'online_booking_enabled' => (bool) $meta['online_booking_enabled'],
|
|
'booking_window' => [
|
|
'value' => (int) $meta['booking_window_value'],
|
|
'unit' => $meta['booking_window_unit'],
|
|
],
|
|
]);
|
|
}
|
|
|
|
// ── Authenticated: book / manage ─────────────────────────────────────────
|
|
|
|
#[OA\Post(
|
|
path: '/api/v1/appointment',
|
|
summary: 'Book a new appointment',
|
|
security: [['bearerAuth' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['doctor_uuid', 'slot_start', 'slot_end'],
|
|
properties: [
|
|
new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'),
|
|
new OA\Property(property: 'slot_start', type: 'integer', description: 'Slot start Unix timestamp'),
|
|
new OA\Property(property: 'slot_end', type: 'integer', description: 'Slot end Unix timestamp'),
|
|
new OA\Property(property: 'note', type: 'string'),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(
|
|
response: 201,
|
|
description: 'Appointment booked successfully',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(property: 'data', type: 'object', description: 'Appointment object'),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 404, description: 'Doctor not found'),
|
|
new OA\Response(response: 409, description: 'Slot already taken'),
|
|
new OA\Response(response: 422, description: 'Validation error'),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/appointment', methods: ['POST'])]
|
|
public function book(Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
|
$slotStart = (int) ($data['slot_start'] ?? 0);
|
|
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
|
$clinicUuid = $data['clinic_uuid'] ?? null;
|
|
|
|
// حالت نوبتدهی سرویسی: مدت نوبت = مجموع مدت سرویسهای bookableِ انتخابشده،
|
|
// و slot_end سمت سرور محاسبه میشود (به مقدار کلاینت اعتماد نمیشود).
|
|
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
|
$serviceItem = null;
|
|
if (!empty($serviceUuids)) {
|
|
$totalMinutes = 0;
|
|
foreach ($serviceUuids as $u) {
|
|
$item = $this->itemRepo->findByUuid($u);
|
|
if ($item === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
|
}
|
|
if (!$item->isBookable()) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
|
}
|
|
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
|
}
|
|
$totalMinutes += (int) $item->getDurationMinutes();
|
|
$serviceItem ??= $item;
|
|
}
|
|
$slotEnd = $slotStart + $totalMinutes * 60;
|
|
}
|
|
|
|
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422);
|
|
}
|
|
|
|
if ($slotStart < time()) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'زمان این اسلات گذشته است', 422);
|
|
}
|
|
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
$bookingClinic = $this->bookingClinic($doctor, $clinicUuid);
|
|
|
|
// سرویس باید متعلق به همان محلی باشد که نوبت در آن ثبت میشود؛ وگرنه بیمار
|
|
// میتوانست سرویس کلینیک را روی نوبت مطب شخصی بنشاند.
|
|
if ($serviceItem !== null && ($err = $this->assertServicesMatchContext($serviceUuids, $doctor, $bookingClinic)) !== null) {
|
|
return $err;
|
|
}
|
|
|
|
$forSelf = (bool) ($data['for_self'] ?? true);
|
|
|
|
// کد ملی و جنسیت بیمار همیشه الزامی است (چه برای خود، چه برای دیگری).
|
|
$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
|
|
// فرانتاند male/female میفرستد؛ به فرمِ متعارفِ بکاند (man/woman) نگاشت میشود.
|
|
$gender = match (strtolower(trim((string) ($data['patient_gender'] ?? '')))) {
|
|
'man', 'male' => 'man',
|
|
'woman', 'female' => 'woman',
|
|
default => '',
|
|
};
|
|
|
|
if ($nationalCode === '') {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی بیمار الزامی است', 422, 'patient_national_code');
|
|
}
|
|
if (!InputValidator::isValidIranNationalCode($nationalCode)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'patient_national_code');
|
|
}
|
|
if ($gender === '') {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'جنسیت بیمار الزامی است', 422, 'patient_gender');
|
|
}
|
|
|
|
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
|
$appointment->setPatientNationalCode($nationalCode);
|
|
$appointment->setPatientGender($gender);
|
|
if ($serviceItem !== null) $appointment->setServiceItem($serviceItem);
|
|
if (isset($data['note'])) $appointment->setNote($data['note']);
|
|
|
|
// نمایندهی دامنهی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظهی
|
|
// پرداخت دوباره از payment.frontend_address محاسبه میشود — این فقط ثبتِ لحظهی رزرو است.
|
|
$bookingCtx = $this->domainResolver->resolve($request->headers->get('origin'));
|
|
if ($bookingCtx->representationId() !== null) {
|
|
$appointment->setBookingRepresentationId($bookingCtx->representationId());
|
|
}
|
|
|
|
// آدرس نوبت از روی session متناظر در برنامهی هفتگی تعیین میشود (location_id).
|
|
$appointment->setClinic($bookingClinic);
|
|
$appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $bookingClinic));
|
|
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
|
|
if ($locationId !== null) {
|
|
$appointment->setAddressId($locationId);
|
|
}
|
|
|
|
if ($forSelf) {
|
|
$appointment->setPatientName($user->getRealName());
|
|
$appointment->setPatientMobile($user->getMobileNumber());
|
|
} else {
|
|
$patientName = trim($data['patient_name'] ?? '');
|
|
$patientMobile = trim($data['patient_mobile'] ?? '');
|
|
if ($patientName === '' || $patientMobile === '') {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام و شماره موبایل بیمار الزامی است', 422);
|
|
}
|
|
$appointment->setPatientName($patientName);
|
|
$appointment->setPatientMobile($patientMobile);
|
|
$appointment->setPatientReason($data['patient_reason'] ?? null);
|
|
}
|
|
|
|
$appointment->markPendingWithTtl(Appointment::PAYMENT_TTL);
|
|
|
|
try {
|
|
$this->appointmentRepo->bookAtomically($appointment);
|
|
} catch (SlotTakenException) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
|
|
}
|
|
|
|
return $this->success(['data' => $appointment->toArray()], 201);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointment/{uuid}',
|
|
summary: 'Get a single appointment by UUID',
|
|
security: [['bearerAuth' => []]],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'uuid',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(property: 'data', type: 'object', description: 'Appointment object'),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 403, description: 'Access denied'),
|
|
new OA\Response(response: 404, description: 'Appointment not found'),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/appointment/{uuid}', methods: ['GET'])]
|
|
public function get(string $uuid, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
|
if ($appointment === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
|
}
|
|
|
|
if (!$this->canView($appointment, $user)) {
|
|
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
return $this->success(['data' => $appointment->toArray()]);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointments/doctor/{doctorUuid}',
|
|
summary: 'List appointments for a specific doctor',
|
|
security: [['bearerAuth' => []]],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'doctorUuid',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
new OA\Parameter(
|
|
name: 'status',
|
|
in: 'query',
|
|
required: false,
|
|
description: 'Filter by appointment status',
|
|
schema: new OA\Schema(type: 'string', example: 'pending')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment list returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(
|
|
property: 'data',
|
|
type: 'array',
|
|
items: new OA\Items(type: 'object', description: 'Appointment object')
|
|
),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 403, description: 'Access denied'),
|
|
new OA\Response(response: 404, description: 'Doctor not found'),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/appointments/doctor/{doctorUuid}', methods: ['GET'])]
|
|
public function listByDoctor(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
|
if ($doctor === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
|
}
|
|
|
|
$isOwner = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN');
|
|
|
|
// مدیر کلینیک لیست پزشک عضو را میبیند، ولی فقط نوبتهای همان کلینیک —
|
|
// نوبتهای مطب شخصی پزشک به کلینیک نشت نمیکند.
|
|
$scopeClinic = $isOwner ? null : $this->accessChecker->viewableClinicFor($user, $doctor);
|
|
if (!$isOwner && $scopeClinic === null) {
|
|
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
// بدون هیچ پارامتر فیلتر/صفحهبندی، رفتار قدیمی (لیست کامل، پاسخ تودرتو) حفظ
|
|
// میشود تا کلاینتهای موجود نشکنند. با هر فیلتری پاسخ صفحهبندیشده میآید.
|
|
$filterKeys = ['statuses', 'from', 'to', 'q', 'service_uuid', 'page', 'limit'];
|
|
$isFiltered = (bool) array_filter($filterKeys, fn(string $k) => $request->query->has($k));
|
|
|
|
if (!$isFiltered) {
|
|
$status = $request->query->get('status');
|
|
$appointments = $this->appointmentRepo->findByDoctor($doctor, $status, $scopeClinic);
|
|
|
|
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
|
}
|
|
|
|
// هم `statuses[]=a&statuses[]=b` و هم `statuses=a,b` پذیرفته میشود؛ سینتکس
|
|
// دوم بدون براکت در Symfony به رشته تبدیل میشود و all() استثنا میدهد.
|
|
$rawStatuses = $request->query->has('statuses') ? $request->query->all()['statuses'] : [];
|
|
$statuses = is_array($rawStatuses) ? $rawStatuses : explode(',', (string) $rawStatuses);
|
|
if ($statuses === [] && $request->query->get('status')) {
|
|
$statuses = [$request->query->get('status')];
|
|
}
|
|
|
|
$page = max(1, (int) $request->query->get('page', 1));
|
|
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
|
|
|
|
$result = $this->appointmentRepo->searchByDoctor(
|
|
doctor: $doctor,
|
|
statuses: array_values(array_filter($statuses, fn($s) => is_string($s) && $s !== '')),
|
|
clinic: $scopeClinic,
|
|
from: $request->query->has('from') ? (int) $request->query->get('from') : null,
|
|
to: $request->query->has('to') ? (int) $request->query->get('to') : null,
|
|
query: $request->query->get('q'),
|
|
serviceUuid: $request->query->get('service_uuid'),
|
|
page: $page,
|
|
limit: $limit,
|
|
);
|
|
|
|
return $this->paginated(
|
|
array_map(fn(Appointment $a) => $a->toArray(), $result['items']),
|
|
$result['total'],
|
|
$page,
|
|
$limit,
|
|
);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/appointments/user',
|
|
summary: 'List appointments for the authenticated user',
|
|
security: [['bearerAuth' => []]],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'status',
|
|
in: 'query',
|
|
required: false,
|
|
description: 'Filter by appointment status',
|
|
schema: new OA\Schema(type: 'string', example: 'pending')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment list returned',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(
|
|
property: 'data',
|
|
type: 'array',
|
|
items: new OA\Items(type: 'object', description: 'Appointment object')
|
|
),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/appointments/user', methods: ['GET'])]
|
|
public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$status = $request->query->get('status');
|
|
$appointments = $this->appointmentRepo->findByUser($user, $status);
|
|
|
|
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
|
}
|
|
|
|
private function canView(Appointment $a, User $user): bool
|
|
{
|
|
return $this->accessChecker->canView($a, $user);
|
|
}
|
|
|
|
private function canManage(Appointment $a, User $user): bool
|
|
{
|
|
return $this->accessChecker->canManage($a, $user);
|
|
}
|
|
|
|
/**
|
|
* محلِ نوبتدهی این درخواست. بدون clinic_uuid یعنی مطب شخصی پزشک — نه «هر محلی
|
|
* که پیدا شد»: با چند برنامهٔ همزمان، حدسزدن محل یعنی ثبت خاموشِ نوبت در جای
|
|
* اشتباه.
|
|
*/
|
|
private function bookingClinic(Doctor $doctor, ?string $clinicUuid): ?Clinic
|
|
{
|
|
return $this->bookingContext->resolve($doctor, $clinicUuid);
|
|
}
|
|
|
|
/**
|
|
* آیا این درخواستِ اسلات از پنل مدیریت است (پزشک/منشی/ادمینِ دارای دسترسی)؟
|
|
* اندپوینتهای اسلات عمومیاند؛ فقط با management=1 + کاربرِ احرازشده و مجاز،
|
|
* توگلِ نوبتدهی آنلاین دور زده میشود. در غیر اینصورت مثل رزرو عمومی رفتار میشود
|
|
* (fail-safe عمومی) تا اسلاتِ خاموش به بازدیدکنندهٔ سایت نشت نکند.
|
|
*/
|
|
private function isManagementContext(Request $request, Doctor $doctor, ?Clinic $clinic): bool
|
|
{
|
|
if ($request->query->get('management') !== '1') {
|
|
return false;
|
|
}
|
|
$user = $this->getUser();
|
|
if (!$user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
return $this->accessChecker->canManageContext($user, $doctor, $clinic);
|
|
}
|
|
|
|
private function assertServicesMatchContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): ?JsonResponse
|
|
{
|
|
[$type, $id] = $clinic !== null
|
|
? ['clinic', $clinic->getId()]
|
|
: ['doctor', $doctor->getId()];
|
|
|
|
foreach ($serviceUuids as $uuid) {
|
|
$section = $this->itemRepo->findByUuid($uuid)?->getSection();
|
|
if ($section === null || $section->getEntityType() !== $type || $section->getEntityId() !== $id) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سرویس انتخابشده به این محل نوبتدهی تعلق ندارد', 422, 'service_item_uuids');
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* تاریخ Y-m-d که واقعاً روی تقویم وجود دارد. regex تنها کافی نیست: «2026-13-99»
|
|
* الگو را پاس میکند ولی روزی نیست.
|
|
*/
|
|
private function isCalendarDate(string $date): bool
|
|
{
|
|
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
|
|
|
return $parsed !== false && $parsed->format('Y-m-d') === $date;
|
|
}
|
|
|
|
/** @return array<int, array<string, mixed>> */
|
|
private function bookableServices(Doctor $doctor, ?Clinic $clinic): array
|
|
{
|
|
[$type, $id] = $clinic !== null
|
|
? ['clinic', $clinic->getId()]
|
|
: ['doctor', $doctor->getId()];
|
|
|
|
return array_map(function (\App\ClinicService\Entity\ServiceItem $i): array {
|
|
$section = $i->getSection();
|
|
return [
|
|
'uuid' => $i->getUuid(),
|
|
'name' => $i->getName(),
|
|
'duration_minutes' => $i->getDurationMinutes(),
|
|
'price_rials' => $i->getPriceRials(),
|
|
'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()],
|
|
];
|
|
}, $this->itemRepo->findBookableByEntity($type, $id));
|
|
}
|
|
|
|
/**
|
|
* شیفتهای فعال هفته بهصورت تخت، با نام انگلیسی روز — آمادهٔ نگاشت به
|
|
* openingHoursSpecification در schema.org. کلیدهای برنامه 0..6 هستند و 0 شنبه است.
|
|
*
|
|
* فقط شیفتهایی برمیگردند که آدرسشان در $allowedAddressIds باشد.
|
|
*
|
|
* @param array<int, \App\Doctor\Entity\DoctorAddress> $allowedAddressIds
|
|
*
|
|
* @return array<int, array{day: string, day_index: int, location_id: int, opens: string, closes: string}>
|
|
*/
|
|
private function openingHours(WeeklySchedule $schedule, array $allowedAddressIds): array
|
|
{
|
|
$hours = [];
|
|
|
|
foreach ($schedule->getDaySchedule() as $dayIndex => $day) {
|
|
$dayName = WeeklySchedule::DAYS[(int) $dayIndex] ?? null;
|
|
if ($dayName === null) {
|
|
continue;
|
|
}
|
|
|
|
foreach (($day['sessions'] ?? []) as $session) {
|
|
if (!($session['active'] ?? false)) {
|
|
continue;
|
|
}
|
|
|
|
// شیفتی که آدرس ندارد یا به آدرسی خارج از این محیط اشاره میکند،
|
|
// قابل رزرو نیست و نباید ساعت کاری تولید کند.
|
|
$locationId = (int) ($session['location_id'] ?? 0);
|
|
if ($locationId === 0 || !isset($allowedAddressIds[$locationId])) {
|
|
continue;
|
|
}
|
|
|
|
$opens = $session['start_time'] ?? null;
|
|
$closes = $session['end_time'] ?? null;
|
|
if ($opens === null || $closes === null) {
|
|
continue;
|
|
}
|
|
|
|
$hours[] = [
|
|
'day' => ucfirst($dayName),
|
|
'day_index' => (int) $dayIndex,
|
|
'location_id' => $locationId,
|
|
'opens' => $opens,
|
|
'closes' => $closes,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $hours;
|
|
}
|
|
|
|
|
|
#[OA\Patch(
|
|
path: '/api/v1/appointment/{uuid}/status',
|
|
summary: 'Update the status of an appointment',
|
|
security: [['bearerAuth' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['status'],
|
|
properties: [
|
|
new OA\Property(property: 'status', type: 'string', example: 'confirmed'),
|
|
new OA\Property(property: 'version', type: 'integer', description: 'Optimistic lock version'),
|
|
]
|
|
)
|
|
),
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'uuid',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Appointment status updated',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(property: 'data', type: 'object', description: 'Updated appointment object'),
|
|
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
new OA\Response(response: 403, description: 'Access denied'),
|
|
new OA\Response(response: 404, description: 'Appointment not found'),
|
|
new OA\Response(response: 409, description: 'Optimistic lock conflict'),
|
|
new OA\Response(response: 422, description: 'Invalid status transition'),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])]
|
|
public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
|
if ($appointment === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
|
}
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$newStatus = trim($data['status'] ?? '');
|
|
$version = (int) ($data['version'] ?? $appointment->getVersion());
|
|
|
|
// لغو مجوز جداگانه دارد: منشی بهصورت پیشفرض اجازهٔ لغو ندارد ولی وضعیتهای
|
|
// دیگر را تغییر میدهد.
|
|
$action = in_array($newStatus, self::CANCEL_STATUSES, true)
|
|
? AppointmentAccessChecker::ACTION_CANCEL
|
|
: AppointmentAccessChecker::ACTION_UPDATE_STATUS;
|
|
|
|
if (!$this->accessChecker->can($appointment, $user, $action)) {
|
|
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
if (!$appointment->canTransitionTo($newStatus)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
|
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus
|
|
), 422);
|
|
}
|
|
|
|
$appointment->transitionTo($newStatus);
|
|
|
|
try {
|
|
$this->appointmentRepo->saveWithLock($appointment, $version);
|
|
} catch (OptimisticLockException) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
|
|
}
|
|
|
|
if ($newStatus === Appointment::STATUS_CONFIRMED) {
|
|
$this->appointmentConfirmation->onConfirmed($appointment);
|
|
}
|
|
|
|
if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
|
|
$reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : '';
|
|
$this->recordCancellation($appointment, $newStatus, $reason !== '' ? $reason : null, $user);
|
|
}
|
|
|
|
return $this->success(['data' => $appointment->toArray()]);
|
|
}
|
|
|
|
/**
|
|
* قطعیکردن نوبت بههمراه پرداخت — «ثبتشده» → «قطعی».
|
|
*
|
|
* یک عملِ اتمیک: انتقال وضعیت، ساخت/یافتنِ پروندهٔ همان محیط با سرویسهای نوبت،
|
|
* و ثبت پرداختهای کامل یا جزئی روی همان مراجعه. اگر هر مرحله شکست بخورد هیچکدام
|
|
* ثبت نمیشوند.
|
|
*/
|
|
#[OA\Post(
|
|
path: '/api/v1/appointment/{uuid}/confirm',
|
|
summary: 'Confirm an appointment and register its payments on the patient case file',
|
|
security: [['bearerAuth' => []]],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Appointment confirmed'),
|
|
new OA\Response(response: 403, description: 'Access denied, or payments sent without the patient_records feature'),
|
|
new OA\Response(response: 404, description: 'Appointment not found'),
|
|
new OA\Response(response: 409, description: 'Version conflict'),
|
|
new OA\Response(response: 422, description: 'Invalid transition or payment'),
|
|
]
|
|
)]
|
|
#[Route('/api/v1/appointment/{uuid}/confirm', methods: ['POST'])]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
public function confirm(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
|
if ($appointment === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
|
}
|
|
|
|
if (!$this->accessChecker->can($appointment, $user, AppointmentAccessChecker::ACTION_UPDATE_STATUS)) {
|
|
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$version = (int) ($data['version'] ?? $appointment->getVersion());
|
|
|
|
if (!$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
|
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), Appointment::STATUS_CONFIRMED
|
|
), 422);
|
|
}
|
|
|
|
// پرداختِ چندروشی (split): هر ردیف یک روش + مبلغ، و بهاختیار جزئیاتِ روش
|
|
// (uuid کارتخوان/حساب بانکیِ ثبتشده + شناسه تراکنش).
|
|
$payments = [];
|
|
foreach ((array) ($data['payments'] ?? []) as $row) {
|
|
$method = trim((string) ($row['method'] ?? ''));
|
|
$amount = (int) ($row['amount_rials'] ?? 0);
|
|
if (!in_array($method, \App\Patient\Entity\SessionPayment::METHODS, true)) {
|
|
return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'method');
|
|
}
|
|
if ($amount <= 0) {
|
|
return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'amount_rials');
|
|
}
|
|
$methodUuid = trim((string) ($row['payment_method_uuid'] ?? ''));
|
|
$reference = trim((string) ($row['reference'] ?? ''));
|
|
$payments[] = [
|
|
'method' => $method,
|
|
'amount_rials' => $amount,
|
|
'payment_method_uuid' => $methodUuid !== '' ? mb_substr($methodUuid, 0, 36) : null,
|
|
'reference' => $reference !== '' ? mb_substr($reference, 0, 255) : null,
|
|
];
|
|
}
|
|
|
|
// انتخاب بیمه سرِ پذیرش: قبل از ساخت مراجعه روی نوبت مینشیند تا سهمها با
|
|
// همان بیمه محاسبه شوند.
|
|
$this->appointmentInsurance->apply($appointment, $data);
|
|
|
|
try {
|
|
$session = $this->appointmentConfirmation->confirmWithPayments($appointment, $version, $payments, $user);
|
|
} catch (OptimisticLockException) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
|
|
}
|
|
|
|
return $this->success([
|
|
'appointment' => $appointment->toArray(),
|
|
'session' => $session === null ? null : [
|
|
'uuid' => $session->getUuid(),
|
|
'visit_price_rials' => $session->getVisitPriceRials(),
|
|
'services_total_rials' => $session->getServicesTotalRials(),
|
|
'final_price_rials' => $session->getFinalPriceRials(),
|
|
'discount_rials' => $session->getDiscountRials(),
|
|
'paid_total_rials' => $session->getPaidTotalRials(),
|
|
'remaining_rials' => $session->getRemainingRials(),
|
|
'is_paid' => $session->getRemainingRials() === 0,
|
|
// تفکیک بیمه — مودالِ قطعیکردن همان مبلغی را نشان میدهد که ثبت شده.
|
|
'insurance_service_category' => $session->getInsuranceServiceCategory()?->value,
|
|
'insurance_base_id' => $session->getInsuranceBaseId(),
|
|
'insurance_supplementary_id' => $session->getInsuranceSupplementaryId(),
|
|
'gross_total_rials' => $session->getGrossTotalRials(),
|
|
'base_insurance_rials' => $session->getBaseInsuranceRials(),
|
|
'supplementary_insurance_rials' => $session->getSupplementaryInsuranceRials(),
|
|
'patient_share_rials' => $session->getPatientShareRials(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی).
|
|
* All fields optional; only what is present in the body changes. Slot moves
|
|
* go through rescheduleTo so active_slot_key stays consistent. Optimistic
|
|
* lock via `version` like the status endpoint.
|
|
*/
|
|
#[Route('/api/v1/appointment/{uuid}', methods: ['PATCH'])]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
|
if ($appointment === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
|
}
|
|
|
|
if (!$this->canManage($appointment, $user)) {
|
|
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$version = (int) ($data['version'] ?? $appointment->getVersion());
|
|
|
|
// status درونخطی نباید گیت لغو را دور بزند.
|
|
if (in_array(trim((string) ($data['status'] ?? '')), self::CANCEL_STATUSES, true)
|
|
&& !$this->accessChecker->canCancel($appointment, $user)) {
|
|
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
// ── حالت نوبتدهی سرویسی: مدت، داده است نه ورودی ──────────────────────────
|
|
// سرویسها باید پیش از بلوک زمان حل شوند، چون مدتِ مجاز به آنها وابسته است.
|
|
// در حالت اسلاتی هیچکدام از این خطوط اجرا نمیشود و رفتار بیتبهبیت همان میماند.
|
|
$serviceMode = $this->serviceCalculator->isServiceMode($appointment->getDoctor(), $appointment->getClinic());
|
|
$hasServiceSet = array_key_exists('service_item_uuids', $data);
|
|
$duration = null;
|
|
|
|
if ($serviceMode) {
|
|
$requestedUuids = $hasServiceSet
|
|
? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids'])))
|
|
: $appointment->currentServiceUuids();
|
|
|
|
if ($requestedUuids !== []) {
|
|
$duration = $this->serviceCalculator->calculate(
|
|
$appointment->getDoctor(),
|
|
$appointment->getClinic(),
|
|
$requestedUuids,
|
|
(array) ($data['durations'] ?? []),
|
|
// سرویسِ غیرفعالِ نوبتِ موجود نباید نوبت را برای همیشه قفل کند؛ ولی
|
|
// افزودن سرویس غیرفعالِ تازه رد میشود.
|
|
allowInactive: !$hasServiceSet,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Slot move / reserve toggle — both times together, or neither.
|
|
$hasStart = array_key_exists('slot_start', $data);
|
|
$hasEnd = array_key_exists('slot_end', $data);
|
|
if ($hasStart !== $hasEnd) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'slot_start و slot_end باید با هم ارسال شوند', 422, 'slot_start');
|
|
}
|
|
if ($hasStart || array_key_exists('is_reserve', $data)) {
|
|
$newStart = $hasStart ? (int) $data['slot_start'] : $appointment->getSlotStart();
|
|
$newEnd = $hasStart ? (int) $data['slot_end'] : $appointment->getSlotEnd();
|
|
if ($newEnd < $newStart) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ساعت پایان قبل از شروع است', 422, 'slot_end');
|
|
}
|
|
$isReserve = array_key_exists('is_reserve', $data) ? (bool) $data['is_reserve'] : null;
|
|
$movingToLiveSlot = ($isReserve ?? $appointment->isReserve()) === false;
|
|
|
|
// نوبتِ زماندارِ سرویسی نمیتواند مدت دلخواه بگیرد. نوبت رزرو معاف است:
|
|
// slot_start == slot_end دارد و بازهای اشغال نمیکند.
|
|
if ($movingToLiveSlot && $duration !== null && $newEnd !== $duration->endFor($newStart)) {
|
|
return $this->error(
|
|
ErrorCodes::ERR_APPOINTMENT_003,
|
|
sprintf('مدت این نوبت باید %d دقیقه باشد', $duration->totalMinutes),
|
|
422,
|
|
'slot_end',
|
|
);
|
|
}
|
|
|
|
if ($movingToLiveSlot && ($newStart !== $appointment->getSlotStart() || $newEnd !== $appointment->getSlotEnd())
|
|
&& $this->appointmentRepo->isSlotTaken($appointment->getDoctor(), $newStart, $newEnd, $appointment->getId())) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start');
|
|
}
|
|
// rescheduleTo() خودش refreshActiveSlotKey() را صدا میزند، پس تبدیل رزرو به
|
|
// نوبت زماندار (is_reserve: false) کلید یکتایی را بازتولید میکند.
|
|
$appointment->rescheduleTo($newStart, $newEnd, $isReserve);
|
|
}
|
|
|
|
if ($duration !== null) {
|
|
if ($hasServiceSet) {
|
|
$appointment->replaceServiceItems($duration->serviceItems);
|
|
}
|
|
$appointment->setServiceDuration($duration->totalMinutes, $duration->bufferMinutes);
|
|
}
|
|
|
|
// Workflow relations — empty string clears, uuid assigns, unknown → 422.
|
|
// `service_item_uuid` تکی وقتی نادیده گرفته میشود که فهرست کامل آمده باشد،
|
|
// وگرنه دو منبع برای یک چیز به نوبتِ ناسازگار میرسد.
|
|
foreach ([
|
|
'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'],
|
|
'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'],
|
|
'staff_uuid' => [$this->staffRepo, 'setStaff', 'پرسنل'],
|
|
] as $key => [$repo, $setter, $label]) {
|
|
if ($key === 'service_item_uuid' && $hasServiceSet) {
|
|
continue;
|
|
}
|
|
if (!array_key_exists($key, $data)) {
|
|
continue;
|
|
}
|
|
$value = trim((string) ($data[$key] ?? ''));
|
|
if ($value === '') {
|
|
$appointment->$setter(null);
|
|
continue;
|
|
}
|
|
$entity = $repo->findByUuid($value);
|
|
if ($entity === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, $label . ' یافت نشد', 422, $key);
|
|
}
|
|
$appointment->$setter($entity);
|
|
}
|
|
|
|
if (array_key_exists('deposit_required', $data)) {
|
|
$appointment->setDepositRequired((bool) $data['deposit_required']);
|
|
}
|
|
if (array_key_exists('deposit_amount_rials', $data)) {
|
|
$appointment->setDepositAmountRials($data['deposit_amount_rials'] !== null ? (int) $data['deposit_amount_rials'] : null);
|
|
}
|
|
if (array_key_exists('note', $data)) {
|
|
$appointment->setNote($data['note'] !== null ? trim((string) $data['note']) : null);
|
|
}
|
|
$this->appointmentInsurance->apply($appointment, $data);
|
|
// جایگزینی نوبت — swap the person occupying the slot.
|
|
if (array_key_exists('patient_name', $data)) {
|
|
$appointment->setPatientName($data['patient_name'] !== null ? trim((string) $data['patient_name']) : null);
|
|
}
|
|
if (array_key_exists('patient_mobile', $data)) {
|
|
$appointment->setPatientMobile($data['patient_mobile'] !== null ? trim((string) $data['patient_mobile']) : null);
|
|
}
|
|
|
|
// Optional status transition, same rules as the dedicated endpoint.
|
|
$newStatus = trim((string) ($data['status'] ?? ''));
|
|
$cancelledTo = null;
|
|
if ($newStatus !== '' && $newStatus !== $appointment->getStatus()) {
|
|
if (!$appointment->canTransitionTo($newStatus)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
|
'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus
|
|
), 422);
|
|
}
|
|
$appointment->transitionTo($newStatus);
|
|
if ($newStatus === Appointment::STATUS_CONFIRMED) {
|
|
$this->appointmentConfirmation->onConfirmed($appointment);
|
|
}
|
|
if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
|
|
$cancelledTo = $newStatus;
|
|
}
|
|
}
|
|
|
|
try {
|
|
$this->appointmentRepo->saveWithLock($appointment, $version);
|
|
} catch (OptimisticLockException) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
|
|
} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
|
|
// race backstop: someone grabbed the slot between the pre-check and the flush
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start');
|
|
}
|
|
|
|
if ($cancelledTo !== null) {
|
|
$reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : '';
|
|
$this->recordCancellation($appointment, $cancelledTo, $reason !== '' ? $reason : null, $user);
|
|
}
|
|
|
|
return $this->success(['data' => $appointment->toArray()]);
|
|
}
|
|
|
|
/**
|
|
* جابهجایی سرویسآگاه — فقط حالت نوبتدهی سرویسی.
|
|
*
|
|
* کلاینت مدت نمیفرستد: `start` میدهد و سرور مدت را از سرویسهای نوبت (یا فهرست
|
|
* صریحِ درخواست) حساب میکند. این تفاوت با `PATCH` است که کلاینت باید `slot_end`
|
|
* درست را از قبل بداند.
|
|
*
|
|
* POST /api/v1/appointment/{uuid}/service-reschedule
|
|
*/
|
|
#[Route('/api/v1/appointment/{uuid}/service-reschedule', methods: ['POST'])]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
public function serviceReschedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
|
if ($appointment === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
|
}
|
|
if (!$this->canManage($appointment, $user)) {
|
|
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$start = (int) ($data['start'] ?? 0);
|
|
if ($start <= 0) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'زمان شروع الزامی است', 422, 'start');
|
|
}
|
|
|
|
$serviceUuids = array_key_exists('service_item_uuids', $data)
|
|
? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids'])))
|
|
: null;
|
|
|
|
try {
|
|
$duration = $this->rescheduleService->reschedule(
|
|
$appointment,
|
|
$start,
|
|
$serviceUuids,
|
|
(array) ($data['durations'] ?? []),
|
|
$this->accessChecker->canManageContext($user, $appointment->getDoctor(), $appointment->getClinic()),
|
|
array_key_exists('version', $data) ? (int) $data['version'] : null,
|
|
);
|
|
} catch (OptimisticLockException) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
|
|
} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
|
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'start');
|
|
}
|
|
|
|
return $this->success([
|
|
'uuid' => $appointment->getUuid(),
|
|
'slot_start' => $appointment->getSlotStart(),
|
|
'slot_end' => $appointment->getSlotEnd(),
|
|
'total_duration_minutes' => $duration->totalMinutes,
|
|
'buffer_minutes' => $duration->bufferMinutes,
|
|
'warnings' => $duration->warnings,
|
|
]);
|
|
}
|
|
|
|
// ── Timeline: رویدادهای یک نوبت ───────────────────────────────────────────
|
|
|
|
#[Route('/api/v1/appointment/{uuid}/events', methods: ['GET'])]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
public function events(string $uuid, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
|
if ($appointment === null) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
|
}
|
|
if (!$this->canView($appointment, $user)) {
|
|
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
return $this->success($this->eventRepo->findByAppointmentUuid($uuid));
|
|
}
|
|
}
|