feat(doctor): complete IRIMC import feature — claim flow, least-privilege importer, unique import key

- Extract import logic from AdminApiController into DoctorImportService
  (thin DoctorImportController keeps the same route/contract)
- Surrogate users get marker role ROLE_UNCLAIMED_DOCTOR (+ backfill command
  app:doctors:backfill-surrogate-role) enabling safe deletion after claim
- DB-level UNIQUE (source, medical_system_code) + concurrent-import retry
- Doctor profile claim flow (climed.md): shahkar + PersonInfo identity checks
  via existing ApiIrService, Persian name normalization (PersianText),
  pessimistic-lock race protection, DoctorClaimRequest audit table
  (national code hashed, mobile masked), doctor_claim rate limiter,
  public claim-info endpoint, welcome SMS
- Admin support tools: manual transfer endpoint + paginated doctor-claims
  audit list + owner_status filter/fields in admin doctors list
- Least privilege: system owner now gets ROLE_IMPORTER (ROLE_ADMIN stripped),
  import endpoint accepts ADMIN|IMPORTER, isStaff includes IMPORTER
- Headless crawler login: X-Service-Token header bypasses captcha only
  (rate limit + password checks intact; empty env = no bypass)
- docs: doctor-claim.md (new), doctor-import.md, admin.md, doctor.md
- tests: DoctorImportTest (6), DoctorClaimTest (11), PersianTextTest (5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-11 11:39:15 +03:30
co-authored by Claude Opus 4.8
parent 83c872bb78
commit af125572c9
29 changed files with 1944 additions and 303 deletions
+6 -125
View File
@@ -7,10 +7,8 @@ use App\Shared\Constant\ErrorCodes;
use App\Appointment\Repository\SlotTakenException;
use App\Auth\Entity\User;
use App\Shared\Service\InputValidator;
use App\Location\Entity\City;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Location\Entity\Province;
use App\Specialty\Entity\Specialty;
use App\Payment\Entity\Payment;
use App\Rating\Entity\Comment;
@@ -326,6 +324,11 @@ class AdminApiController extends BaseController
$where[] = 'd.gender = :gender';
$params['gender'] = $gender;
}
$ownerStatus = trim((string) $request->query->get('owner_status', ''));
if (in_array($ownerStatus, ['claimed', 'unclaimed', 'pending_transfer'], true)) {
$where[] = 'd.owner_status = :ownerStatus';
$params['ownerStatus'] = $ownerStatus;
}
if ($specId > 0) {
$where[] = 'EXISTS (SELECT 1 FROM doctor_specialties ds2 WHERE ds2.doctor_id = d.id AND ds2.specialty_id = :specId)';
$params['specId'] = $specId;
@@ -348,6 +351,7 @@ class AdminApiController extends BaseController
"SELECT d.id, d.uuid, d.name, d.gender, d.degree, d.medical_system_code,
d.mobile_number as doctor_mobile, d.active_doctor_appointment,
d.doctor_rate, d.doctor_rate_percentage, d.images, d.created_at,
d.owner_status, d.source,
u.mobile_number as user_mobile, u.email
FROM doctors d JOIN users u ON u.id = d.user_id
WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset",
@@ -444,129 +448,6 @@ class AdminApiController extends BaseController
return $this->success(['uuid' => $doctor->getUuid()], 201);
}
/**
* ایمپورت یک پزشک از سازمان نظام پزشکی (بدون شماره موبایل).
*
* برخلاف createDoctor، این اندپوینت موبایل نمی‌خواهد: برای هر پزشک یک «کاربر
* جانشین» غیرفعال با شناسهٔ مصنوعی ساخته می‌شود و پروفایل در وضعیت unclaimed
* ذخیره می‌گردد تا بعداً به پزشک واقعی منتقل شود. idempotent بر پایهٔ
* (source, medical_system_code): اجرای مجدد، رکورد موجود را به‌روزرسانی می‌کند.
*/
#[OA\Post(
path: '/api/v1/admin/doctors/import',
summary: 'Import an IRIMC doctor without a mobile number (unclaimed profile)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'medical_system_code'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'medical_system_code', type: 'string'),
new OA\Property(property: 'source', type: 'string', default: 'irimc'),
new OA\Property(property: 'source_ref', type: 'string', nullable: true, description: 'profile_url یا شناسهٔ مبدأ'),
new OA\Property(property: 'gender', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'states', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'cities', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Doctor imported (created)'),
new OA\Response(response: 200, description: 'Doctor already existed (updated or skipped)'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[Route('/api/v1/admin/doctors/import', methods: ['POST'])]
public function importDoctor(Request $request, #[CurrentUser] User $admin): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim((string) ($data['name'] ?? ''));
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode'] ?? ''));
$source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc';
if ($name === '') {
return $this->error(ErrorCodes::VALIDATION, 'نام الزامی است', 422, 'name');
}
if ($code === '') {
return $this->error(ErrorCodes::VALIDATION, 'کد نظام پزشکی الزامی است', 422, 'medical_system_code');
}
$doctorRepo = $this->em->getRepository(Doctor::class);
$userRepo = $this->em->getRepository(User::class);
// idempotency: همان پزشکِ منبع → به‌روزرسانی، نه ساخت تکراری
$doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]);
$created = false;
// پروفایل تصاحب‌شده را با ایمپورت مجدد بازنویسی نکن (مالک واقعی اولویت دارد)
if ($doctor !== null && $doctor->getOwnerStatus() === 'claimed') {
return $this->success(['uuid' => $doctor->getUuid(), 'created' => false, 'skipped' => 'claimed']);
}
if ($doctor === null) {
// کاربر جانشینِ یکتا و غیرفعال؛ شناسهٔ مصنوعی قطعی از روی کد نظام پزشکی
$synthetic = 'imp_' . substr(md5($source . ':' . $code), 0, 14); // ≤ ۱۸ کاراکتر، ASCII، یکتا
$user = $userRepo->findOneBy(['mobileNumber' => $synthetic]);
if ($user === null) {
$user = new User($synthetic);
$user->setRealName($name);
$user->setStatus(0); // جانشین: هرگز لاگین نمی‌کند
$this->em->persist($user);
}
$doctor = new Doctor($user, $name);
$doctor->setSource($source);
$doctor->setOwnerStatus('unclaimed');
$doctor->setActiveDoctorAppointment(false); // تا مالک واقعی برنامهٔ کاری بسازد
$created = true;
}
// فیلدهای مشترک
$doctor->setName($name);
$doctor->setMedicalSystemCode($code);
$doctor->setManagedBy($admin->getId());
if (array_key_exists('source_ref', $data) || array_key_exists('profile_url', $data)) {
$doctor->setSourceRef($data['source_ref'] ?? $data['profile_url'] ?? null);
}
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']);
// روابط بر پایهٔ شناسه‌های مرجع (تخصص/استان/شهر)
$this->syncRefCollection($doctor->getSpecialties(), $data['specialties'] ?? null, Specialty::class);
$this->syncRefCollection($doctor->getProvinces(), $data['states'] ?? null, Province::class);
$this->syncRefCollection($doctor->getCities(), $data['cities'] ?? null, City::class);
$this->em->persist($doctor);
$this->em->flush();
return $this->success(
['uuid' => $doctor->getUuid(), 'created' => $created],
$created ? 201 : 200
);
}
/**
* یک مجموعهٔ 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);
}
}
}
// ── Clinics ───────────────────────────────────────────────────────────────
#[Route('/api/v1/admin/clinics', methods: ['GET'])]
+9 -3
View File
@@ -70,11 +70,17 @@ class SystemOwnerCommand extends Command
}
}
// least privilege: کاربر سیستمی فقط ROLE_IMPORTER می‌گیرد (دسترسی فقط به اندپوینت
// ایمپورت پزشک). اگر از نسخه‌های قبلی ROLE_ADMIN دارد، حذف می‌شود.
$roles = $user->getRoles();
if (!in_array('ROLE_ADMIN', $roles, true)) {
$roles[] = 'ROLE_ADMIN';
$user->setRoles(array_values(array_unique($roles)));
if (in_array('ROLE_ADMIN', $roles, true)) {
$roles = array_values(array_diff($roles, ['ROLE_ADMIN']));
$io->note('ROLE_ADMIN از کاربر سیستمی حذف شد (least privilege).');
}
if (!in_array('ROLE_IMPORTER', $roles, true)) {
$roles[] = 'ROLE_IMPORTER';
}
$user->setRoles(array_values(array_unique($roles)));
if ($password !== null) {
$user->setPasswordHash($this->hasher->hashPassword($user, (string) $password));
+2 -1
View File
@@ -121,6 +121,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this->hasRole('ROLE_DOCTOR')
|| $this->hasRole('ROLE_CLINIC')
|| $this->hasRole('ROLE_SECRETARY')
|| $this->hasRole('ROLE_ADMIN');
|| $this->hasRole('ROLE_ADMIN')
|| $this->hasRole('ROLE_IMPORTER'); // کاربر سیستمی کرالر — لاگین با رمز؛ دسترسی فقط اندپوینت ایمپورت
}
}
+15 -1
View File
@@ -30,8 +30,20 @@ class PasswordAuthenticator extends AbstractAuthenticator
private readonly RateLimiterFactory $loginLimiter,
private readonly CaptchaGuard $captcha,
private readonly int $refreshTokenTtl = 2592000,
private readonly ?string $crawlerServiceToken = null,
) {}
/**
* لاگین سرویسی کرالر: هدر X-Service-Token با مقدار env CRAWLER_SERVICE_TOKEN
* فقط کپچا را دور می‌زند — rate limit و اعتبارسنجی رمز دست‌نخورده می‌مانند.
* env خالی = هیچ bypass (secure by default).
*/
private function isTrustedServiceLogin(Request $request): bool
{
return ($this->crawlerServiceToken ?? '') !== ''
&& hash_equals($this->crawlerServiceToken, (string) $request->headers->get('X-Service-Token', ''));
}
public function supports(Request $request): ?bool
{
return $request->getPathInfo() === '/api/v1/user/login'
@@ -46,7 +58,9 @@ class PasswordAuthenticator extends AbstractAuthenticator
}
// AppException را ExceptionSubscriber به پاسخ 422 با ERR_CAPTCHA_001 تبدیل می‌کند.
$this->captcha->assertValid($request);
if (!$this->isTrustedServiceLogin($request)) {
$this->captcha->assertValid($request);
}
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim($data['mobile_number'] ?? '');
@@ -0,0 +1,76 @@
<?php
namespace App\Doctor\Command;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Doctor\Service\DoctorImportService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* backfill یک‌بارمصرف: نقش ROLE_UNCLAIMED_DOCTOR برای کاربران جانشینِ ایمپورت
* (mobile با پیشوند imp_، غیرفعال، متصل به پزشک unclaimed) که پیش از افزودن
* این نقش ساخته شده‌اند. غیرمخرب؛ با --dry-run فقط گزارش می‌دهد.
*
* php bin/console app:doctors:backfill-surrogate-role --dry-run
* php bin/console app:doctors:backfill-surrogate-role
*/
#[AsCommand(
name: 'app:doctors:backfill-surrogate-role',
description: 'Add ROLE_UNCLAIMED_DOCTOR to legacy IRIMC surrogate users (idempotent, supports --dry-run)',
)]
class BackfillSurrogateRoleCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $em)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
/** @var User[] $surrogates */
$surrogates = $this->em->createQueryBuilder()
->select('u')
->from(User::class, 'u')
->join(Doctor::class, 'd', 'WITH', 'd.user = u')
->where("u.mobileNumber LIKE 'imp\\_%'")
->andWhere('u.status = 0')
->andWhere("d.ownerStatus = 'unclaimed'")
->getQuery()
->getResult();
$updated = 0;
foreach ($surrogates as $user) {
if ($user->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)) {
continue;
}
$updated++;
$io->text(sprintf('%s %s', $dryRun ? '[dry-run]' : '[update]', $user->getMobileNumber()));
if (!$dryRun) {
$user->addRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR);
}
}
if (!$dryRun && $updated > 0) {
$this->em->flush();
}
$io->success(sprintf('%d surrogate(s) %s (of %d scanned)', $updated, $dryRun ? 'would be updated' : 'updated', count($surrogates)));
return Command::SUCCESS;
}
}
@@ -0,0 +1,198 @@
<?php
namespace App\Doctor\Controller;
use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorRepository;
use App\Doctor\Service\DoctorClaimService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Doctors')]
class DoctorClaimController extends BaseController
{
public function __construct(
private readonly DoctorRepository $doctorRepo,
private readonly DoctorClaimService $claimService,
private readonly RateLimiterFactory $doctorClaimLimiter,
private readonly \App\Doctor\Repository\DoctorClaimRequestRepository $claimRepo,
) {}
#[OA\Get(
path: '/api/v1/doctor/{uuid}/claim-info',
summary: 'Whether this doctor profile can be claimed (public, renders the claim button)',
responses: [
new OA\Response(response: 200, description: '{ claimable, owner_status }'),
new OA\Response(response: 404, description: 'Doctor not found'),
]
)]
#[Route('/api/v1/doctor/{uuid}/claim-info', methods: ['GET'])]
public function claimInfo(string $uuid): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
}
return $this->success([
'claimable' => $doctor->getOwnerStatus() === 'unclaimed',
'owner_status' => $doctor->getOwnerStatus(),
]);
}
#[OA\Post(
path: '/api/v1/doctor/{uuid}/claim',
summary: 'Claim an unclaimed (IRIMC-imported) doctor profile after identity verification',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['national_code', 'birth_date', 'first_name', 'last_name'],
properties: [
new OA\Property(property: 'national_code', type: 'string', example: '0010007700'),
new OA\Property(property: 'birth_date', type: 'string', description: 'شمسی Y/m/d', example: '1371/1/1'),
new OA\Property(property: 'first_name', type: 'string'),
new OA\Property(property: 'last_name', type: 'string'),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Claimed'),
new OA\Response(response: 409, description: 'Not claimable / user already owns a doctor'),
new OA\Response(response: 422, description: 'Validation or identity mismatch'),
new OA\Response(response: 429, description: 'Rate limited'),
new OA\Response(response: 502, description: 'Identity provider unavailable'),
]
)]
#[Route('/api/v1/doctor/{uuid}/claim', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function claim(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$limiter = $this->doctorClaimLimiter->create('claim_' . $user->getId() . '_' . $uuid);
$limit = $limiter->consume();
if (!$limit->isAccepted()) {
throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp() - time());
}
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$nationalCode = preg_replace('/\D/', '', \App\Shared\Util\PersianText::normalize((string) ($data['national_code'] ?? '')));
$birthDate = trim(\App\Shared\Util\PersianText::normalize((string) ($data['birth_date'] ?? '')));
$firstName = trim((string) ($data['first_name'] ?? ''));
$lastName = trim((string) ($data['last_name'] ?? ''));
if (!preg_match('/^\d{10}$/', $nationalCode)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'national_code');
}
if (!preg_match('~^1[34]\d{2}/\d{1,2}/\d{1,2}$~', $birthDate)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ تولد نامعتبر است (مثال: 1371/1/1)', 422, 'birth_date');
}
if ($firstName === '' || $lastName === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام و نام خانوادگی الزامی است', 422);
}
$claim = $this->claimService->claim($doctor, $user, $nationalCode, $birthDate, $firstName, $lastName);
return $this->success([
'status' => 'claimed',
'claim' => ['uuid' => $claim->getUuid()],
'doctor' => ['uuid' => $doctor->getUuid(), 'name' => $doctor->getName()],
]);
}
#[OA\Post(
path: '/api/v1/admin/doctors/{uuid}/transfer',
summary: 'Manually transfer an unclaimed doctor profile to a real user (admin support tool)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['mobile'],
properties: [new OA\Property(property: 'mobile', type: 'string', example: '09121234567')]
)
),
responses: [
new OA\Response(response: 200, description: 'Transferred'),
new OA\Response(response: 404, description: 'Doctor not found'),
new OA\Response(response: 409, description: 'Already claimed / target user owns another doctor'),
new OA\Response(response: 422, description: 'Invalid mobile'),
]
)]
#[Route('/api/v1/admin/doctors/{uuid}/transfer', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function transfer(string $uuid, Request $request): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim((string) ($data['mobile'] ?? ''));
if (!preg_match('/^09\d{9}$/', $mobile)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile');
}
$claim = $this->claimService->transferByAdmin($doctor, $mobile);
return $this->success([
'uuid' => $doctor->getUuid(),
'owner_status' => $doctor->getOwnerStatus(),
'user_mobile' => $mobile,
'claim' => ['uuid' => $claim->getUuid()],
]);
}
#[OA\Get(
path: '/api/v1/admin/doctor-claims',
summary: 'Paginated audit list of doctor profile claim requests',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'status', in: 'query', schema: new OA\Schema(type: 'string', enum: ['pending', 'completed', 'failed'])),
new OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer', default: 20)),
],
responses: [new OA\Response(response: 200, description: 'Paginated claim requests')]
)]
#[Route('/api/v1/admin/doctor-claims', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function claimsList(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$status = trim((string) $request->query->get('status', ''));
$qb = $this->claimRepo->createQueryBuilder('c')
->orderBy('c.createdAt', 'DESC');
if (in_array($status, [\App\Doctor\Entity\DoctorClaimRequest::STATUS_PENDING, \App\Doctor\Entity\DoctorClaimRequest::STATUS_COMPLETED, \App\Doctor\Entity\DoctorClaimRequest::STATUS_FAILED], true)) {
$qb->andWhere('c.status = :status')->setParameter('status', $status);
}
$total = (int) (clone $qb)->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getResult();
return $this->paginated(
array_map(fn(\App\Doctor\Entity\DoctorClaimRequest $c) => $c->toArray(), $items),
$total,
$page,
$limit
);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Doctor\Controller;
use App\Auth\Entity\User;
use App\Doctor\Service\DoctorImportService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* اندپوینت ایمپورت پزشک IRIMC — جدا از AdminApiController تا کاربر سیستمیِ
* کرالر با نقش حداقلی ROLE_IMPORTER (بدون دسترسی به بقیهٔ پنل ادمین) بتواند
* فقط همین عمل را انجام دهد (least privilege). مسیر برای سازگاری با کرالر و
* مستندات، همان مسیر قبلی مانده است.
*/
#[OA\Tag(name: 'Doctors')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class DoctorImportController extends BaseController
{
public function __construct(
private readonly DoctorImportService $importService,
) {}
#[OA\Post(
path: '/api/v1/admin/doctors/import',
summary: 'Import an IRIMC doctor without a mobile number (unclaimed profile)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'medical_system_code'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'medical_system_code', type: 'string'),
new OA\Property(property: 'source', type: 'string', default: 'irimc'),
new OA\Property(property: 'source_ref', type: 'string', nullable: true, description: 'profile_url یا شناسهٔ مبدأ'),
new OA\Property(property: 'gender', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'states', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'cities', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Doctor imported (created)'),
new OA\Response(response: 200, description: 'Doctor already existed (updated or skipped)'),
new OA\Response(response: 403, description: 'Requires ROLE_ADMIN or ROLE_IMPORTER'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[Route('/api/v1/admin/doctors/import', methods: ['POST'])]
public function import(Request $request, #[CurrentUser] User $user): JsonResponse
{
if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_IMPORTER')) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی به این منبع مجاز نیست', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$name = trim((string) ($data['name'] ?? ''));
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode'] ?? ''));
if ($name === '') {
return $this->error(ErrorCodes::VALIDATION, 'نام الزامی است', 422, 'name');
}
if ($code === '') {
return $this->error(ErrorCodes::VALIDATION, 'کد نظام پزشکی الزامی است', 422, 'medical_system_code');
}
$result = $this->importService->import($data, $user);
$payload = ['uuid' => $result->doctor->getUuid(), 'created' => $result->created];
if ($result->skipped !== null) {
$payload['skipped'] = $result->skipped;
}
return $this->success($payload, $result->created ? 201 : 200);
}
}
+6 -2
View File
@@ -17,7 +17,9 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: DoctorRepository::class)]
#[ORM\Table(name: 'doctors')]
#[ORM\UniqueConstraint(name: 'idx_doctors_user', columns: ['user_id'])]
#[ORM\UniqueConstraint(name: 'uniq_doctors_source_code', columns: ['source', 'medical_system_code'])]
#[ORM\Index(columns: ['active_doctor_appointment'], name: 'idx_doctors_active')]
#[ORM\Index(columns: ['owner_status'], name: 'idx_doctors_owner')]
class Doctor
{
public const DEGREES = ['expert', 'general', 'specialist', 'subspecialistplus'];
@@ -79,11 +81,11 @@ class Doctor
// ── Profile ownership (IRIMC import) ───────────────────────────────────────
// owner_status: claimed | unclaimed | pending_transfer
#[ORM\Column(name: 'owner_status', type: 'string', length: 20)]
#[ORM\Column(name: 'owner_status', type: 'string', length: 20, options: ['default' => 'claimed'])]
private string $ownerStatus = 'claimed';
// source: manual | irimc
#[ORM\Column(type: 'string', length: 20)]
#[ORM\Column(type: 'string', length: 20, options: ['default' => 'manual'])]
private string $source = 'manual';
// شناسه رکورد مبدأ (profile_url یا کد نظام پزشکی) برای idempotency و ممیزی
@@ -521,6 +523,7 @@ class Doctor
'free_turn' => $sf['free_turn'],
'hours_of_work' => $sf['hours_of_work'],
'active' => $this->activeDoctorAppointment && $sf['has_schedule'],
'owner_status' => $this->ownerStatus,
];
}
@@ -553,6 +556,7 @@ class Doctor
], $this->services->toArray()),
'satisfaction' => (string) $this->doctorRatePercentage,
'point' => (string) $this->doctorRate,
'owner_status' => $this->ownerStatus,
'free_turn' => $sf['free_turn'],
'hours_of_work' => $sf['hours_of_work'],
'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()),
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Doctor\Entity;
use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorClaimRequestRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* رکورد ممیزی درخواست تصاحب پروفایل پزشک (claim).
*
* دادهٔ حساس خام ذخیره نمی‌شود: کد ملی فقط hash و موبایل فقط mask.
*/
#[ORM\Entity(repositoryClass: DoctorClaimRequestRepository::class)]
#[ORM\Table(name: 'doctor_claim_requests')]
#[ORM\Index(columns: ['doctor_id', 'status'], name: 'idx_claim_doctor_status')]
class DoctorClaimRequest
{
public const STATUS_PENDING = 'pending';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', nullable: true, onDelete: 'SET NULL')]
private ?User $user;
#[ORM\Column(type: 'string', length: 20)]
private string $status = self::STATUS_PENDING;
#[ORM\Column(name: 'national_code_hash', type: 'string', length: 64)]
private string $nationalCodeHash;
#[ORM\Column(name: 'mobile_masked', type: 'string', length: 15)]
private string $mobileMasked;
#[ORM\Column(name: 'verification_method', type: 'string', length: 40)]
private string $verificationMethod;
#[ORM\Column(name: 'failure_reason', type: 'string', length: 100, nullable: true)]
private ?string $failureReason = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'completed_at', type: 'integer', nullable: true)]
private ?int $completedAt = null;
public function __construct(Doctor $doctor, ?User $user, string $nationalCode, string $mobile, string $verificationMethod)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->doctor = $doctor;
$this->user = $user;
$this->nationalCodeHash = hash('sha256', $nationalCode);
$this->mobileMasked = self::maskMobile($mobile);
$this->verificationMethod = $verificationMethod;
$this->createdAt = time();
}
public static function maskMobile(string $mobile): string
{
return strlen($mobile) >= 7
? substr($mobile, 0, 4) . '***' . substr($mobile, -4)
: '***';
}
public function markCompleted(): void
{
$this->status = self::STATUS_COMPLETED;
$this->completedAt = time();
}
public function markFailed(string $reason): void
{
$this->status = self::STATUS_FAILED;
$this->failureReason = mb_substr($reason, 0, 100);
$this->completedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getUser(): ?User { return $this->user; }
public function getStatus(): string { return $this->status; }
public function getFailureReason(): ?string { return $this->failureReason; }
public function getVerificationMethod(): string { return $this->verificationMethod; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getCompletedAt(): ?int { return $this->completedAt; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'status' => $this->status,
'doctor' => ['uuid' => $this->doctor->getUuid(), 'name' => $this->doctor->getName()],
'mobile_masked' => $this->mobileMasked,
'verification_method' => $this->verificationMethod,
'failure_reason' => $this->failureReason,
'created_at' => $this->createdAt,
'completed_at' => $this->completedAt,
];
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Doctor\Repository;
use App\Doctor\Entity\DoctorClaimRequest;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class DoctorClaimRequestRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, DoctorClaimRequest::class);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Doctor\Service;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorClaimRequest;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Shared\Service\ApiIrService;
use App\Shared\Util\PersianText;
use App\Sms\Entity\SmsLog;
use App\Sms\Service\SmsService;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* تصاحب پروفایل پزشک ایمپورت‌شده (unclaimed) توسط پزشک واقعی.
*
* دو مسیر: self-claim (احراز هویت API.ir: شاهکار + PersonInfo + تطبیق نام) و
* انتقال دستی ادمین. نهایی‌سازی هر دو مسیر یکی است: اتصال کاربر واقعی، نقش
* ROLE_DOCTOR، حذف امن کاربر جانشین، و ثبت رکورد ممیزی DoctorClaimRequest.
*
* ضد race: تغییر وضعیت unclaimed→pending_transfer زیر قفل PESSIMISTIC_WRITE
* انجام می‌شود؛ درخواست هم‌زمان دوم 409 می‌گیرد. فراخوانی خارجی هرگز داخل قفل نیست.
*/
class DoctorClaimService
{
public const METHOD_APIIR = 'apiir_personinfo';
public const METHOD_APIIR_SHAHKAR = 'apiir_personinfo+shahkar';
public const METHOD_ADMIN = 'admin_manual';
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ApiIrService $apiIr,
private readonly SmsService $smsService,
private readonly LoggerInterface $logger,
) {}
public function claim(Doctor $doctor, User $user, string $nationalCode, string $birthDateJalali, string $firstName, string $lastName): DoctorClaimRequest
{
$method = $this->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);
});
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Doctor\Service;
use App\Doctor\Entity\Doctor;
final class DoctorImportResult
{
public function __construct(
public readonly Doctor $doctor,
public readonly bool $created,
public readonly ?string $skipped = null,
) {}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Doctor\Service;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Specialty\Entity\Specialty;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
/**
* ایمپورت پزشک از سازمان نظام پزشکی (IRIMC) — منطق دامنه، جدا از کنترلر.
*
* برخلاف ساخت عادی پزشک، موبایل لازم نیست: برای هر پزشک یک «کاربر جانشین»
* غیرفعال با شناسهٔ مصنوعی ساخته می‌شود و پروفایل در وضعیت unclaimed ذخیره
* می‌گردد تا بعداً به پزشک واقعی منتقل شود. idempotent بر پایهٔ
* (source, medical_system_code): اجرای مجدد، رکورد موجود را به‌روزرسانی می‌کند
* و پروفایل claimed هرگز بازنویسی نمی‌شود (مالک واقعی اولویت دارد).
*/
class DoctorImportService
{
/** نقش marker کاربر جانشین — permission نمی‌دهد؛ مبنای شناسایی و حذف امن پس از claim است. */
public const ROLE_UNCLAIMED_DOCTOR = 'ROLE_UNCLAIMED_DOCTOR';
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ManagerRegistry $registry,
) {}
/** @param array $data بدنهٔ validated (name و medical_system_code غیرخالی). */
public function import(array $data, User $importedBy): DoctorImportResult
{
try {
return $this->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 {
$name = trim((string) $data['name']);
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode']));
$source = trim((string) ($data['source'] ?? 'irimc')) ?: 'irimc';
$doctorRepo = $this->em->getRepository(Doctor::class);
$userRepo = $this->em->getRepository(User::class);
// idempotency: همان پزشکِ منبع → به‌روزرسانی، نه ساخت تکراری
$doctor = $doctorRepo->findOneBy(['source' => $source, 'medicalSystemCode' => $code]);
$created = false;
// پروفایل تصاحب‌شده را با ایمپورت مجدد بازنویسی نکن (مالک واقعی اولویت دارد)
if ($doctor !== null && $doctor->getOwnerStatus() === 'claimed') {
return new DoctorImportResult($doctor, false, 'claimed');
}
if ($doctor === null) {
// کاربر جانشینِ یکتا و غیرفعال؛ شناسهٔ مصنوعی قطعی از روی کد نظام پزشکی
$synthetic = 'imp_' . substr(md5($source . ':' . $code), 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(false); // تا مالک واقعی برنامهٔ کاری بسازد
$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($data['source_ref'] ?? $data['profile_url'] ?? null);
}
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']);
// روابط بر پایهٔ شناسه‌های مرجع (تخصص/استان/شهر)
$this->syncRefCollection($doctor->getSpecialties(), $data['specialties'] ?? null, Specialty::class);
$this->syncRefCollection($doctor->getProvinces(), $data['states'] ?? null, Province::class);
$this->syncRefCollection($doctor->getCities(), $data['cities'] ?? null, City::class);
$this->em->persist($doctor);
$this->em->flush();
return new DoctorImportResult($doctor, $created);
});
}
/**
* یک مجموعهٔ 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);
}
}
}
}
+25
View File
@@ -70,6 +70,31 @@ class ApiIrService
];
}
/**
* استعلام هویت شخص از روی کد ملی و تاریخ تولد (PersonInfo).
*
* @param string $birthDateJalali تاریخ تولد شمسی به فرمت Y/m/d (مثلاً 1371/1/1)
* @return array{firstName: string, lastName: string, alive: bool}|null null یعنی رکوردی مطابقت نکرد.
*/
public function personInfo(string $nationalCode, string $birthDateJalali): ?array
{
$data = $this->post('/api/sw1/PersonInfo', [
'nationalCode' => $nationalCode,
'birthDate' => $birthDateJalali,
]);
$person = $data['data'] ?? null;
if (!is_array($person) || ($person['nationalCode'] ?? '') === '') {
return null;
}
return [
'firstName' => (string) ($person['firstName'] ?? ''),
'lastName' => (string) ($person['lastName'] ?? ''),
'alive' => filter_var($person['alive'] ?? false, FILTER_VALIDATE_BOOLEAN),
];
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Shared\Util;
/**
* نرمال‌سازی متن فارسی برای مقایسهٔ نام‌ها.
*
* تفاوت‌های رایج را یکدست می‌کند: ي/ی عربی-فارسی، ك/ک، ارقام عربی/فارسی،
* نیم‌فاصله و فاصله‌های تکراری، فاصلهٔ ابتدا/انتها و Unicode normalization —
* تا مقایسهٔ نام هرگز با compare خام رشته انجام نشود.
*/
final class PersianText
{
public static function normalize(string $text): string
{
if (class_exists(\Normalizer::class)) {
$text = \Normalizer::normalize($text, \Normalizer::FORM_KC) ?: $text;
}
$text = strtr($text, [
"\u{064A}" => 'ی', // ي عربی
"\u{0649}" => 'ی', // ى الف مقصوره
"\u{0643}" => 'ک', // ك عربی
"\u{200C}" => ' ', // نیم‌فاصله → فاصله
"\u{200B}" => '', // zero-width space
"\u{FEFF}" => '', // BOM
"\u{0640}" => '', // کشیده ـ
]);
// ارقام فارسی/عربی → لاتین
$text = strtr($text, array_combine(
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٠','١','٢','٣','٤','٥','٦','٧','٨','٩'],
['0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'],
));
return trim(preg_replace('/\s+/u', ' ', $text) ?? $text);
}
/** مقایسهٔ دو نام فارسی پس از نرمال‌سازی. */
public static function sameName(string $a, string $b): bool
{
return self::normalize($a) === self::normalize($b);
}
/** حذف عنوان «دکتر» از ابتدای نام (برای مقایسهٔ نام پروفایل با نام ثبت احوال). */
public static function stripDoctorTitle(string $name): string
{
return trim(preg_replace('/^\s*دکتر\s+/u', '', self::normalize($name)) ?? $name);
}
}