apiIr->isConfigured() ? self::METHOD_APIIR_SHAHKAR : self::METHOD_APIIR; // مرحلهٔ ۱ — رزرو اتمیک پروفایل زیر قفل (تراکنش کوتاه، بدون فراخوان خارجی) $claim = $this->em->wrapInTransaction(function () use ($doctor, $user, $nationalCode, $method): DoctorClaimRequest { $locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE); if ($locked->getOwnerStatus() !== 'unclaimed') { throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'این پروفایل قابل تصاحب نیست یا درخواست دیگری در جریان است', 409); } $existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $user]); if ($existing !== null) { throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'شما از قبل یک پروفایل پزشک دارید', 409); } $codeOwner = $this->em->getRepository(User::class)->findOneBy(['nationalCode' => $nationalCode]); if ($codeOwner !== null && $codeOwner->getId() !== $user->getId()) { throw new AppException(ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN, null, 409); } $locked->setOwnerStatus('pending_transfer'); $claim = new DoctorClaimRequest($locked, $user, $nationalCode, $user->getMobileNumber(), $method); $this->em->persist($claim); return $claim; }); // مرحلهٔ ۲ — احراز هویت (خارج از قفل) try { $this->verifyIdentity($doctor, $user, $nationalCode, $birthDateJalali, $firstName, $lastName); } catch (AppException $e) { $this->revert($doctor, $claim, $e->getMessage()); throw $e; } // مرحلهٔ ۳ — نهایی‌سازی اتمیک $this->finalize($doctor, $user, $claim, $nationalCode); // مرحلهٔ ۴ — پیامک خوش‌آمد (غیر بحرانی؛ شکستش claim را باطل نمی‌کند) $this->smsService->dispatchTemplate(SmsLog::TAG_WELCOME, $user->getMobileNumber(), [ 'name' => PersianText::stripDoctorTitle($doctor->getName()), 'site' => 'نوبت۷۲۴', ]); return $claim; } /** انتقال دستی توسط ادمین (پشتیبانی) — بدون استعلام هویت؛ کاربر هدف با موبایل پیدا/ساخته می‌شود. */ public function transferByAdmin(Doctor $doctor, string $mobile): DoctorClaimRequest { $claim = $this->em->wrapInTransaction(function () use ($doctor, $mobile): DoctorClaimRequest { $locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE); if ($locked->getOwnerStatus() === 'claimed') { throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'این پروفایل قبلاً تصاحب شده است', 409); } $userRepo = $this->em->getRepository(User::class); $target = $userRepo->findOneBy(['mobileNumber' => $mobile]); if ($target === null) { $target = new User($mobile); $target->setRealName(PersianText::stripDoctorTitle($locked->getName())); $target->setStatus(1); $this->em->persist($target); } $existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $target]); if ($existing !== null && $existing->getId() !== $locked->getId()) { throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً پروفایل پزشک دیگری دارد', 409); } $locked->setOwnerStatus('pending_transfer'); $claim = new DoctorClaimRequest($locked, $target, '', $mobile, self::METHOD_ADMIN); $this->em->persist($claim); return $claim; }); $this->finalize($doctor, $claim->getUser(), $claim, null); return $claim; } private function verifyIdentity(Doctor $doctor, User $user, string $nationalCode, string $birthDateJalali, string $firstName, string $lastName): void { // تطبیق موبایل ↔ کد ملی (شاهکار). بدون پیکربندی api.ir استعلام ممکن نیست → // موبایلِ OTP-تأییدشدهٔ کاربر لاگین‌شده مبنا می‌ماند و فقط PersonInfo چک می‌شود. if ($this->apiIr->isConfigured() && !$this->apiIr->shahkarMatch($nationalCode, $user->getMobileNumber())) { throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, null, 422); } $person = $this->apiIr->personInfo($nationalCode, $birthDateJalali); if ($person === null || !$person['alive']) { throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'اطلاعات هویتی با سامانهٔ ثبت احوال مطابقت ندارد', 422); } // ورودی کاربر ↔ هویت تأییدشده if (!PersianText::sameName($firstName . ' ' . $lastName, $person['firstName'] . ' ' . $person['lastName'])) { throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'نام واردشده با اطلاعات هویتی مطابقت ندارد', 422); } // هویت تأییدشده ↔ نام پروفایل ایمپورت‌شده از نظام پزشکی $profileName = PersianText::stripDoctorTitle($doctor->getName()); $verifiedName = PersianText::normalize($person['firstName'] . ' ' . $person['lastName']); if ($profileName !== $verifiedName) { throw new AppException(ErrorCodes::ERR_NATIONAL_CODE_MISMATCH, 'نام شما با نام این پروفایل پزشک مطابقت ندارد', 422); } } private function finalize(Doctor $doctor, User $target, DoctorClaimRequest $claim, ?string $nationalCode): void { $surrogate = $this->em->wrapInTransaction(function () use ($doctor, $target, $claim, $nationalCode): ?User { $locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE); if ($locked->getOwnerStatus() !== 'pending_transfer') { throw new AppException(ErrorCodes::ERR_CONFLICT_001, 'وضعیت پروفایل در این میان تغییر کرده است', 409); } $surrogate = $locked->getUser(); if ($nationalCode !== null && $nationalCode !== '') { $target->setNationalCode($nationalCode); $target->setNationalCodeVerified(true); } $target->addRole('ROLE_DOCTOR'); $locked->transferOwnershipTo($target); $claim->markCompleted(); return $surrogate; }); // حذف امن جانشین — پس از flush انتقال، تا شمارش پزشکانِ متصل قطعی باشد if ($surrogate !== null && $surrogate->getId() !== $target->getId() && $surrogate->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR) && $this->em->getRepository(Doctor::class)->count(['user' => $surrogate]) === 0) { $this->em->remove($surrogate); $this->em->flush(); } $this->logger->info('doctor profile claimed', [ 'claim_uuid' => $claim->getUuid(), 'doctor_uuid' => $doctor->getUuid(), 'user_id' => $target->getId(), 'method' => $claim->getVerificationMethod(), ]); } private function revert(Doctor $doctor, DoctorClaimRequest $claim, string $reason): void { $this->em->wrapInTransaction(function () use ($doctor, $claim, $reason): void { $locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE); if ($locked->getOwnerStatus() === 'pending_transfer') { $locked->setOwnerStatus('unclaimed'); } $claim->markFailed($reason); }); } }