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(); // شمارهٔ موبایل نامِ پزشک نیست. دعوت بدون نام قبلاً موبایل را بهعنوان نام // مینشاند و همان رکورد در نتایج عمومی و