repo->findPendingByMobileAndClinic($mobile, $clinic->getId()); if ($existing !== null) { throw new AppException('ERR_CONFLICT_001', 'این شماره قبلاً برای این کلینیک دعوت شده است', 409); } $inv = new ClinicDoctorInvitation($clinic, $invitedBy, $mobile); $inv->setInvitedName($name); $inv->setInvitedSpecialty($specialty); // پروفایل پزشک همین‌جا ساخته می‌شود تا بلافاصله پس از دعوت قابل مشاهده باشد، // ولی بدون رمز عبور و بدون اتصال به کلینیک — اتصال فقط پس از پذیرش انجام می‌شود. $inv->setDoctor($this->provisionDoctor($inv)); $this->repo->save($inv); $this->sendSms($inv, $clinic); return $inv; } public function resend(ClinicDoctorInvitation $inv): void { if (in_array($inv->getStatus(), [ClinicDoctorInvitation::STATUS_REMOVED, ClinicDoctorInvitation::STATUS_ACCEPTED], true)) { throw new AppException('ERR_CONFLICT_001', 'امکان ارسال مجدد دعوتنامه وجود ندارد', 409); } $inv->refresh(); $this->em->flush(); $this->sendSms($inv, $inv->getClinic()); } public function changeStatus(ClinicDoctorInvitation $inv, string $status): void { $allowed = [ ClinicDoctorInvitation::STATUS_PENDING, ClinicDoctorInvitation::STATUS_SUSPENDED, ClinicDoctorInvitation::STATUS_REMOVED, ]; if (!in_array($status, $allowed, true)) { throw new AppException('ERR_VALIDATION_001', 'وضعیت نامعتبر است', 422); } // بازگشت به «در انتظار» فقط وقتی معنا دارد که لینک هم دوباره قابل استفاده شود، // پس توکن تازه می‌شود و پیامک مجدداً ارسال می‌گردد. if ($status === ClinicDoctorInvitation::STATUS_PENDING) { if (in_array($inv->getStatus(), [ClinicDoctorInvitation::STATUS_ACCEPTED, ClinicDoctorInvitation::STATUS_REJECTED], true)) { throw new AppException('ERR_CONFLICT_001', 'دعوتنامه پاسخ‌داده‌شده را نمی‌توان به حالت انتظار برگرداند', 409); } $inv->refresh(); $this->em->flush(); $this->sendSms($inv, $inv->getClinic()); return; } $inv->setStatus($status); $this->em->flush(); } public function delete(ClinicDoctorInvitation $inv): void { $this->em->remove($inv); $this->em->flush(); } public function accept(ClinicDoctorInvitation $inv): void { if (!$inv->isUsable()) { throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410); } $password = $this->em->wrapInTransaction(function () use ($inv): ?string { $doctor = $this->attachDoctorToClinic($inv); $inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED); $inv->markUsed(); return $this->ensureLoginCredentials($doctor->getUser()); }); if ($password !== null) { $this->sendCredentialsSms($inv->getMobile(), $password); } } /** * ترمیم دعوت‌نامه‌ای که قبلاً «پذیرفته‌شده» ثبت شده ولی پروفایل پزشک برایش ساخته نشده است. * برخلاف accept()، وضعیت دعوت‌نامه را دست نمی‌زند. */ public function repairAccepted(ClinicDoctorInvitation $inv): void { $password = $this->em->wrapInTransaction(function () use ($inv): ?string { $doctor = $this->attachDoctorToClinic($inv); return $this->ensureLoginCredentials($doctor->getUser()); }); if ($password !== null) { $this->sendCredentialsSms($inv->getMobile(), $password); } } /** * پروفایل پزشکِ دعوت‌نامه را قطعی می‌کند (در صورت نبود می‌سازد) و به کلینیک متصل می‌کند. */ private function attachDoctorToClinic(ClinicDoctorInvitation $inv): Doctor { $doctor = $this->provisionDoctor($inv); $inv->setDoctor($doctor); if ($doctor->getOwnerStatus() !== 'claimed') { $doctor->transferOwnershipTo($doctor->getUser()); } $clinic = $inv->getClinic(); if (!$clinic->getDoctors()->contains($doctor)) { $clinic->getDoctors()->add($doctor); } return $doctor; } /** * پروفایل پزشک متناظر با شماره موبایل دعوت‌نامه را برمی‌گرداند و در صورت نبود * کاربر و پروفایل را می‌سازد. رمز عبور اینجا ست نمی‌شود — آن کار فقط هنگام پذیرش. */ private function provisionDoctor(ClinicDoctorInvitation $inv): Doctor { $doctor = $inv->getDoctor() ?? $this->doctorRepo->findOneByMobile($inv->getMobile()); if ($doctor !== null) { return $doctor; } $mobile = $inv->getMobile(); // شمارهٔ موبایل نامِ پزشک نیست. دعوت بدون نام قبلاً موبایل را به‌عنوان نام // می‌نشاند و همان رکورد در نتایج عمومی و صفحات سایت منتشر می‌شد. $name = $this->resolveInvitedName($inv->getInvitedName()); $user = $this->userRepo->findOneBy(['mobileNumber' => $mobile]); if ($user === null) { $user = new User($mobile); $user->setRealName($name); $this->em->persist($user); } $user->addRole('ROLE_DOCTOR'); $doctor = $this->doctorRepo->findOneBy(['user' => $user]); if ($doctor === null) { $doctor = new Doctor($user, $this->resolveInvitedName($user->getRealName())); $doctor->setMobileNumber($mobile); $doctor->setOwnerStatus('unclaimed'); $this->em->persist($doctor); } return $doctor; } /** * نام پزشکِ دعوت‌شده. کلینیک اغلب فقط شمارهٔ موبایل را دارد، پس نام واقعی هنوز * ناشناخته است — یک برچسب خنثی می‌نشیند تا وقتی خود پزشک پروفایلش را claim کند. * هرگز موبایل یا مقدار آزمایشی برنمی‌گرداند. */ private function resolveInvitedName(?string $candidate): string { return DisplayName::isPlaceholder($candidate) ? self::UNNAMED_DOCTOR : $candidate; } /** * برای کاربری که هنوز رمز عبور ندارد یک رمز تولید می‌کند تا بتواند وارد شود. * رمز کاربران موجود هرگز بازنویسی نمی‌شود. * * @return string|null رمز خام برای ارسال پیامک، یا null اگر کاربر از قبل رمز داشته */ private function ensureLoginCredentials(User $user): ?string { if ($user->getPasswordHash() !== null) { return null; } $password = bin2hex(random_bytes(4)); $user->setPasswordHash($this->hasher->hashPassword($user, $password)); return $password; } public function reject(ClinicDoctorInvitation $inv): void { if (!$inv->isUsable()) { throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410); } $inv->setStatus(ClinicDoctorInvitation::STATUS_REJECTED); $inv->markUsed(); $this->em->flush(); } private function sendSms(ClinicDoctorInvitation $inv, Clinic $clinic): void { $clinicName = $clinic->getName() ?? 'کلینیک'; $link = rtrim($this->appUrl, '/') . '/i/' . $inv->getToken(); $this->smsService->dispatchTemplate(\App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION, $inv->getMobile(), [ 'clinic' => $clinicName, 'link' => $link, ]); } private function sendCredentialsSms(string $mobile, string $password): void { $this->smsService->dispatchTemplate(\App\Sms\Entity\SmsLog::TAG_PRE_REGISTRATION, $mobile, [ 'username' => $mobile, 'password' => $password, 'link' => rtrim($this->appUrl, '/') . '/admin', ]); } }