doImport($data, $importedBy); } catch (UniqueConstraintViolationException) { // برندهٔ هم‌زمانی رکورد را همین الان ساخته (قید uniq_doctors_source_code). // EM پس از این خطا بسته است — reset و اجرای مجدد که این‌بار مسیر update را می‌رود. $this->registry->resetManager(); return $this->doImport($data, $importedBy); } } private function doImport(array $data, User $importedBy): DoctorImportResult { return $this->em->wrapInTransaction(function () use ($data, $importedBy): DoctorImportResult { // نامِ نظام پزشکی پیشوند «دکتر» دارد؛ کنوانسیون پنل نام بدون پیشوند است // (UI خودش «دکتر» را جلو می‌گذارد). حذف پیشوند + normalize فارسی. $name = \App\Shared\Util\PersianText::stripDoctorTitle((string) $data['name']); $code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode'])); $source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc'; $ref = $data['source_ref'] ?? $data['profile_url'] ?? null; // شناسهٔ authoritative پروفایل مبدأ؛ کلید اصلی idempotency (بر کد مقدم است). $profileId = SourceProfileId::fromRef($ref); $doctorRepo = $this->em->getRepository(Doctor::class); $userRepo = $this->em->getRepository(User::class); // idempotency: اول با شناسهٔ پروفایل (پایدار حتی اگر کد عوض شده باشد)، // سپس با (source, medical_system_code). یک پروفایل هرگز دو رکورد نمی‌سازد. $doctor = $profileId !== null ? $doctorRepo->findOneBy(['source' => $source, 'sourceProfileId' => $profileId]) : null; $doctor ??= $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]); $created = false; // پروفایل تصاحب‌شده را با ایمپورت مجدد بازنویسی نکن (مالک واقعی اولویت دارد) if ($doctor !== null && $doctor->getOwnerStatus() === 'claimed') { return new DoctorImportResult($doctor, false, 'claimed'); } if ($doctor === null) { // جلوگیری از تکرار بین منابع: اگر همین کد نظام پزشکی با هر source دیگری // (مثلاً ثبت دستی) وجود دارد، ایمپورت نه می‌سازد و نه بازنویسی می‌کند. $existing = $doctorRepo->findOneBy(['medicalSystemCode' => $code]); if ($existing !== null) { return new DoctorImportResult($existing, false, 'duplicate'); } } if ($doctor === null) { // کاربر جانشینِ یکتا و غیرفعال؛ شناسهٔ مصنوعی قطعی از شناسهٔ پایدارِ پروفایل // (یا کد، اگر پروفایل شناسه ندارد) تا با تغییر کد، جانشین تکراری ساخته نشود. $identity = $profileId ?? $code; $synthetic = 'imp_' . substr(md5($source . ':' . $identity), 0, 14); // ≤ ۱۸ کاراکتر، ASCII، یکتا $user = $userRepo->findOneBy(['mobileNumber' => $synthetic]); if ($user === null) { $user = new User($synthetic); $user->setRealName($name); $user->setStatus(0); // جانشین: هرگز لاگین نمی‌کند $user->addRole(self::ROLE_UNCLAIMED_DOCTOR); $this->em->persist($user); } $doctor = new Doctor($user, $name); $doctor->setSource($source); $doctor->setOwnerStatus('unclaimed'); $doctor->setActiveDoctorAppointment(true); // پزشک ایمپورت‌شده فعال باشد $created = true; } // backfill: جانشین‌های ایمپورت‌شده پیش از افزودن نقش marker، در ایمپورت مجدد نقش می‌گیرند $surrogate = $doctor->getUser(); if (!$created && str_starts_with($surrogate->getMobileNumber(), 'imp_') && !$surrogate->hasRole(self::ROLE_UNCLAIMED_DOCTOR)) { $surrogate->addRole(self::ROLE_UNCLAIMED_DOCTOR); } // فیلدهای مشترک $doctor->setName($name); $doctor->setMedicalSystemCode($code); $doctor->setManagedBy($importedBy->getId()); if (array_key_exists('source_ref', $data) || array_key_exists('profile_url', $data)) { $doctor->setSourceRef($ref); $doctor->setSourceProfileId($profileId); } if (!empty($data['gender'])) $doctor->setGender($data['gender']); if (!empty($data['degree'])) $doctor->setDegree($data['degree']); if (array_key_exists('info', $data)) $doctor->setInfo($data['info']); // روابط بر پایهٔ شناسه‌های مرجع (تخصص/استان/شهر) $specialtyIds = $data['specialties'] ?? null; if (is_array($specialtyIds)) { $specialtyIds = $this->em->getRepository(Specialty::class) ->expandWithAncestors(array_map('intval', $specialtyIds)); } $this->syncRefCollection($doctor->getSpecialties(), $specialtyIds, Specialty::class); $this->syncRefCollection($doctor->getProvinces(), $data['states'] ?? null, Province::class); $this->syncRefCollection($doctor->getCities(), $data['cities'] ?? null, City::class); $this->ensureDefaultAddress($doctor, $name, $data); $this->em->persist($doctor); $this->em->flush(); return new DoctorImportResult($doctor, $created); }); } /** * هر پزشک ایمپورت‌شده باید دست‌کم یک آدرس «مطب دکتر …» با شهر و استان داشته باشد. * اگر آدرسی ندارد می‌سازد؛ فیلدهای خالیِ آدرس موجود را backfill می‌کند و * مقادیر واردشده توسط کاربر را بازنویسی نمی‌کند. */ private function ensureDefaultAddress(Doctor $doctor, string $name, array $data): void { $city = !empty($data['cities']) ? $this->em->getRepository(City::class)->find((int) $data['cities'][0]) : null; $province = !empty($data['states']) ? $this->em->getRepository(Province::class)->find((int) $data['states'][0]) : null; $address = $doctor->getAddresses()->first() ?: null; if ($address === null) { $address = DoctorAddress::forDoctor($doctor); $doctor->getAddresses()->add($address); $this->em->persist($address); } if ($address->getName() === null || $address->getName() === '') { $address->setName('مطب دکتر ' . $name); } if ($address->getCity() === null && $city !== null) { $address->setCity($city); } if ($address->getProvince() === null && $province !== null) { $address->setProvince($province); } } /** * یک مجموعهٔ ManyToMany پزشک را با آرایه‌ای از شناسه‌های مرجع همگام می‌کند. * اگر $ids null باشد دست نمی‌خورد؛ اگر آرایه باشد، پاک و از نو پر می‌شود. */ private function syncRefCollection(\Doctrine\Common\Collections\Collection $col, ?array $ids, string $class): void { if ($ids === null) { return; } $col->clear(); foreach ($ids as $id) { $ref = $this->em->getRepository($class)->find((int) $id); if ($ref !== null && !$col->contains($ref)) { $col->add($ref); } } } }