getContent(), true); if (!is_array($data)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); } foreach (['service_uuid', 'branch_uuid'] as $field) { if (!is_string($data[$field] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, sprintf('فیلد %s الزامی است', $field), 422, $field); } } if (!is_numeric($data['start'] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد start الزامی است', 422, 'start'); } if (!is_array($data['assignment'] ?? null) || $data['assignment'] === []) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد assignment الزامی است', 422, 'assignment'); } $address = $this->branches->resolve($user, $data['branch_uuid']); $service = $this->requireItem($user, $data['service_uuid']); $selected = []; foreach (($data['item_uuids'] ?? []) as $itemUuid) { if (is_string($itemUuid)) { $selected[] = $this->requireItem($user, $itemUuid); } } $plan = $this->planner->build( $service, $selected, $address, is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null, ); $assignment = $this->resolveAssignment($user, $data['assignment']); $this->assertAssignmentCoversPlan($plan, $assignment); [$entityType, $entityId] = $this->branches->pair($user); $hold = $this->holds->hold( $user, $plan, $assignment, (int) $data['start'], $entityType, $entityId, ); return $this->success($hold->toArray(), 201); } #[Route('/api/v1/appointment-hold/{uuid}', name: 'appointment_hold_release', methods: ['DELETE'])] public function release(#[CurrentUser] User $user, string $uuid): JsonResponse { $hold = $this->requireHold($user, $uuid); if ($hold->isConfirmed()) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این رزرو ثبت نهایی شده و آزاد نمی‌شود', 422); } $this->booking->releaseHold($hold); $this->em->remove($hold); $this->em->flush(); return $this->success(null); } /** * ثبت نهایی. نوبت با همان قرارداد موجود ساخته می‌شود تا مسیرهای فعلی * (`active_slot_key`، رویدادها، پرداخت) دست‌نخورده بمانند — تور ایمنی دوگانه. */ #[Route('/api/v1/appointment-confirm', name: 'appointment_confirm', methods: ['POST'])] public function confirm(#[CurrentUser] User $user, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); if (!is_array($data) || !is_string($data['hold_uuid'] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد hold_uuid الزامی است', 422, 'hold_uuid'); } $hold = $this->requireHold($user, $data['hold_uuid']); $appointment = $this->makeAppointment($user, $hold, $data); $this->em->persist($appointment); $this->em->flush(); $this->booking->confirm($hold, $appointment); return $this->success([ 'appointment_uuid' => $appointment->getUuid(), 'starts_at' => $hold->getStartsAt(), 'ends_at' => $hold->getEndsAt(), 'assignment' => $hold->getPayload()['assignment'] ?? [], ]); } /** * جابه‌جایی: **اول** رزرو جدید، بعد آزادسازی قدیم. * * ترتیب عمدی است — اگر رزرو جدید شکست بخورد، نوبت قدیمی دست‌نخورده می‌ماند و * بیمار بی‌نوبت نمی‌شود. ترتیب برعکس، در بدترین حالت هر دو را از دست می‌داد. */ #[Route('/api/v1/appointment/{uuid}/rebook', name: 'appointment_rebook', methods: ['POST'])] public function rebook(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); if (!is_array($data) || !is_string($data['hold_uuid'] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد hold_uuid الزامی است', 422, 'hold_uuid'); } $appointment = $this->em->getRepository(Appointment::class) ->findOneBy(['uuid' => $uuid]); [$entityType, $entityId] = $this->branches->pair($user); if ($appointment === null || !$this->ownership->belongsToPair($entityType, $entityId, $appointment)) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404); } $hold = $this->requireHold($user, $data['hold_uuid']); // رزرو جدید از قبل گرفته شده؛ اینجا فقط تأیید و سپس آزادسازی قدیم. $this->booking->confirm($hold, $appointment); $released = $this->booking->cancel($appointment); return $this->success([ 'appointment_uuid' => $appointment->getUuid(), 'released_intervals' => $released, 'starts_at' => $hold->getStartsAt(), ]); } /** * هر نیازمندی باید در `assignment` منبع داشته باشد. بدون این، رزرو موقت * می‌توانست نصفِ منابع لازم را بگیرد و بقیه هنگام حضور بیمار کم بیاید. * * @param array> $assignment */ private function assertAssignmentCoversPlan(\App\Appointment\Plan\ValueObject\AppointmentPlan $plan, array $assignment): void { foreach ($plan->segments as $segment) { foreach ($segment->requirements as $requirement) { $given = count($assignment[$requirement->role] ?? []); if ($given < $requirement->count) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, sprintf('برای نقش «%s» منبع کافی انتخاب نشده است', $requirement->roleName), 422, 'assignment', ); } } } } /** * @param array $raw * @return array> */ private function resolveAssignment(User $user, array $raw): array { [$entityType, $entityId] = $this->branches->pair($user); $assignment = []; foreach ($raw as $role => $uuids) { // کلیدِ عددی در JSON یعنی آرایه فرستاده‌اند نه شیء؛ نقش باید نام داشته باشد. if (!is_array($uuids) || !is_string($role) || $role === '') { throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'ساختار assignment نامعتبر است', 422, 'assignment'); } foreach ($uuids as $resourceUuid) { if (!is_string($resourceUuid)) { throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'uuid منبع نامعتبر است', 422, 'assignment'); } $resource = $this->resources->findByUuid($resourceUuid); if ($resource === null || !$this->ownership->belongsToPair($entityType, $entityId, $resource)) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'منبع یافت نشد', 404); } $assignment[$role][] = $resource; } } return $assignment; } /** * نوبت با همان سازندهٔ موجود ساخته می‌شود، پس `active_slot_key` و رویدادها و * مسیر پرداخت دقیقاً مثل قبل کار می‌کنند. اشغال چندمنبعی **کنار** آن می‌نشیند، * نه به‌جایش — تور ایمنی دوگانه‌ای که خودِ تسک خواسته است. * * @param array $data */ private function makeAppointment(User $user, AppointmentHold $hold, array $data): Appointment { if (!is_string($data['doctor_uuid'] ?? null)) { throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'فیلد doctor_uuid الزامی است', 422, 'doctor_uuid'); } $doctor = $this->doctors->findOneBy(['uuid' => $data['doctor_uuid']]); if ($doctor === null) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404); } // بیمار پیش‌فرض خودِ کاربر است؛ منشی می‌تواند برای شخص دیگری ثبت کند. $patient = $user; if (is_string($data['patient_uuid'] ?? null)) { $found = $this->users->findOneBy(['uuid' => $data['patient_uuid']]); if ($found === null) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404); } $patient = $found; } $appointment = new Appointment($doctor, $patient, $hold->getStartsAt(), $hold->getEndsAt()); $appointment->assignTenantPair($hold->getEntityType(), $hold->getEntityId()); return $appointment; } private function requireHold(User $user, string $uuid): AppointmentHold { $hold = $this->holdRepo->findByUuid($uuid); [$entityType, $entityId] = $this->branches->pair($user); // رزرو کاربر دیگر ۴۰۴ می‌گیرد، نه ۴۰۳: وجودش نباید لو برود. if ($hold === null || $hold->getUser()->getId() !== $user->getId() || !$this->ownership->belongsToPair($entityType, $entityId, $hold) ) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'رزرو موقت یافت نشد', 404); } return $hold; } private function requireItem(User $user, string $uuid): ServiceItem { $item = $this->items->findByUuid($uuid); [$entityType, $entityId] = $this->branches->pair($user); if ($item === null || $item->getSection()->getEntityType() !== $entityType || $item->getSection()->getEntityId() !== $entityId ) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); } return $item; } }