Files
clinicpro/src/Doctor/Entity/DoctorClaimRequest.php
T
hamedandClaude Opus 4.8 af125572c9 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>
2026-07-11 11:39:15 +03:30

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,
];
}
}