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:
@@ -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'])]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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'); // کاربر سیستمی کرالر — لاگین با رمز؛ دسترسی فقط اندپوینت ایمپورت
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user