- 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>
116 lines
4.3 KiB
PHP
116 lines
4.3 KiB
PHP
<?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,
|
|
];
|
|
}
|
|
}
|